179 lines
6.4 KiB
C#
179 lines
6.4 KiB
C#
using Flurl.Http;
|
||
using Newtonsoft.Json;
|
||
|
||
namespace InnovEnergy.App.Backend.Services;
|
||
|
||
public static class WeatherService
|
||
{
|
||
public record DailyWeather(
|
||
string Date,
|
||
double TempMin,
|
||
double TempMax,
|
||
double SunshineHours,
|
||
double PrecipitationMm,
|
||
string Description
|
||
);
|
||
|
||
private static readonly Dictionary<string, (double Lat, double Lon)> GeoCache = new();
|
||
|
||
/// <summary>
|
||
/// Returns a 7-day weather forecast for the given city, or null on any failure.
|
||
/// </summary>
|
||
public static async Task<List<DailyWeather>?> GetForecastAsync(string? city, string? country, string? region = null)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(city))
|
||
return null;
|
||
|
||
try
|
||
{
|
||
var coords = await GeocodeAsync(city, region);
|
||
if (coords == null)
|
||
return null;
|
||
|
||
var (lat, lon) = coords.Value;
|
||
return await FetchForecastAsync(lat, lon);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.Error.WriteLine($"[WeatherService] Error fetching forecast for '{city}': {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formats a forecast list into a compact text block for AI prompt injection.
|
||
/// </summary>
|
||
public static string FormatForPrompt(List<DailyWeather> forecast)
|
||
{
|
||
var lines = forecast.Select(d =>
|
||
{
|
||
var date = DateTime.Parse(d.Date);
|
||
var dayName = date.ToString("ddd dd MMM");
|
||
return $"- {dayName}: {d.TempMin:F0}–{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
|
||
});
|
||
|
||
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts a geocodable city name from a Location field that may contain a full address.
|
||
/// Handles Swiss formats like "Street 5, 8604 Volketswil" → "Volketswil"
|
||
/// Also tries the Region field as fallback.
|
||
/// </summary>
|
||
private static IEnumerable<string> ExtractSearchTerms(string city, string? region)
|
||
{
|
||
// If it contains a comma, try the part after the last comma (often "PostalCode City")
|
||
if (city.Contains(','))
|
||
{
|
||
var afterComma = city.Split(',').Last().Trim();
|
||
// Strip leading postal code (digits) → "8604 Volketswil" → "Volketswil"
|
||
var withoutPostal = System.Text.RegularExpressions.Regex.Replace(afterComma, @"^\d+\s*", "").Trim();
|
||
if (!string.IsNullOrEmpty(withoutPostal))
|
||
yield return withoutPostal;
|
||
if (!string.IsNullOrEmpty(afterComma))
|
||
yield return afterComma;
|
||
}
|
||
|
||
// Try the raw value as-is
|
||
yield return city;
|
||
|
||
// Fallback to Region
|
||
if (!string.IsNullOrWhiteSpace(region))
|
||
yield return region;
|
||
}
|
||
|
||
private static async Task<(double Lat, double Lon)?> GeocodeAsync(string city, string? region = null)
|
||
{
|
||
if (GeoCache.TryGetValue(city, out var cached))
|
||
return cached;
|
||
|
||
foreach (var term in ExtractSearchTerms(city, region))
|
||
{
|
||
var url = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(term)}&count=1&language=en";
|
||
var json = await url.GetStringAsync();
|
||
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||
|
||
if (data?.results != null && data.results.Count > 0)
|
||
{
|
||
var lat = (double)data.results[0].latitude;
|
||
var lon = (double)data.results[0].longitude;
|
||
|
||
GeoCache[city] = (lat, lon);
|
||
Console.WriteLine($"[WeatherService] Geocoded '{city}' (matched on '{term}') → ({lat:F4}, {lon:F4})");
|
||
return (lat, lon);
|
||
}
|
||
}
|
||
|
||
Console.WriteLine($"[WeatherService] Could not geocode location: '{city}'");
|
||
return null;
|
||
}
|
||
|
||
private static async Task<List<DailyWeather>?> FetchForecastAsync(double lat, double lon)
|
||
{
|
||
var url = $"https://api.open-meteo.com/v1/forecast"
|
||
+ $"?latitude={lat}&longitude={lon}"
|
||
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
|
||
+ "&timezone=Europe/Zurich&forecast_days=7";
|
||
|
||
var json = await url.GetStringAsync();
|
||
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||
|
||
if (data?.daily == null)
|
||
return null;
|
||
|
||
var dates = data.daily.time;
|
||
var tempMax = data.daily.temperature_2m_max;
|
||
var tempMin = data.daily.temperature_2m_min;
|
||
var sun = data.daily.sunshine_duration;
|
||
var precip = data.daily.precipitation_sum;
|
||
var codes = data.daily.weathercode;
|
||
|
||
var forecast = new List<DailyWeather>();
|
||
for (int i = 0; i < dates.Count; i++)
|
||
{
|
||
forecast.Add(new DailyWeather(
|
||
Date: (string)dates[i],
|
||
TempMin: (double)tempMin[i],
|
||
TempMax: (double)tempMax[i],
|
||
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
|
||
PrecipitationMm: (double)precip[i],
|
||
Description: WeatherCodeToDescription((int)codes[i])
|
||
));
|
||
}
|
||
|
||
Console.WriteLine($"[WeatherService] Fetched {forecast.Count}-day forecast.");
|
||
return forecast;
|
||
}
|
||
|
||
private static string WeatherCodeToDescription(int code) => code switch
|
||
{
|
||
0 => "Clear sky",
|
||
1 => "Mainly clear",
|
||
2 => "Partly cloudy",
|
||
3 => "Overcast",
|
||
45 => "Fog",
|
||
48 => "Depositing rime fog",
|
||
51 => "Light drizzle",
|
||
53 => "Moderate drizzle",
|
||
55 => "Dense drizzle",
|
||
61 => "Slight rain",
|
||
63 => "Moderate rain",
|
||
65 => "Heavy rain",
|
||
66 => "Light freezing rain",
|
||
67 => "Heavy freezing rain",
|
||
71 => "Slight snow",
|
||
73 => "Moderate snow",
|
||
75 => "Heavy snow",
|
||
77 => "Snow grains",
|
||
80 => "Slight showers",
|
||
81 => "Moderate showers",
|
||
82 => "Violent showers",
|
||
85 => "Slight snow showers",
|
||
86 => "Heavy snow showers",
|
||
95 => "Thunderstorm",
|
||
96 => "Thunderstorm with slight hail",
|
||
99 => "Thunderstorm with heavy hail",
|
||
_ => "Unknown"
|
||
};
|
||
}
|