Skip to content

add summary chart components #361

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

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions air-quality-backend/api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
origins = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:8000",
"http://127.0.0.1:8000",
]

load_dotenv()
Expand Down
73 changes: 45 additions & 28 deletions air-quality-backend/api/src/mappers/forecast_mapper.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,45 @@
from datetime import UTC
from typing import List

from src.types import ForecastDto
from shared.src.database.forecasts import Forecast


def database_to_api_result(measurement: Forecast) -> ForecastDto:
return {
"base_time": measurement["forecast_base_time"].astimezone(UTC),
"valid_time": measurement["forecast_valid_time"].astimezone(UTC),
"location_type": measurement["location_type"],
"location_name": measurement["name"],
"location": {
"longitude": measurement["location"]["coordinates"][0],
"latitude": measurement["location"]["coordinates"][1],
},
"overall_aqi_level": measurement["overall_aqi_level"],
"no2": measurement["no2"],
"o3": measurement["o3"],
"pm2_5": measurement["pm2_5"],
"pm10": measurement["pm10"],
"so2": measurement["so2"],
}


def map_forecast(measurements_from_database: List[Forecast]) -> List[ForecastDto]:
return list(map(database_to_api_result, measurements_from_database))
from datetime import UTC
from typing import List

from src.types import ForecastDto
from shared.src.database.forecasts import Forecast


def database_to_api_result(measurement: Forecast) -> ForecastDto:
return {
"base_time": measurement["forecast_base_time"].astimezone(UTC),
"valid_time": measurement["forecast_valid_time"].astimezone(UTC),
"location_type": measurement["location_type"],
"location_name": measurement["name"],
"location": {
"longitude": measurement["location"]["coordinates"][0],
"latitude": measurement["location"]["coordinates"][1],
},
"overall_aqi_level": measurement["overall_aqi_level"],
"no2": measurement["no2"],
"o3": measurement["o3"],
"pm2_5": measurement["pm2_5"],
"pm10": measurement["pm10"],
"so2": measurement["so2"],
}


def map_forecast(measurements_from_database: List[Forecast]) -> List[ForecastDto]:
return list(map(database_to_api_result, measurements_from_database))


def map_measurement_counts(measurements_from_database: List[Forecast]) -> dict:
"""Maps database measurements to a count of measurements per city and pollutant"""
counts = {}
pollutants = ["no2", "o3", "pm2_5", "pm10", "so2"]

for measurement in measurements_from_database:
city = measurement["location_name"]
if city not in counts:
counts[city] = {pollutant: 0 for pollutant in pollutants}

for pollutant in pollutants:
if measurement[pollutant] is not None:
counts[city][pollutant] += 1

return counts
195 changes: 127 additions & 68 deletions air-quality-backend/api/src/mappers/measurements_mapper.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,127 @@
from datetime import UTC
from typing import List

from src.types import MeasurementDto, MeasurementSummaryDto
from shared.src.aqi.calculator import get_pollutant_index_level, get_overall_aqi_level
from shared.src.aqi.pollutant_type import PollutantType
from shared.src.database.in_situ import InSituMeasurement, InSituAveragedMeasurement


def map_measurement(measurement: InSituMeasurement) -> MeasurementDto:
return {
"measurement_date": measurement["measurement_date"].astimezone(UTC),
"location_type": measurement["location_type"],
"location_name": measurement["name"],
"location": {
"longitude": measurement["location"]["coordinates"][0],
"latitude": measurement["location"]["coordinates"][1],
},
**{
pollutant_type.value: measurement[pollutant_type.literal()]["value"]
for pollutant_type in PollutantType
if pollutant_type.value in measurement
},
"api_source": measurement["api_source"],
"entity": measurement["metadata"]["entity"],
"sensor_type": measurement["metadata"]["sensor_type"],
"site_name": measurement["location_name"],
}


def map_measurements(measurements: list[InSituMeasurement]) -> list[MeasurementDto]:
return list(map(map_measurement, measurements))


def map_summarized_measurement(
measurement: InSituAveragedMeasurement,
) -> MeasurementSummaryDto:
pollutant_data = {}
mean_aqi_values = []
for pollutant_type in PollutantType:
pollutant_value = pollutant_type.literal()
avg_value = measurement[pollutant_value]["mean"]
if avg_value is not None:
aqi = get_pollutant_index_level(
avg_value,
pollutant_type,
)
if pollutant_value not in pollutant_data:
pollutant_data[pollutant_value] = {}
pollutant_data[pollutant_value]["mean"] = {
"aqi_level": aqi,
"value": avg_value,
}
mean_aqi_values.append(aqi)

return {
"measurement_base_time": measurement["measurement_base_time"],
"location_type": measurement["location_type"],
"location_name": measurement["name"],
"overall_aqi_level": {"mean": get_overall_aqi_level(mean_aqi_values)},
**pollutant_data,
}


def map_summarized_measurements(
averages: List[InSituAveragedMeasurement],
) -> List[MeasurementSummaryDto]:
return list(map(map_summarized_measurement, averages))
from datetime import UTC
from typing import List

from src.types import MeasurementDto, MeasurementSummaryDto
from shared.src.aqi.calculator import get_pollutant_index_level, get_overall_aqi_level
from shared.src.aqi.pollutant_type import PollutantType
from shared.src.database.in_situ import InSituMeasurement, InSituAveragedMeasurement


