Skip to content

Commit

Permalink
ASA - Critical Hours Frontend (#3909)
Browse files Browse the repository at this point in the history
- Implements critical hours frontend hook up to show critical hours in fuel stats table
- A bunch of renaming and refactoring, "fuel types" -> "fuel stats" and the like
  • Loading branch information
conbrad authored Sep 10, 2024
1 parent 19346ec commit 65f48a9
Show file tree
Hide file tree
Showing 18 changed files with 231 additions and 273 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"HRDPS",
"idir",
"Indeterminates",
"Kamloops",
"luxon",
"maxx",
"maxy",
Expand All @@ -108,6 +109,7 @@
"PROJCS",
"pydantic",
"RDPS",
"reduxjs",
"reproject",
"rocketchat",
"rollup",
Expand All @@ -116,6 +118,7 @@
"sfms",
"sqlalchemy",
"starlette",
"testid",
"tobytes",
"upsampled",
"uvicorn",
Expand Down
23 changes: 15 additions & 8 deletions api/app/db/crud/auto_spatial_advisory.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ async def get_zone_ids_in_centre(session: AsyncSession, fire_centre_name: str):

return all_results


async def get_all_sfms_fuel_type_records(session: AsyncSession) -> List[SFMSFuelType]:
"""
Retrieve all records from the sfms_fuel_types table.
Expand All @@ -154,26 +154,33 @@ async def get_all_sfms_fuel_type_records(session: AsyncSession) -> List[SFMSFuel
return result.all()


async def get_precomputed_high_hfi_fuel_type_areas_for_shape(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date, advisory_shape_id: int) -> List[Row]:
async def get_precomputed_stats_for_shape(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date, advisory_shape_id: int) -> List[Row]:
perf_start = perf_counter()
stmt = (
select(AdvisoryFuelStats.advisory_shape_id, AdvisoryFuelStats.fuel_type, AdvisoryFuelStats.threshold, AdvisoryFuelStats.area, AdvisoryFuelStats.run_parameters)
.join_from(AdvisoryFuelStats, RunParameters, AdvisoryFuelStats.run_parameters == RunParameters.id)
.join_from(AdvisoryFuelStats, Shape, AdvisoryFuelStats.advisory_shape_id == Shape.id)
select(
CriticalHours.start_hour,
CriticalHours.end_hour,
AdvisoryFuelStats.fuel_type,
AdvisoryFuelStats.threshold,
AdvisoryFuelStats.area,
)
.distinct(AdvisoryFuelStats.fuel_type, AdvisoryFuelStats.run_parameters)
.outerjoin(RunParameters, AdvisoryFuelStats.run_parameters == RunParameters.id)
.outerjoin(CriticalHours, CriticalHours.run_parameters == RunParameters.id)
.outerjoin(Shape, AdvisoryFuelStats.advisory_shape_id == Shape.id)
.where(
Shape.source_identifier == str(advisory_shape_id),
RunParameters.run_type == run_type.value,
RunParameters.run_datetime == run_datetime,
RunParameters.for_date == for_date,
)
.order_by(AdvisoryFuelStats.fuel_type)
.order_by(AdvisoryFuelStats.threshold)
)

result = await session.execute(stmt)
all_results = result.all()
perf_end = perf_counter()
delta = perf_end - perf_start
logger.info("%f delta count before and after fuel types/high hfi/zone query", delta)
logger.info("%f delta count before and after advisory stats query", delta)
return all_results


Expand Down
73 changes: 9 additions & 64 deletions api/app/routers/fba.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,20 @@
get_all_sfms_fuel_types,
get_all_hfi_thresholds,
get_hfi_area,
get_precomputed_high_hfi_fuel_type_areas_for_shape,
get_precomputed_stats_for_shape,
get_provincial_rollup,
get_run_datetimes,
get_zonal_elevation_stats,
get_zonal_tpi_stats,
get_centre_tpi_stats,
get_zone_ids_in_centre,
)
from app.db.models.auto_spatial_advisory import RunTypeEnum
from app.schemas.fba import (
AdvisoryCriticalHours,
ClassifiedHfiThresholdFuelTypeArea,
FireCenterListResponse,
FireShapeAreaListResponse,
FireShapeArea,
FireZoneElevationStats,
FireZoneElevationStatsByThreshold,
FireZoneElevationStatsListResponse,
FireZoneTPIStats,
SFMSFuelType,
HfiThreshold,
Expand Down Expand Up @@ -105,50 +102,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):
"""
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)

async with get_async_read_session_scope() as session:
# get thresholds data
thresholds = await get_all_hfi_thresholds(session)
# get fuel type ids data
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
)
data = []

