-
Notifications
You must be signed in to change notification settings - Fork 0
Automated PR feature/calculate-aqi-service into Main #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
80a80d4
ddecd6c
738bd3e
994a422
66f055a
7af0d1a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| using AirAware.Data; | ||
| using AirAware.Models; | ||
| using AirAware.Services; | ||
| using AirAware.ViewModels; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.EntityFrameworkCore; | ||
|
|
@@ -41,6 +42,7 @@ [FromRoute] Guid id | |
| [HttpPost("readings")] | ||
| public async Task<IActionResult> PostAsync( | ||
| [FromServices] AppDbContext context, | ||
| [FromServices] IAqiCalculator aqiCalculator, | ||
| [FromBody] CreateReadingViewModel model | ||
| ) | ||
| { | ||
|
|
@@ -55,19 +57,66 @@ [FromBody] CreateReadingViewModel model | |
| if (station == null) | ||
| return BadRequest("Station with the provided ID does not exist."); | ||
|
|
||
| double? pm10 = model.Pm10; | ||
| if (!pm10.HasValue && !string.IsNullOrWhiteSpace(model.RawPayload)) | ||
| { | ||
| try | ||
| { | ||
| using var doc = System.Text.Json.JsonDocument.Parse(model.RawPayload); | ||
| var root = doc.RootElement; | ||
|
|
||
| if (root.TryGetProperty("pm10", out var p10) && p10.TryGetDouble(out var val10)) | ||
| pm10 = val10; | ||
| else if (root.TryGetProperty("pm_10", out var p10b) && p10b.TryGetDouble(out var val10b)) | ||
| pm10 = val10b; | ||
| else if (root.TryGetProperty("pm10_atm", out var p10c) && p10c.TryGetDouble(out var val10c)) | ||
| pm10 = val10c; | ||
| // add provider specific paths here | ||
| } | ||
| catch | ||
| { | ||
| // parsing failed — ignore and continue (we already have pm25) | ||
| } | ||
| } | ||
|
|
||
| var reading = new Reading | ||
| { | ||
| StationId = model.StationId, | ||
| Pm2_5 = model.Pm2_5, | ||
| Pm10 = model.Pm10, | ||
| Pm25 = model.Pm25, | ||
| Pm10 = pm10, | ||
| RawPayload = model.RawPayload | ||
| }; | ||
|
|
||
| try | ||
| { | ||
| await context.Readings.AddAsync(reading); | ||
| await context.SaveChangesAsync(); | ||
| return Created($"api/v1/readings/{reading.Id}", reading); | ||
|
|
||
| // compute AQI synchronously | ||
| var (final, pm25Result, pm10Result) = aqiCalculator.Calculate(reading); | ||
|
|
||
|
Comment on lines
92
to
+97
|
||
| var aqiRecord = new AqiRecord | ||
| { | ||
| ReadingId = reading.Id, | ||
| StationId = reading.StationId, | ||
| AqiValue = final.Value, | ||
| Category = final.Category, | ||
| Pm25Aqi = pm25Result.Value, | ||
| Pm25Category = pm25Result.Category, | ||
| Pm10Aqi = pm10Result.Value, | ||
| Pm10Category = pm10Result.Category, | ||
| ComputedAt = DateTime.UtcNow | ||
| }; | ||
|
|
||
| // Optional: ensure unique insert by checking existing AqiRecords for reading.Id | ||
| var existing = await context.AqiRecords.FirstOrDefaultAsync(a => a.ReadingId == reading.Id); | ||
| if (existing == null) | ||
| { | ||
| await context.AqiRecords.AddAsync(aqiRecord); | ||
| await context.SaveChangesAsync(); | ||
| } | ||
|
|
||
|
joaoferreira-dev marked this conversation as resolved.
|
||
| return Created($"api/v1/readings/{reading.Id}", new { reading, aqi = aqiRecord }); | ||
| } | ||
| catch (Exception) | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace AirAware.Models; | ||
|
|
||
| public class AqiRecord | ||
| { | ||
| public Guid Id { get; set; } = Guid.NewGuid(); | ||
| public Guid ReadingId { get; set; } | ||
| [JsonIgnore] | ||
| public Reading Reading { get; set; } = null!; | ||
| public Guid StationId { get; set; } | ||
| [JsonIgnore] | ||
| public Station Station { get; set; } = null!; | ||
| public int AqiValue { get; set; } | ||
| public int? Pm25Aqi { get; set; } | ||
| public int? Pm10Aqi { get; set; } | ||
| public string Category { get; set; } = string.Empty; | ||
| public string? Pm25Category { get; set; } | ||
| public string? Pm10Category { get; set; } | ||
| public DateTime ComputedAt { get; set; } = DateTime.Now; | ||
|
joaoferreira-dev marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| using AirAware.Models; | ||
|
|
||
| namespace AirAware.Services; | ||
|
|
||
| public class AqiCalculator : IAqiCalculator | ||
| { | ||
| // Breakpoint tuple: (C_lo, C_hi, I_lo, I_hi, Category) | ||
| private readonly List<(double Clo, double Chi, int Ilo, int Ihi, string Category)> _pm25Breakpoints = | ||
| new List<(double, double, int, int, string)> | ||
| { | ||
| (0.0, 12.0, 0, 50, "Good"), | ||
| (12.1, 35.4, 51, 100, "Moderate"), | ||
| (35.5, 55.4, 101, 150, "Unhealthy for Sensitive Groups"), | ||
| (55.5, 150.4, 151, 200, "Unhealthy"), | ||
| (150.5, 250.4, 201, 300, "Very Unhealthy"), | ||
| (250.5, 500.4, 301, 500, "Hazardous") | ||
| }; | ||
|
|
||
| private readonly List<(double Clo, double Chi, int Ilo, int Ihi, string Category)> _pm10Breakpoints = | ||
| new List<(double, double, int, int, string)> | ||
| { | ||
| (0, 54, 0, 50, "Good"), | ||
| (55, 154, 51, 100, "Moderate"), | ||
| (155, 254, 101, 150, "Unhealthy for Sensitive Groups"), | ||
| (255, 354, 151, 200, "Unhealthy"), | ||
| (355, 424, 201, 300, "Very Unhealthy"), | ||
| (425, 504, 301, 500, "Hazardous") | ||
| }; | ||
|
|
||
| public (AqiResult final, AqiResult pm25, AqiResult pm10) Calculate(Reading reading) | ||
| { | ||
| var pm25Result = CalculateForPm25(reading.Pm25); | ||
| var pm10Result = CalculateForPm10(reading.Pm10 ?? 0); | ||
|
|
||
| // Final AQI is the highest individual pollutant AQI | ||
| var final = pm25Result.Value >= pm10Result.Value ? pm25Result : pm10Result; | ||
| return (final, pm25Result, pm10Result); | ||
| } | ||
|
|
||
| public AqiResult CalculateForPm25(double concentration) | ||
| { | ||
| return CalculateFromBreakpoints(concentration, _pm25Breakpoints, "PM2.5"); | ||
| } | ||
|
|
||
| public AqiResult CalculateForPm10(double concentration) | ||
| { | ||
| return CalculateFromBreakpoints(concentration, _pm10Breakpoints, "PM10"); | ||
| } | ||
|
|
||
| private AqiResult CalculateFromBreakpoints( | ||
| double concentration, | ||
| List<(double Clo, double Chi, int Ilo, int Ihi, string Category)> breakpoints, | ||
| string pollutant) | ||
| { | ||
| // Handle values above highest breakpoint by capping to maximum defined breakpoint (EPA defines up to 500) | ||
| var bp = breakpoints.Find(b => concentration >= b.Clo && concentration <= b.Chi); | ||
|
|
||
| if (bp == default) | ||
| { | ||
| // If below first breakpoint and not matched because of precision, handle explicitly | ||
| if (concentration < breakpoints[0].Clo) | ||
| { | ||
| var first = breakpoints[0]; | ||
| int aqi = first.Ilo; | ||
| return new AqiResult(aqi, first.Category, pollutant); | ||
| } | ||
|
|
||
| // If concentration is above highest Chi, cap to highest range using highest interval Ihi | ||
| var last = breakpoints[breakpoints.Count - 1]; | ||
| int cappedAqi = last.Ihi; | ||
| return new AqiResult(cappedAqi, last.Category, pollutant); | ||
| } | ||
|
|
||
| // Apply the linear interpolation formula: | ||
| // I = (Ihi - Ilo)/(Chi - Clo) * (C - Clo) + Ilo | ||
| double Ihi = bp.Ihi; | ||
| double Ilo = bp.Ilo; | ||
| double Chi = bp.Chi; | ||
| double Clo = bp.Clo; | ||
| double C = concentration; | ||
|
|
||
| double I = ((Ihi - Ilo) / (Chi - Clo)) * (C - Clo) + Ilo; | ||
| int rounded = (int)Math.Round(I, MidpointRounding.AwayFromZero); | ||
|
|
||
| return new AqiResult(rounded, bp.Category, pollutant); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| using AirAware.Models; | ||
|
|
||
| namespace AirAware.Services; | ||
|
|
||
| public interface IAqiCalculator | ||
| { | ||
| /// <summary> | ||
| /// Calculates AQI for a reading. Returns the selected final AQI (higher of PM2.5 or PM10), | ||
| /// and the individual results for PM2.5 and PM10. | ||
| /// </summary> | ||
| (AqiResult final, AqiResult pm25, AqiResult pm10) Calculate(Reading reading); | ||
|
|
||
| AqiResult CalculateForPm25(double concentration); | ||
| AqiResult CalculateForPm10(double concentration); | ||
| } | ||
|
|
||
| public record AqiResult(int Value, string Category, string Pollutant); |
Uh oh!
There was an error while loading. Please reload this page.