def map_measurement(measurement: InSituMeasurement) -> MeasurementDto:
return {
"measurement_date": measurement["measurement_date"].astimezone(UTC),
"location_type": measurement["location_type"],
"location_name": measurement["name"],
"location": {
"longitude": measurement["location"]["coordinates"][0],
"latitude": measurement["location"]["coordinates"][1],
},
**{
pollutant_type.value: measurement[pollutant_type.literal()]["value"]
for pollutant_type in PollutantType
if pollutant_type.value in measurement
},
"api_source": measurement["api_source"],
"entity": measurement["metadata"]["entity"],
"sensor_type": measurement["metadata"]["sensor_type"],
"site_name": measurement["location_name"],
}


def map_measurements(measurements: list[InSituMeasurement]) -> list[MeasurementDto]:
return list(map(map_measurement, measurements))


def map_summarized_measurement(
measurement: InSituAveragedMeasurement,
) -> MeasurementSummaryDto:
pollutant_data = {}
mean_aqi_values = []
for pollutant_type in PollutantType:
pollutant_value = pollutant_type.literal()
avg_value = measurement[pollutant_value]["mean"]
if avg_value is not None:
aqi = get_pollutant_index_level(
avg_value,
pollutant_type,
)
if pollutant_value not in pollutant_data:
pollutant_data[pollutant_value] = {}
pollutant_data[pollutant_value]["mean"] = {
"aqi_level": aqi,
"value": avg_value,
}
mean_aqi_values.append(aqi)

return {
"measurement_base_time": measurement["measurement_base_time"],
"location_type": measurement["location_type"],
"location_name": measurement["name"],
"overall_aqi_level": {"mean": get_overall_aqi_level(mean_aqi_values)},
**pollutant_data,
}


def map_summarized_measurements(
averages: List[InSituAveragedMeasurement],
) -> List[MeasurementSummaryDto]:
return list(map(map_summarized_measurement, averages))


def map_measurement_counts(measurements):
"""Map measurements to counts by city and pollutant, including unique location counts per pollutant"""
counts = {}
location_sets = {} # Track unique locations per city and pollutant

for measurement in measurements:
city = measurement["name"]
if city not in counts:
counts[city] = {
"so2": 0,
"no2": 0,
"o3": 0,
"pm10": 0,
"pm2_5": 0,
"so2_locations": 0,
"no2_locations": 0,
"o3_locations": 0,
"pm10_locations": 0,
"pm2_5_locations": 0,
}
location_sets[city] = {
"so2": set(),
"no2": set(),
"o3": set(),
"pm10": set(),
"pm2_5": set(),
}

location_name = measurement["location_name"]

# Count measurements and track locations per pollutant
if "so2" in measurement and measurement["so2"] is not None:
counts[city]["so2"] += 1
location_sets[city]["so2"].add(location_name)

if "no2" in measurement and measurement["no2"] is not None:
counts[city]["no2"] += 1
location_sets[city]["no2"].add(location_name)

if "o3" in measurement and measurement["o3"] is not None:
counts[city]["o3"] += 1
location_sets[city]["o3"].add(location_name)

if "pm10" in measurement and measurement["pm10"] is not None:
counts[city]["pm10"] += 1
location_sets[city]["pm10"].add(location_name)

if "pm2_5" in measurement and measurement["pm2_5"] is not None:
counts[city]["pm2_5"] += 1
location_sets[city]["pm2_5"].add(location_name)

# Add location counts per pollutant to the final output
for city in counts:
for pollutant in ["so2", "no2", "o3", "pm10", "pm2_5"]:
counts[city][f"{pollutant}_locations"] = len(location_sets[city][pollutant])

return counts
26 changes: 25 additions & 1 deletion air-quality-backend/api/src/measurements_controller.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import logging as log
from datetime import datetime
from typing import List
from typing import List, Dict

from fastapi import Query, APIRouter

from src.mappers.measurements_mapper import (
map_measurements,
map_summarized_measurements,
map_measurement_counts,
)
from .types import MeasurementSummaryDto, MeasurementDto
from shared.src.database.in_situ import ApiSource, get_averaged, find_by_criteria
Expand Down Expand Up @@ -50,3 +51,26 @@ async def get_measurements_summary(
)
log.info(f"Found results for {len(averaged_measurements)} locations")
return map_summarized_measurements(averaged_measurements)


@router.get("/air-pollutant/measurements/counts")
async def get_measurement_counts(
date_from: datetime,
date_to: datetime,
location_type: AirQualityLocationType = "city",
location_names: List[str] = Query(None),
) -> Dict:
"""Get count of measurements per city and pollutant for a given time range"""
log.info(
f"Fetching measurement counts between {date_from} - {date_to} for {location_type}"
)
measurements = find_by_criteria(
date_from,
date_to,
location_type,
location_names,
)

counts = map_measurement_counts(measurements)
log.info(f"Found measurement counts for {len(counts)} locations")
return counts
29 changes: 29 additions & 0 deletions air-quality-backend/api/src/routes/measurement_counts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import datetime
from typing import Dict, List

from fastapi import APIRouter, Query
from src.mappers.forecast_mapper import map_measurement_counts
from src.services.forecast_service import get_measurements_for_time_range
from src.types import LocationType

router = APIRouter()


@router.get("/counts")
async def get_measurement_counts(
date_from: datetime,
date_to: datetime,
location_type: LocationType = "city",
location_names: List[str] = Query(None),
) -> Dict:
"""Get count of measurements per city and pollutant for a given time range"""
measurements = await get_measurements_for_time_range(
date_from=date_from,
date_to=date_to,
location_type=location_type,
location_names=location_names,
)

counts = map_measurement_counts(measurements)
print("Measurement counts:", counts) # Debug logging
return counts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ def test__map_forecast_database_data_to_api_output_data():
"source": "cams-production",
"texture_uri": "/2024-07-24_00/no2_2024-07-24_00_CAMS",
"min_value": 0.0,
"max_value": 100.0
"max_value": 100.0,
}
assert result[0] == expected
Loading
Loading