diff --git a/samples/OpenWeatherMapSharp.Console/Program.cs b/samples/OpenWeatherMapSharp.Console/Program.cs index 62001ac..dfb4545 100644 --- a/samples/OpenWeatherMapSharp.Console/Program.cs +++ b/samples/OpenWeatherMapSharp.Console/Program.cs @@ -87,4 +87,49 @@ OpenWeatherMapServiceResponse weatherResponse }; AnsiConsole.Write(weatherPanel); +// == AIR POLLUTION == +OpenWeatherMapServiceResponse airPollutionResponse + = await openWeatherMapService.GetAirPolutionAsync(geolocation.Latitude, geolocation.Longitude); + +if (!airPollutionResponse.IsSuccess || airPollutionResponse.Response is not AirPolutionRoot airQuality || airQuality.Entries.Count == 0) +{ + AnsiConsole.MarkupLine("[bold red]Unfortunately I can't retrieve air pollution data. Please try again.[/]"); + return; +} + +AirPolutionEntry pollution = airQuality.Entries.First(); + +// Map AQI to meaning +string GetAqiMeaning(int aqi) => aqi switch +{ + 1 => "[green]Good[/]", + 2 => "[yellow]Fair[/]", + 3 => "[orange1]Moderate[/]", + 4 => "[red]Poor[/]", + 5 => "[maroon]Very Poor[/]", + _ => "[grey]Unknown[/]" +}; + +// == AIR POLLUTION PANEL == +List pollutionMarkupList = +[ + new($"[red]Air Quality Index (AQI): [/]{pollution.AQI.Index} ({GetAqiMeaning(pollution.AQI.Index)})"), + new("-----"), + new($"[red]PM2.5: [/]{pollution.Components.FineParticlesMatter:0.00} µg/m³"), + new($"[red]PM10: [/]{pollution.Components.CoarseParticulateMatter:0.00} µg/m³"), + new($"[red]O₃ (Ozone): [/]{pollution.Components.Ozone:0.00} µg/m³"), + new($"[red]NOâ‚‚ (Nitrogen Dioxide): [/]{pollution.Components.NitrogenDioxide:0.00} µg/m³"), + new($"[red]SOâ‚‚ (Sulfur Dioxide): [/]{pollution.Components.SulfurDioxide:0.00} µg/m³"), + new($"[red]CO (Carbon Monoxide): [/]{pollution.Components.CarbonMonoxide:0.00} µg/m³"), + new($"[red]NH₃ (Ammonia): [/]{pollution.Components.Ammonia:0.00} µg/m³") +]; + +Panel pollutionPanel = new(new Rows(pollutionMarkupList)) +{ + Header = new PanelHeader("Air Pollution"), + Width = 120 +}; +AnsiConsole.Write(pollutionPanel); + + Console.ReadLine(); diff --git a/src/OpenWeatherMapSharp/IOpenWeatherMapService.cs b/src/OpenWeatherMapSharp/IOpenWeatherMapService.cs index d0acf45..14ce7ca 100644 --- a/src/OpenWeatherMapSharp/IOpenWeatherMapService.cs +++ b/src/OpenWeatherMapSharp/IOpenWeatherMapService.cs @@ -121,5 +121,39 @@ Task>> GetLocationByLatLonAsync( double latitude, double longitude, int limit = 5); + + /// + /// Retrieves current air pollution data for a specific location. + /// + /// Latitude of the location. + /// Longitude of the location. + /// Current air pollution data wrapped in a service response. + Task> GetAirPolutionAsync( + double latitude, + double longitude); + + /// + /// Retrieves forecasted air pollution data for the coming days for a specific location. + /// + /// Latitude of the location. + /// Longitude of the location. + /// Air pollution forecast data wrapped in a service response. + Task> GetAirPolutionForecastAsync( + double latitude, + double longitude); + + /// + /// Retrieves historical air pollution data for a specific location and time range. + /// + /// Latitude of the location. + /// Longitude of the location. + /// Start of the time range (UTC). + /// End of the time range (UTC). + /// Historical air pollution data wrapped in a service response. + Task> GetAirPolutionHistoryAsync( + double latitude, + double longitude, + DateTime start, + DateTime end); } } diff --git a/src/OpenWeatherMapSharp/Models/AirPolutionComponents.cs b/src/OpenWeatherMapSharp/Models/AirPolutionComponents.cs new file mode 100644 index 0000000..d3ee3e7 --- /dev/null +++ b/src/OpenWeatherMapSharp/Models/AirPolutionComponents.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Serialization; + +namespace OpenWeatherMapSharp.Models +{ + /// + /// Represents the concentration values + /// of various air pollutants (in μg/m³). + /// + public class AirPolutionComponents + { + /// + /// Carbon monoxide concentration. + /// + [JsonPropertyName("co")] + public double CarbonMonoxide { get; set; } + + /// + /// Nitric oxide concentration. + /// + [JsonPropertyName("no")] + public double NitrogenMonoxide { get; set; } + + /// + /// Nitrogen dioxide concentration. + /// + [JsonPropertyName("no2")] + public double NitrogenDioxide { get; set; } + + /// + /// Ozone concentration. + /// + [JsonPropertyName("o3")] + public double Ozone { get; set; } + + /// + /// Sulfur dioxide concentration. + /// + [JsonPropertyName("so2")] + public double SulfurDioxide { get; set; } + + /// + /// Fine particulate matter (PM2.5) concentration. + /// + [JsonPropertyName("pm2_5")] + public double FineParticlesMatter { get; set; } + + /// + /// Coarse particulate matter (PM10) concentration. + /// + [JsonPropertyName("pm10")] + public double CoarseParticulateMatter { get; set; } + + /// + /// Ammonia concentration. + /// + [JsonPropertyName("nh3")] + public double Ammonia { get; set; } + } +} diff --git a/src/OpenWeatherMapSharp/Models/AirPolutionEntry.cs b/src/OpenWeatherMapSharp/Models/AirPolutionEntry.cs new file mode 100644 index 0000000..1d222d4 --- /dev/null +++ b/src/OpenWeatherMapSharp/Models/AirPolutionEntry.cs @@ -0,0 +1,39 @@ +using OpenWeatherMapSharp.Utils; +using System; +using System.Text.Json.Serialization; + +namespace OpenWeatherMapSharp.Models +{ + /// + /// Represents a single entry of air quality data, + /// including AQI, components, and timestamp. + /// + public class AirPolutionEntry + { + /// + /// Main air quality index (AQI). + /// + [JsonPropertyName("main")] + public AirPolutionIndex AQI { get; set; } + + /// + /// Concentrations of individual air components. + /// + [JsonPropertyName("components")] + public AirPolutionComponents Components { get; set; } + + /// + /// Timestamp of the measurement (Unix time in seconds). + /// + [JsonPropertyName("dt")] + public long DateUnix { get; set; } + + /// + /// Timestamp of the weather data as a + /// UTC . + /// + [JsonIgnore] + public DateTime Date + => DateUnix.ToDateTime(); + } +} diff --git a/src/OpenWeatherMapSharp/Models/AirPolutionIndex.cs b/src/OpenWeatherMapSharp/Models/AirPolutionIndex.cs new file mode 100644 index 0000000..c0315f3 --- /dev/null +++ b/src/OpenWeatherMapSharp/Models/AirPolutionIndex.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace OpenWeatherMapSharp.Models +{ + /// + /// Represents the air quality index (AQI) value. + /// + public class AirPolutionIndex + { + /// + /// Air Quality Index (1 = Good, 5 = Very Poor). + /// + [JsonPropertyName("aqi")] + public int Index { get; set; } + } +} diff --git a/src/OpenWeatherMapSharp/Models/AirPolutionRoot.cs b/src/OpenWeatherMapSharp/Models/AirPolutionRoot.cs new file mode 100644 index 0000000..bfc3a5a --- /dev/null +++ b/src/OpenWeatherMapSharp/Models/AirPolutionRoot.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace OpenWeatherMapSharp.Models +{ + /// + /// Root object representing the air quality response from the API. + /// + public class AirPolutionRoot + { + /// + /// Geographic coordinates of the measurement location. + /// + [JsonPropertyName("coord")] + public Coordinates Coordinates { get; set; } + + /// + /// List of air polution measurements. + /// + [JsonPropertyName("list")] + public List Entries { get; set; } + } +} diff --git a/src/OpenWeatherMapSharp/OpenWeatherMapService.cs b/src/OpenWeatherMapSharp/OpenWeatherMapService.cs index 463ba19..1a9ab05 100644 --- a/src/OpenWeatherMapSharp/OpenWeatherMapService.cs +++ b/src/OpenWeatherMapSharp/OpenWeatherMapService.cs @@ -148,5 +148,26 @@ public async Task>> GetLocationB string url = string.Format(Statics.GeocodeReverseUri, latitude, longitude, limit, _apiKey); return await HttpService.GetDataAsync>(url); } + + /// + public async Task> GetAirPolutionAsync(double latitude, double longitude) + { + string url = string.Format(Statics.AirPollutionCoordinatesUri, latitude, longitude, _apiKey); + return await HttpService.GetDataAsync(url); + } + + /// + public async Task> GetAirPolutionForecastAsync(double latitude, double longitude) + { + string url = string.Format(Statics.AirPollutionCoordinatesForecastUri, latitude, longitude, _apiKey); + return await HttpService.GetDataAsync(url); + } + + /// + public async Task> GetAirPolutionHistoryAsync(double latitude, double longitude, DateTime start, DateTime end) + { + string url = string.Format(Statics.AirPollutionCoordinatesHistoryUri, latitude, longitude, start.ToUnixTimestamp(), end.ToUnixTimestamp(), _apiKey); + return await HttpService.GetDataAsync(url); + } } } diff --git a/src/OpenWeatherMapSharp/Utils/DateTimeExtensions.cs b/src/OpenWeatherMapSharp/Utils/DateTimeExtensions.cs new file mode 100644 index 0000000..fda0aff --- /dev/null +++ b/src/OpenWeatherMapSharp/Utils/DateTimeExtensions.cs @@ -0,0 +1,27 @@ +using System; + +namespace OpenWeatherMapSharp.Utils +{ + /// + /// Provides extension methods for converting + /// a to a Unix timestamps. + /// + internal static class DateTimeExtensions + { + /// + /// Converts a DateTime object to a Unix timestamp (seconds since 1970-01-01T00:00:00Z). + /// + /// The DateTime to convert. + /// Should be in UTC or convertible to UTC. + /// The Unix timestamp in seconds. + public static long ToUnixTimestamp(this DateTime dateTime) + { + // Ensure the DateTime is in UTC + var utcDateTime = dateTime.Kind == DateTimeKind.Utc + ? dateTime + : dateTime.ToUniversalTime(); + + return new DateTimeOffset(utcDateTime).ToUnixTimeSeconds(); + } + } +} diff --git a/src/OpenWeatherMapSharp/Utils/LongExtensions.cs b/src/OpenWeatherMapSharp/Utils/LongExtensions.cs index df55bee..d2ef150 100644 --- a/src/OpenWeatherMapSharp/Utils/LongExtensions.cs +++ b/src/OpenWeatherMapSharp/Utils/LongExtensions.cs @@ -17,12 +17,12 @@ internal static class LongExtensions /// The Unix timestamp to convert. /// /// A object representing - /// the local time. + /// the universal time. /// internal static DateTime ToDateTime(this long unixTimeStamp) { DateTime dateTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - return dateTime.AddSeconds(unixTimeStamp).ToLocalTime(); + return dateTime.AddSeconds(unixTimeStamp).ToUniversalTime(); } } } diff --git a/src/OpenWeatherMapSharp/Utils/Statics.cs b/src/OpenWeatherMapSharp/Utils/Statics.cs index 32129ef..d0eb36a 100644 --- a/src/OpenWeatherMapSharp/Utils/Statics.cs +++ b/src/OpenWeatherMapSharp/Utils/Statics.cs @@ -17,6 +17,9 @@ private static readonly string ForecastBaseUri private static readonly string GeocodeBaseUri = $"{BaseUri}/geo/1.0"; + private static readonly string AirPollutionBaseUri + = $"{BaseUri}/data/2.5/air_pollution"; + /// /// Weather by geographic coordinates (latitude, longitude). @@ -80,5 +83,26 @@ public static readonly string GeocodeZipUri /// public static readonly string GeocodeReverseUri = GeocodeBaseUri + "/reverse?lat={0}&lon={1}&limit={2}&appid={3}"; + + /// + /// Air pollution data by geographic coordinates. + /// Format: lat={0}&lon={1}&appid={2} + /// + public static readonly string AirPollutionCoordinatesUri + = AirPollutionBaseUri + "?lat={0}&lon={1}&appid={2}"; + + /// + /// Air pollution forecast by geographic coordinates. + /// Format: lat={0}&lon={1}&start={2}&end={3}&appid={4} + /// + public static readonly string AirPollutionCoordinatesForecastUri + = AirPollutionBaseUri + "/forecast?lat={0}&lon={1}&appid={2}"; + + /// + /// Air pollution history by geographic coordinates. + /// Format: lat={0}&lon={1}&start={2}&end={3}&appid={4} + /// + public static readonly string AirPollutionCoordinatesHistoryUri + = AirPollutionBaseUri + "/history?lat={0}&lon={1}&start={2}&end={3}&appid={4}"; } } diff --git a/tests/OpenWeatherMapSharp.UnitTests/OpenWeatherMapServiceTests.cs b/tests/OpenWeatherMapSharp.UnitTests/OpenWeatherMapServiceTests.cs index a751675..e21aebc 100644 --- a/tests/OpenWeatherMapSharp.UnitTests/OpenWeatherMapServiceTests.cs +++ b/tests/OpenWeatherMapSharp.UnitTests/OpenWeatherMapServiceTests.cs @@ -265,4 +265,80 @@ OpenWeatherMapServiceResponse response Assert.False(response.IsSuccess); Assert.NotNull(response.Error); } + + [Fact] + public async Task GetAirPollution_ShouldReturnValidResponse() + { + // Arrange + OpenWeatherMapService service = new(OPENWEATHERMAPAPIKEY); + double latitude = 48.89; + double longitude = 8.69; + + // Act + OpenWeatherMapServiceResponse response = + await service.GetAirPolutionAsync(latitude, longitude); + + // Assert + Assert.NotNull(response); + Assert.True(response.IsSuccess); + Assert.NotNull(response.Response); + Assert.Null(response.Error); + + var entry = response.Response.Entries.FirstOrDefault(); + Assert.NotNull(entry); + Assert.InRange(entry.Components.CoarseParticulateMatter, 0, 1000); // basic plausibility check + } + + [Fact] + public async Task GetAirPollutionForecast_ShouldReturnFutureEntries() + { + // Arrange + OpenWeatherMapService service = new(OPENWEATHERMAPAPIKEY); + double latitude = 48.89; + double longitude = 8.69; + + // Act + OpenWeatherMapServiceResponse response = + await service.GetAirPolutionForecastAsync(latitude, longitude); + + // Assert + Assert.NotNull(response); + Assert.True(response.IsSuccess); + Assert.NotNull(response.Response); + Assert.Null(response.Error); + + var first = response.Response.Entries.FirstOrDefault(); + Assert.NotNull(first); + Assert.True(first.Date > DateTime.UtcNow.AddHours(-1)); + } + + [Fact] + public async Task GetAirPollutionHistory_ShouldReturnEntriesInTimeRange() + { + // Arrange + OpenWeatherMapService service = new(OPENWEATHERMAPAPIKEY); + double latitude = 48.89; + double longitude = 8.69; + + // Use a valid past time range (e.g. 3–2 days ago) + DateTime end = DateTime.UtcNow.AddDays(-2); + DateTime start = end.AddHours(-24); + + // Act + OpenWeatherMapServiceResponse response = + await service.GetAirPolutionHistoryAsync(latitude, longitude, start, end); + + // Assert + Assert.NotNull(response); + Assert.True(response.IsSuccess); + Assert.NotNull(response.Response); + Assert.Null(response.Error); + Assert.NotEmpty(response.Response.Entries); + + // Ensure all entries are within the time range + foreach (var entry in response.Response.Entries) + { + Assert.InRange(entry.Date, start, end); + } + } } \ No newline at end of file