for record in hfi_fuel_type_ids_for_zone:
fuel_type_id = record[1]
threshold_id = record[2]
# area is stored in square metres in DB. For user convenience, convert to hectares
# 1 ha = 10,000 sq.m.
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,
)
)

return {zone_id: data}


@router.get("/fire-centre-hfi-fuels/{run_type}/{for_date}/{run_datetime}/{fire_centre_name}", response_model=dict[str, dict[int, List[ClassifiedHfiThresholdFuelTypeArea]]])
@router.get("/fire-centre-hfi-stats/{run_type}/{for_date}/{run_datetime}/{fire_centre_name}", response_model=dict[str, dict[int, List[ClassifiedHfiThresholdFuelTypeArea]]])
async def get_hfi_fuels_data_for_fire_centre(run_type: RunType, for_date: date, run_datetime: datetime, fire_centre_name: str):
"""
Fetch rollup of fuel type/HFI threshold/area data for a specified fire zone.
Fetch fuel type and critical hours data for all fire zones in a fire centre for a given date
"""
logger.info("fire-centre-hfi-fuels/%s/%s/%s/%s", run_type.value, for_date, run_datetime, fire_centre_name)
logger.info("fire-centre-hfi-stats/%s/%s/%s/%s", run_type.value, for_date, run_datetime, fire_centre_name)

async with get_async_read_session_scope() as session:
# get thresholds data
Expand All @@ -161,23 +120,22 @@ async def get_hfi_fuels_data_for_fire_centre(run_type: RunType, for_date: date,
all_zone_data = {}
for zone_id in zone_ids:
# get HFI/fuels data for specific zone
hfi_fuel_type_ids_for_zone = await get_precomputed_high_hfi_fuel_type_areas_for_shape(
hfi_fuel_type_ids_for_zone = await get_precomputed_stats_for_shape(
session, run_type=RunTypeEnum(run_type.value), for_date=for_date, run_datetime=run_datetime, advisory_shape_id=zone_id
)
zone_data = []

for record in hfi_fuel_type_ids_for_zone:
fuel_type_id = record[1]
threshold_id = record[2]
for critical_hour_start, critical_hour_end, fuel_type_id, threshold_id, area in hfi_fuel_type_ids_for_zone:
# area is stored in square metres in DB. For user convenience, convert to hectares
# 1 ha = 10,000 sq.m.
area = record[3] / 10000
area = area / 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)
zone_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),
critical_hours=AdvisoryCriticalHours(start_time=critical_hour_start, end_time=critical_hour_end),
area=area,
)
)
Expand All @@ -201,19 +159,6 @@ 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"""
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_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"""
Expand Down
8 changes: 8 additions & 0 deletions api/app/schemas/fba.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,21 @@ class SFMSFuelType(BaseModel):
description: str


class AdvisoryCriticalHours(BaseModel):
"""Critical Hours for an advisory."""

start_time: Optional[float]
end_time: Optional[float]


