Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions AirAware/Controllers/ReadingController.cs
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;
Expand Down Expand Up @@ -41,6 +42,7 @@ [FromRoute] Guid id
[HttpPost("readings")]
public async Task<IActionResult> PostAsync(
[FromServices] AppDbContext context,
[FromServices] IAqiCalculator aqiCalculator,
[FromBody] CreateReadingViewModel model
)
{
Expand All @@ -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)
}
}
Comment thread
joaoferreira-dev marked this conversation as resolved.

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
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Readings are saved, then AqiRecords are saved in a separate SaveChangesAsync() without a transaction. If the AQI insert fails, you’ll persist a reading without its AQI record. Consider adding the AqiRecord to the context and saving both in a single SaveChangesAsync() (or use an explicit transaction).

Copilot uses AI. Check for mistakes.
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();
}

Comment thread
joaoferreira-dev marked this conversation as resolved.
return Created($"api/v1/readings/{reading.Id}", new { reading, aqi = aqiRecord });
}
catch (Exception)
{
Expand Down
35 changes: 35 additions & 0 deletions AirAware/Controllers/StationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,39 @@ [FromRoute] Guid id
return StatusCode(StatusCodes.Status500InternalServerError);
}
}

[HttpGet]
[Route("stations/{id}/aqi/latest")]
public async Task<IActionResult> GetLatestAqiForStation(
[FromServices] AppDbContext context,
[FromRoute] Guid id
)
{
// Ensure station exists
var station = await context.Stations.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id);
if (station == null) return NotFound("Station not found.");

// Get latest AQI record for this station, including its reading
var latest = await context.AqiRecords
.AsNoTracking()
.Where(a => a.StationId == id)
.OrderByDescending(a => a.ComputedAt)
.Join(context.Readings.AsNoTracking(),
a => a.ReadingId,
r => r.Id,
(a, r) => new { Aqi = a, Reading = r })
.Select(ar => new
{
ar.Aqi.Id,
ar.Aqi.AqiValue,
ar.Aqi.Category,
ar.Aqi.ComputedAt,
Reading = new { ar.Reading.Id, ar.Reading.Pm25, ar.Reading.Pm10, ar.Reading.CreatedAt }
})
.FirstOrDefaultAsync();

if (latest == null) return NotFound("No AQI records for station.");

return Ok(latest);
}
}
1 change: 1 addition & 0 deletions AirAware/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class AppDbContext: DbContext
{
public DbSet<Station> Stations { get; set; }
public DbSet<Reading> Readings { get; set; }
public DbSet<AqiRecord> AqiRecords { get; set; }
Comment thread
joaoferreira-dev marked this conversation as resolved.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseSqlite("DataSource=app.db;Cache=Shared");
Expand Down
21 changes: 21 additions & 0 deletions AirAware/Models/AqiRecord.cs
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;
Comment thread
joaoferreira-dev marked this conversation as resolved.
}
4 changes: 2 additions & 2 deletions AirAware/Models/Reading.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ public class Reading
public Guid StationId { get; set; }
[JsonIgnore]
public Station Station { get; set; } = null!;
public double Pm2_5 { get; set; }
public double Pm10 { get; set; }
public double Pm25 { get; set; }
public double? Pm10 { get; set; }
public string? RawPayload { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.Now;
}
87 changes: 87 additions & 0 deletions AirAware/Services/AqiCalculator.cs
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);
}
}
17 changes: 17 additions & 0 deletions AirAware/Services/IAqiCalculator.cs
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);
2 changes: 2 additions & 0 deletions AirAware/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AirAware.Data;
using AirAware.Services;

namespace AirAware;

Expand All @@ -7,6 +8,7 @@ public class Startup
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddScoped<IAqiCalculator, AqiCalculator>();
services.AddDbContext<AppDbContext>();
}

Expand Down
5 changes: 2 additions & 3 deletions AirAware/ViewModels/CreateReadingViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ public class CreateReadingViewModel
[Required]
public Guid StationId { get; set; }
[Required]
public double Pm2_5 { get; set; }
[Required]
public double Pm10 { get; set; }
public double Pm25 { get; set; }
Comment thread
joaoferreira-dev marked this conversation as resolved.
public double? Pm10 { get; set; }
public string? RawPayload { get; set; }
}