diff --git a/.vscode/settings.json b/.vscode/settings.json index 96d8b5d93..9c08beda9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -109,6 +109,7 @@ "RDPS", "reproject", "rocketchat", + "rollup", "sessionmaker", "sfms", "sqlalchemy", diff --git a/api/app/db/crud/auto_spatial_advisory.py b/api/app/db/crud/auto_spatial_advisory.py index a6831261c..ab361b08a 100644 --- a/api/app/db/crud/auto_spatial_advisory.py +++ b/api/app/db/crud/auto_spatial_advisory.py @@ -352,6 +352,23 @@ async def get_zonal_elevation_stats(session: AsyncSession, fire_zone_id: int, ru return await session.execute(stmt) +async def get_zonal_tpi_stats(session: AsyncSession, fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date) -> AdvisoryTPIStats: + run_parameters_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) + stmt = select(Shape.id).where(Shape.source_identifier == str(fire_zone_id)) + result = await session.execute(stmt) + shape_id = result.scalar() + + stmt = select( + AdvisoryTPIStats.advisory_shape_id, + AdvisoryTPIStats.valley_bottom, + AdvisoryTPIStats.mid_slope, + AdvisoryTPIStats.upper_slope, + ).where(AdvisoryTPIStats.advisory_shape_id == shape_id, AdvisoryTPIStats.run_parameters == run_parameters_id) + + result = await session.execute(stmt) + return result.first() + + async def get_provincial_rollup(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date) -> List[Row]: logger.info("gathering provincial rollup") run_parameter_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index da11c0501..7af041a04 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -1,7 +1,7 @@ -""" Routers for Auto Spatial Advisory -""" +"""Routers for Auto Spatial Advisory""" import logging +import math from datetime import date, datetime from typing import List from fastapi import APIRouter, Depends @@ -15,6 +15,7 @@ get_provincial_rollup, get_run_datetimes, get_zonal_elevation_stats, + get_zonal_tpi_stats, ) from app.db.models.auto_spatial_advisory import RunTypeEnum from app.schemas.fba import ( @@ -25,13 +26,14 @@ FireZoneElevationStats, FireZoneElevationStatsByThreshold, FireZoneElevationStatsListResponse, + FireZoneTPIStats, SFMSFuelType, HfiThreshold, FireShapeAreaDetail, ProvincialSummaryResponse, ) from app.auth import authentication_required, audit -from app.wildfire_one.wfwx_api import (get_auth_header, get_fire_centers) +from app.wildfire_one.wfwx_api import get_auth_header, get_fire_centers from app.auto_spatial_advisory.process_hfi import RunType logger = logging.getLogger(__name__) @@ -42,38 +44,37 @@ ) -@router.get('/fire-centers', response_model=FireCenterListResponse) +@router.get("/fire-centers", response_model=FireCenterListResponse) async def get_all_fire_centers(_=Depends(authentication_required)): - """ Returns fire centers for all active stations. """ - logger.info('/fba/fire-centers/') + """Returns fire centers for all active stations.""" + logger.info("/fba/fire-centers/") async with ClientSession() as session: header = await get_auth_header(session) fire_centers = await get_fire_centers(session, header) return FireCenterListResponse(fire_centers=fire_centers) -@router.get('/fire-shape-areas/{run_type}/{run_datetime}/{for_date}', - response_model=FireShapeAreaListResponse) +@router.get("/fire-shape-areas/{run_type}/{run_datetime}/{for_date}", response_model=FireShapeAreaListResponse) async def get_shapes(run_type: RunType, run_datetime: datetime, for_date: date, _=Depends(authentication_required)): - """ Return area of each zone unit shape, and percentage of area of zone unit shape with high hfi. """ + """Return area of each zone unit shape, and percentage of area of zone unit shape with high hfi.""" async with get_async_read_session_scope() as session: shapes = [] - rows = await get_hfi_area(session, - RunTypeEnum(run_type.value), - run_datetime, - for_date) + rows = await get_hfi_area(session, RunTypeEnum(run_type.value), run_datetime, for_date) # Fetch rows. for row in rows: combustible_area = row.combustible_area # type: ignore hfi_area = row.hfi_area # type: ignore - shapes.append(FireShapeArea( - fire_shape_id=row.source_identifier, # type: ignore - threshold=row.threshold, # type: ignore - combustible_area=row.combustible_area, # type: ignore - elevated_hfi_area=row.hfi_area, # type: ignore - elevated_hfi_percentage=hfi_area / combustible_area * 100)) + shapes.append( + FireShapeArea( + fire_shape_id=row.source_identifier, # type: ignore + threshold=row.threshold, # type: ignore + combustible_area=row.combustible_area, # type: ignore + elevated_hfi_area=row.hfi_area, # type: ignore + elevated_hfi_percentage=hfi_area / combustible_area * 100, + ) + ) return FireShapeAreaListResponse(shapes=shapes) @@ -102,16 +103,12 @@ async def get_provincial_summary(run_type: RunType, run_datetime: datetime, for_ return ProvincialSummaryResponse(provincial_summary=fire_shape_area_details) -@router.get('/hfi-fuels/{run_type}/{for_date}/{run_datetime}/{zone_id}', - response_model=dict[int, List[ClassifiedHfiThresholdFuelTypeArea]]) -async def get_hfi_fuels_data_for_fire_zone(run_type: RunType, - for_date: date, - run_datetime: datetime, - zone_id: int): +@router.get("/hfi-fuels/{run_type}/{for_date}/{run_datetime}/{zone_id}", response_model=dict[int, List[ClassifiedHfiThresholdFuelTypeArea]]) +async def get_hfi_fuels_data_for_fire_zone(run_type: RunType, for_date: date, run_datetime: datetime, zone_id: int): """ Fetch rollup of fuel type/HFI threshold/area data for a specified fire zone. """ - logger.info('hfi-fuels/%s/%s/%s/%s', run_type.value, for_date, run_datetime, zone_id) + logger.info("hfi-fuels/%s/%s/%s/%s", run_type.value, for_date, run_datetime, zone_id) async with get_async_read_session_scope() as session: # get thresholds data @@ -120,11 +117,9 @@ async def get_hfi_fuels_data_for_fire_zone(run_type: RunType, fuel_types = await get_all_sfms_fuel_types(session) # get HFI/fuels data for specific zone - hfi_fuel_type_ids_for_zone = await get_precomputed_high_hfi_fuel_type_areas_for_shape(session, - run_type=RunTypeEnum(run_type.value), - for_date=for_date, - run_datetime=run_datetime, - advisory_shape_id=zone_id) + hfi_fuel_type_ids_for_zone = await get_precomputed_high_hfi_fuel_type_areas_for_shape( + session, run_type=RunTypeEnum(run_type.value), for_date=for_date, run_datetime=run_datetime, advisory_shape_id=zone_id + ) data = [] for record in hfi_fuel_type_ids_for_zone: @@ -135,25 +130,21 @@ async def get_hfi_fuels_data_for_fire_zone(run_type: RunType, area = record[3] / 10000 fuel_type_obj = next((ft for ft in fuel_types if ft.fuel_type_id == fuel_type_id), None) threshold_obj = next((th for th in thresholds if th.id == threshold_id), None) - data.append(ClassifiedHfiThresholdFuelTypeArea( - fuel_type=SFMSFuelType( - fuel_type_id=fuel_type_obj.fuel_type_id, - fuel_type_code=fuel_type_obj.fuel_type_code, - description=fuel_type_obj.description - ), - threshold=HfiThreshold( - id=threshold_obj.id, - name=threshold_obj.name, - description=threshold_obj.description), - area=area)) + data.append( + ClassifiedHfiThresholdFuelTypeArea( + fuel_type=SFMSFuelType(fuel_type_id=fuel_type_obj.fuel_type_id, fuel_type_code=fuel_type_obj.fuel_type_code, description=fuel_type_obj.description), + threshold=HfiThreshold(id=threshold_obj.id, name=threshold_obj.name, description=threshold_obj.description), + area=area, + ) + ) return {zone_id: data} -@router.get('/sfms-run-datetimes/{run_type}/{for_date}', response_model=List[datetime]) +@router.get("/sfms-run-datetimes/{run_type}/{for_date}", response_model=List[datetime]) async def get_run_datetimes_for_date_and_runtype(run_type: RunType, for_date: date, _=Depends(authentication_required)): - """ Return list of datetimes for which SFMS has run, given a specific for_date and run_type. - Datetimes should be ordered with most recent first. """ + """Return list of datetimes for which SFMS has run, given a specific for_date and run_type. + Datetimes should be ordered with most recent first.""" async with get_async_read_session_scope() as session: datetimes = [] @@ -165,21 +156,29 @@ async def get_run_datetimes_for_date_and_runtype(run_type: RunType, for_date: da return datetimes -@router.get('/fire-zone-elevation-info/{run_type}/{for_date}/{run_datetime}/{fire_zone_id}', - response_model=FireZoneElevationStatsListResponse) -async def get_fire_zone_elevation_stats(fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date, - _=Depends(authentication_required)): - """ Return the elevation statistics for each advisory threshold """ +@router.get("/fire-zone-elevation-info/{run_type}/{for_date}/{run_datetime}/{fire_zone_id}", response_model=FireZoneElevationStatsListResponse) +async def get_fire_zone_elevation_stats(fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date, _=Depends(authentication_required)): + """Return the elevation statistics for each advisory threshold""" async with get_async_read_session_scope() as session: data = [] rows = await get_zonal_elevation_stats(session, fire_zone_id, run_type, run_datetime, for_date) for row in rows: - stats = FireZoneElevationStats( - minimum=row.minimum, - quartile_25=row.quartile_25, - median=row.median, - quartile_75=row.quartile_75, - maximum=row.maximum) + stats = FireZoneElevationStats(minimum=row.minimum, quartile_25=row.quartile_25, median=row.median, quartile_75=row.quartile_75, maximum=row.maximum) stats_by_threshold = FireZoneElevationStatsByThreshold(threshold=row.threshold, elevation_info=stats) data.append(stats_by_threshold) return FireZoneElevationStatsListResponse(hfi_elevation_info=data) + + +@router.get("/fire-zone-tpi-stats/{run_type}/{for_date}/{run_datetime}/{fire_zone_id}", response_model=FireZoneTPIStats) +async def get_fire_zone_tpi_stats(fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date, _=Depends(authentication_required)): + """Return the elevation TPI statistics for each advisory threshold""" + logger.info("/fba/fire-zone-tpi-stats/") + async with get_async_read_session_scope() as session: + stats = await get_zonal_tpi_stats(session, fire_zone_id, run_type, run_datetime, for_date) + square_metres = math.pow(stats.pixel_size_metres, 2) + return FireZoneTPIStats( + fire_zone_id=fire_zone_id, + valley_bottom=stats.valley_bottom * square_metres, + mid_slope=stats.mid_slope * square_metres, + upper_slope=stats.upper_slope * square_metres, + ) diff --git a/api/app/schemas/fba.py b/api/app/schemas/fba.py index 4c024ed5b..4a3c60a82 100644 --- a/api/app/schemas/fba.py +++ b/api/app/schemas/fba.py @@ -1,31 +1,35 @@ -""" This module contains pydantic models related to the new formal/non-tinker fba. """ +"""This module contains pydantic models related to the new formal/non-tinker fba.""" from typing import List, Optional from pydantic import BaseModel class FireCenterStation(BaseModel): - """ A fire weather station has a code, name and geographical coordinate. """ + """A fire weather station has a code, name and geographical coordinate.""" + code: int name: str zone: Optional[str] = None class FireCentre(BaseModel): - """ The highest-level organizational unit for wildfire planning. Each fire centre - has 1 or more planning areas within it. """ + """The highest-level organizational unit for wildfire planning. Each fire centre + has 1 or more planning areas within it.""" + id: str name: str stations: List[FireCenterStation] class FireCenterListResponse(BaseModel): - """ Response for all fire centers, in a list """ + """Response for all fire centers, in a list""" + fire_centers: List[FireCentre] class FireShapeArea(BaseModel): - """ A zone is a grouping of planning areas within a fire centre. """ + """A zone is a grouping of planning areas within a fire centre.""" + fire_shape_id: int threshold: Optional[int] = None combustible_area: float @@ -34,11 +38,14 @@ class FireShapeArea(BaseModel): class FireShapeAreaListResponse(BaseModel): - """ Response for all planning areas, in a list """ + """Response for all planning areas, in a list""" + shapes: List[FireShapeArea] + class FireShapeAreaDetail(FireShapeArea): - """ Summary information about an advisory shape """ + """Summary information about an advisory shape""" + fire_shape_name: str fire_centre_name: str @@ -48,49 +55,56 @@ class ProvincialSummaryResponse(BaseModel): class FireShapeHighHfiAreas(BaseModel): - """ A fire zone and the area exceeding HFI thresholds. """ + """A fire zone and the area exceeding HFI thresholds.""" + fire_shape_id: int advisory_area: float warn_area: float class FireShapeHighHfiAreasListResponse(BaseModel): - """ Response for all fire zones and their areas exceeding high HFI thresholds. """ + """Response for all fire zones and their areas exceeding high HFI thresholds.""" + zones: List[FireShapeHighHfiAreas] class HfiThresholdAreaByFuelType(BaseModel): - """ Total area in sq.m. within HFI threshold for a specific fuel type """ + """Total area in sq.m. within HFI threshold for a specific fuel type""" + fuel_type_id: int threshold: int area: float class HfiThreshold(BaseModel): - """ An HFI Classification threshold """ + """An HFI Classification threshold""" + id: int name: str description: str class SFMSFuelType(BaseModel): - """ Data for fuel types used by SFMS system to calculate HFI spatially. """ + """Data for fuel types used by SFMS system to calculate HFI spatially.""" + fuel_type_id: int fuel_type_code: str description: str class ClassifiedHfiThresholdFuelTypeArea(BaseModel): - """ Collection of data objects recording the area within an advisory shape + """Collection of data objects recording the area within an advisory shape that meets a particular HfiThreshold for a specific SFMSFuelType """ + fuel_type: SFMSFuelType threshold: HfiThreshold area: float class FireZoneElevationStats(BaseModel): - """ Basic elevation statistics for a firezone """ + """Basic elevation statistics for a firezone""" + minimum: float quartile_25: float median: float @@ -98,12 +112,23 @@ class FireZoneElevationStats(BaseModel): maximum: float +class FireZoneTPIStats(BaseModel): + """Classified TPI areas of the fire zone contributing to the HFI >4k. Each area is in square metres.""" + + fire_zone_id: int + valley_bottom: int + mid_slope: int + upper_slope: int + + class FireZoneElevationStatsByThreshold(BaseModel): - """ Elevation statistics for a firezone by threshold""" + """Elevation statistics for a firezone by threshold""" + threshold: int elevation_info: FireZoneElevationStats class FireZoneElevationStatsListResponse(BaseModel): - """ Response for a firezone that includes elevation statistics by threshold for the run parameters of interest """ + """Response for a firezone that includes elevation statistics by threshold for the run parameters of interest""" + hfi_elevation_info: List[FireZoneElevationStatsByThreshold] diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index 93bb4c55a..191d7d984 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -1,12 +1,23 @@ from unittest.mock import patch +import math import pytest from fastapi.testclient import TestClient +from datetime import date, datetime, timezone +from app.db.models.auto_spatial_advisory import AdvisoryElevationStats, AdvisoryTPIStats, RunParameters + +get_fire_centres_url = "/api/fba/fire-centers" +get_fire_zone_areas_url = "/api/fba/fire-shape-areas/forecast/2022-09-27/2022-09-27" +get_fire_zone_tpi_stats_url = "/api/fba/fire-zone-tpi-stats/forecast/2022-09-27/2022-09-27/1" +get_fire_zone_elevation_info_url = "/api/fba/fire-zone-elevation-info/forecast/2022-09-27/2022-09-27/1" +get_sfms_run_datetimes_url = "/api/fba/sfms-run-datetimes/forecast/2022-09-27" -from app.tests.utils.mock_jwt_decode_role import MockJWTDecodeWithRole -get_fire_centres_url = '/api/fba/fire-centers' -get_fire_zone_areas_url = '/api/fba/fire-shape-areas/forecast/2022-09-27/2022-09-27' decode_fn = "jwt.decode" +mock_tpi_stats = AdvisoryTPIStats(id=1, advisory_shape_id=1, valley_bottom=1, mid_slope=2, upper_slope=3, pixel_size_metres=50) +mock_elevation_info = [AdvisoryElevationStats(id=1, advisory_shape_id=1, threshold=1, minimum=1.0, quartile_25=2.0, median=3.0, quartile_75=4.0, maximum=5.0)] +mock_sfms_run_datetimes = [ + RunParameters(id=1, run_type="forecast", run_datetime=datetime(year=2024, month=1, day=1, hour=1, tzinfo=timezone.utc), for_date=date(year=2024, month=1, day=2)) +] async def mock_get_fire_centres(*_, **__): @@ -21,8 +32,16 @@ async def mock_get_auth_header(*_, **__): return {} -def mock_admin_role_function(*_, **__): - return MockJWTDecodeWithRole('hfi_station_admin') +async def mock_get_tpi_stats(*_, **__): + return mock_tpi_stats + + +async def mock_get_elevation_info(*_, **__): + return mock_elevation_info + + +async def mock_get_sfms_run_datetimes(*_, **__): + return mock_sfms_run_datetimes @pytest.fixture() @@ -33,24 +52,60 @@ def client(): yield test_client -@patch('app.routers.fba.get_auth_header', mock_get_auth_header) -@patch('app.routers.fba.get_fire_centers', mock_get_fire_centres) -@patch(decode_fn, mock_admin_role_function) -def test_get_fire_centres_authorized(client: TestClient): - """ Allowed to get fire centres when authorized""" - response = client.get(get_fire_centres_url) - assert response.status_code == 200 +@pytest.mark.parametrize( + "endpoint", + [get_fire_centres_url, get_fire_zone_areas_url, get_fire_zone_tpi_stats_url, get_fire_zone_elevation_info_url, get_sfms_run_datetimes_url], +) +def test_get_endpoints_unauthorized(client: TestClient, endpoint: str): + """Forbidden to get fire zone areas when unauthorized""" + response = client.get(endpoint) + assert response.status_code == 401 -def test_get_fire_centres_unauthorized(client: TestClient): - """ Forbidden to get fire centres when unauthorized""" +@patch("app.routers.fba.get_auth_header", mock_get_auth_header) +@patch("app.routers.fba.get_fire_centers", mock_get_fire_centres) +@pytest.mark.usefixtures("mock_jwt_decode") +def test_get_fire_centres_authorized(client: TestClient): + """Allowed to get fire centres when authorized""" response = client.get(get_fire_centres_url) - assert response.status_code == 401 + assert response.status_code == 200 + +@patch("app.routers.fba.get_auth_header", mock_get_auth_header) +@patch("app.routers.fba.get_zonal_elevation_stats", mock_get_elevation_info) +@pytest.mark.usefixtures("mock_jwt_decode") +def test_get_fire_zone_elevation_info_authorized(client: TestClient): + """Allowed to get fire zone elevation info when authorized""" + response = client.get(get_fire_zone_elevation_info_url) + assert response.status_code == 200 + assert response.json()["hfi_elevation_info"][0]["threshold"] == mock_elevation_info[0].threshold + assert response.json()["hfi_elevation_info"][0]["elevation_info"]["minimum"] == mock_elevation_info[0].minimum + assert response.json()["hfi_elevation_info"][0]["elevation_info"]["quartile_25"] == mock_elevation_info[0].quartile_25 + assert response.json()["hfi_elevation_info"][0]["elevation_info"]["median"] == mock_elevation_info[0].median + assert response.json()["hfi_elevation_info"][0]["elevation_info"]["quartile_75"] == mock_elevation_info[0].quartile_75 + assert response.json()["hfi_elevation_info"][0]["elevation_info"]["maximum"] == mock_elevation_info[0].maximum + + +@patch("app.routers.fba.get_auth_header", mock_get_auth_header) +@patch("app.routers.fba.get_run_datetimes", mock_get_sfms_run_datetimes) +@pytest.mark.usefixtures("mock_jwt_decode") +def test_get_sfms_run_datetimes_authorized(client: TestClient): + """Allowed to get sfms run datetimes when authorized""" + response = client.get(get_sfms_run_datetimes_url) + assert response.status_code == 200 + assert response.json()[0] == datetime(year=2024, month=1, day=1, hour=1, tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -def test_get_fire_zone_areas_unauthorized(client: TestClient): - """ Forbidden to get fire zone areas when unauthorized""" - response = client.get(get_fire_zone_areas_url) - assert response.status_code == 401 +@patch("app.routers.fba.get_auth_header", mock_get_auth_header) +@patch("app.routers.fba.get_zonal_tpi_stats", mock_get_tpi_stats) +@pytest.mark.usefixtures("mock_jwt_decode") +def test_get_fire_zone_tpi_stats_authorized(client: TestClient): + """Allowed to get fire zone tpi stats when authorized""" + response = client.get(get_fire_zone_tpi_stats_url) + square_metres = math.pow(mock_tpi_stats.pixel_size_metres, 2) + assert response.status_code == 200 + assert response.json()["fire_zone_id"] == 1 + assert response.json()["valley_bottom"] == mock_tpi_stats.valley_bottom * square_metres + assert response.json()["mid_slope"] == mock_tpi_stats.mid_slope * square_metres + assert response.json()["upper_slope"] == mock_tpi_stats.upper_slope * square_metres