class ClassifiedHfiThresholdFuelTypeArea(BaseModel):
"""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
critical_hours: AdvisoryCriticalHours
area: float


Expand Down
62 changes: 31 additions & 31 deletions api/app/tests/fba/test_fba_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@
from fastapi.testclient import TestClient
from datetime import date, datetime, timezone
from collections import namedtuple
from app.db.models.auto_spatial_advisory import AdvisoryElevationStats, AdvisoryTPIStats, RunParameters
from app.db.models.auto_spatial_advisory import AdvisoryTPIStats, HfiClassificationThreshold, RunParameters, SFMSFuelType

mock_fire_centre_name = "PGFireCentre"

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_centre_info_url = "/api/fba/fire-centre-hfi-stats/forecast/2022-09-27/2022-09-27/Kamloops%20Fire%20Centre"
get_fire_zone_elevation_info_url = "/api/fba/fire-zone-elevation-info/forecast/2022-09-27/2022-09-27/1"
get_fire_centre_tpi_stats_url = f"/api/fba/fire-centre-tpi-stats/forecast/2024-08-10/2024-08-10/{mock_fire_centre_name}"
get_sfms_run_datetimes_url = "/api/fba/sfms-run-datetimes/forecast/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_fire_centre_info = [(9.0, 11.0, 1, 1, 50)]
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))
]
Expand All @@ -43,12 +44,12 @@ async def mock_get_tpi_stats(*_, **__):
return mock_tpi_stats


async def mock_get_centre_tpi_stats(*_, **__):
return [mock_centre_tpi_stats_1, mock_centre_tpi_stats_2]
async def mock_get_fire_centre_info(*_, **__):
return mock_fire_centre_info


async def mock_get_elevation_info(*_, **__):
return mock_elevation_info
async def mock_get_centre_tpi_stats(*_, **__):
return [mock_centre_tpi_stats_1, mock_centre_tpi_stats_2]


async def mock_get_sfms_run_datetimes(*_, **__):
Expand All @@ -65,7 +66,7 @@ def client():

@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],
[get_fire_centres_url, get_fire_zone_areas_url, get_fire_zone_tpi_stats_url, get_fire_centre_info_url, get_sfms_run_datetimes_url],
)
def test_get_endpoints_unauthorized(client: TestClient, endpoint: str):
"""Forbidden to get fire zone areas when unauthorized"""
Expand All @@ -83,19 +84,32 @@ def test_get_fire_centres_authorized(client: TestClient):
assert response.status_code == 200


async def mock_hfi_thresholds(*_, **__):
return [HfiClassificationThreshold(id=1, description="4000 < hfi < 10000", name="advisory")]


async def mock_sfms_fuel_types(*_, **__):
return [SFMSFuelType(id=1, fuel_type_id=1, fuel_type_code="C2", description="test fuel type c2")]


async def mock_zone_ids_in_centre(*_, **__):
return [1]


@patch("app.routers.fba.get_auth_header", mock_get_auth_header)
@patch("app.routers.fba.get_zonal_elevation_stats", mock_get_elevation_info)
@patch("app.routers.fba.get_precomputed_stats_for_shape", mock_get_fire_centre_info)
@patch("app.routers.fba.get_all_hfi_thresholds", mock_hfi_thresholds)
@patch("app.routers.fba.get_all_sfms_fuel_types", mock_sfms_fuel_types)
@patch("app.routers.fba.get_zone_ids_in_centre", mock_zone_ids_in_centre)
@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)
def test_get_fire_center_info_authorized(client: TestClient):
"""Allowed to get fire centre info when authorized"""
response = client.get(get_fire_centre_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
assert response.json()["Kamloops Fire Centre"]["1"][0]["fuel_type"]["fuel_type_id"] == 1
assert response.json()["Kamloops Fire Centre"]["1"][0]["threshold"]["id"] == 1
assert response.json()["Kamloops Fire Centre"]["1"][0]["critical_hours"]["start_time"] == 9.0
assert response.json()["Kamloops Fire Centre"]["1"][0]["critical_hours"]["end_time"] == 11.0


@patch("app.routers.fba.get_auth_header", mock_get_auth_header)
Expand All @@ -108,20 +122,6 @@ def test_get_sfms_run_datetimes_authorized(client: TestClient):
assert response.json()[0] == datetime(year=2024, month=1, day=1, hour=1, tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


@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


@patch("app.routers.fba.get_auth_header", mock_get_auth_header)
@patch("app.routers.fba.get_centre_tpi_stats", mock_get_centre_tpi_stats)
@pytest.mark.usefixtures("mock_jwt_decode")
Expand Down
29 changes: 13 additions & 16 deletions web/src/api/fbaAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@ export interface FBAResponse {
fire_centers: FireCenter[]
}

export interface FireZoneThresholdFuelTypeArea {
export interface AdvisoryCriticalHours {
start_time?: number
end_time?: number
}


export interface FireZoneFuelStats {
fuel_type: FuelType
threshold: HfiThreshold
critical_hours: AdvisoryCriticalHours
area: number
}

Expand Down Expand Up @@ -96,9 +103,9 @@ export interface FuelType {
description: string
}

export interface FireCentreHfiFuelsData {
export interface FireCentreHFIStats {
[fire_centre_name: string]: {
[fire_zone_id: number]: FireZoneThresholdFuelTypeArea[]
[fire_zone_id: number]: FireZoneFuelStats[]
}
}

Expand Down Expand Up @@ -142,24 +149,14 @@ export async function getAllRunDates(run_type: RunType, for_date: string): Promi
return data
}

export async function getHFIThresholdsFuelTypesForZone(
run_type: RunType,
for_date: string,
run_datetime: string,
zone_id: number
): Promise<Record<number, FireZoneThresholdFuelTypeArea[]>> {
const url = `fba/hfi-fuels/${run_type.toLowerCase()}/${for_date}/${run_datetime}/${zone_id}`
const { data } = await axios.get(url)
return data
}

export async function getHFIThresholdsFuelTypesForCentre(
export async function getFireCentreHFIStats(
run_type: RunType,
for_date: string,
run_datetime: string,
fire_centre: string
): Promise<FireCentreHfiFuelsData> {
const url = `fba/fire-centre-hfi-fuels/${run_type.toLowerCase()}/${for_date}/${run_datetime}/${fire_centre}`
): Promise<FireCentreHFIStats> {
const url = `fba/fire-centre-hfi-stats/${run_type.toLowerCase()}/${for_date}/${run_datetime}/${fire_centre}`
const { data } = await axios.get(url)
return data
}
Expand Down
Loading

0 comments on commit 65f48a9

Please sign in to comment.