Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b8762ad
Caching for run parameters, provincial summary and fire shape areas
dgboss Aug 21, 2025
6fe321c
Progress
dgboss Aug 25, 2025
9df8c04
Hook and slice clean up
dgboss Aug 25, 2025
fd004e8
Re-enable auth
dgboss Aug 25, 2025
72d577f
Fix unit tests due to new hooks
dgboss Aug 26, 2025
33fb086
Allow viewing of offline data on launch
dgboss Aug 26, 2025
88308da
Download TDY and TMR hfi pmtiles on start
dgboss Aug 27, 2025
ff3cdc2
Merge branch 'main' into task/offline-data/4741
dgboss Aug 27, 2025
ad70cb8
Remove dupes
dgboss Aug 27, 2025
9851f46
Typing change
dgboss Aug 27, 2025
83b7498
fba endpoint tests
dgboss Aug 27, 2025
59407fc
Fix test
dgboss Aug 27, 2025
fe78941
Mock db call
dgboss Aug 28, 2025
54ae667
run parameters slice tests
dgboss Sep 23, 2025
b391bec
Cahcing tests
dgboss Nov 17, 2025
efbf12d
Merge branch 'main' into task/offline-data/4741
dgboss Nov 18, 2025
a4ddd16
Dedupe in fba router
dgboss Nov 18, 2025
ad9710b
Code clean up
dgboss Nov 18, 2025
15c7039
test fix
dgboss Nov 18, 2025
9b08f35
Fix test
dgboss Nov 19, 2025
f3008c3
Remove test
dgboss Nov 20, 2025
7d111f3
fire centre slice tests
dgboss Nov 20, 2025
18011a0
data slice utils tests
dgboss Nov 20, 2025
3d10af2
fix
dgboss Nov 20, 2025
2f7b7e6
More fba endpoint tests
dgboss Nov 20, 2025
53d2e8b
Remove useless test
dgboss Nov 20, 2025
ab0986c
Fix logic error
dgboss Nov 20, 2025
d33cf9d
fixes
dgboss Nov 20, 2025
21e0674
dataHook tests
dgboss Nov 24, 2025
f3bbcca
Null checks
dgboss Nov 24, 2025
bf85c1f
Merge branch 'main' into task/offline-data/4741
dgboss Nov 24, 2025
86e065a
Simplify app startup date
dgboss Nov 24, 2025
1bb76b7
Formatting
dgboss Nov 24, 2025
1057de2
Fix tests
dgboss Nov 24, 2025
8fdd027
PR feedback
dgboss Nov 27, 2025
a331dac
Use cached fire centers if offline.
dgboss Nov 27, 2025
44be9c1
Add missing dependency to zoneStatus hook.
dgboss Nov 27, 2025
1710904
Add return type to readFromFilesystem
dgboss Nov 27, 2025
f1e9ba9
Typings
dgboss Nov 27, 2025
326599f
test fixes
dgboss Nov 28, 2025
896d4b5
Don't set zone on today
dgboss Nov 28, 2025
ccaa277
Better fix
dgboss Nov 28, 2025
86d85f6
Another test fix
dgboss Nov 28, 2025
f2be8a4
Merge branch 'main' into task/offline-data/4741
dgboss Nov 28, 2025
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
311 changes: 216 additions & 95 deletions api/app/routers/fba.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from aiohttp.client import ClientSession
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.auto_spatial_advisory.fuel_type_layer import (
get_fuel_type_raster_by_year,
Expand All @@ -17,23 +18,27 @@
get_fuel_type_area_stats,
get_zone_wind_stats_for_source_id,
)
from wps_shared.auth import audit, asa_authentication_required, audit_asa
from wps_shared.auth import asa_authentication_required, audit, audit_asa
from wps_shared.db.crud.auto_spatial_advisory import (
get_all_hfi_thresholds_by_id,
get_all_sfms_fuel_type_records,
get_all_zone_source_ids,
get_centre_tpi_stats,
get_fire_centre_tpi_fuel_areas,
get_hfi_area,
get_min_wind_speed_hfi_thresholds,
get_most_recent_run_datetime_for_date,
get_most_recent_run_datetime_for_date_range,
get_precomputed_stats_for_shape,
get_provincial_rollup,
get_run_datetimes,
get_sfms_bounds,
get_tpi_fuel_areas,
get_tpi_stats,
get_zone_source_ids_in_centre,
)
from wps_shared.db.database import get_async_read_session_scope
from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum, TPIClassEnum
from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum, SFMSFuelType, TPIClassEnum
from wps_shared.schemas.fba import (
FireCenterListResponse,
FireCentreTPIResponse,
Expand All @@ -42,10 +47,14 @@
FireShapeAreaListResponse,
FireZoneHFIStats,
FireZoneTPIStats,
HFIStatsResponse,
LatestSFMSRunParameter,
LatestSFMSRunParameterRangeResponse,
LatestSFMSRunParameterResponse,
ProvincialSummaryResponse,
SFMSBoundsResponse,
SFMSRunParameter,
TPIResponse,
)
from wps_shared.wildfire_one.wfwx_api import get_auth_header, get_fire_centers

Expand All @@ -57,6 +66,84 @@
)


async def get_all_zone_data_for_source_ids(
session: AsyncSession,
zone_source_ids: List[SFMSFuelType],
run_type: RunType,
for_date: date,
run_datetime: datetime,
):
# get fuel type ids data
fuel_types = await get_all_sfms_fuel_type_records(session)
fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year)
zone_wind_stats_by_source_id = {}
hfi_thresholds_by_id = await get_all_hfi_thresholds_by_id(session)
advisory_wind_speed_by_source_id = await get_min_wind_speed_hfi_thresholds(
session, zone_source_ids, run_type, run_datetime, for_date
)
for source_id, wind_speed_stats in advisory_wind_speed_by_source_id.items():
min_wind_stats = get_zone_wind_stats_for_source_id(wind_speed_stats, hfi_thresholds_by_id)
zone_wind_stats_by_source_id[source_id] = min_wind_stats

all_zone_data: dict[int, FireZoneHFIStats] = {}
for zone_source_id in zone_source_ids:
# get HFI/fuels data for specific zone
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,
source_identifier=zone_source_id,
fuel_type_raster_id=fuel_type_raster.id,
)

if hfi_fuel_type_ids_for_zone is None or len(hfi_fuel_type_ids_for_zone) == 0:
# Handle the situation where data for the current year was actually processed with
# last year's fuel grid
prev_fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year - 1)
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,
source_identifier=zone_source_id,
fuel_type_raster_id=prev_fuel_type_raster.id,
)

zone_fuel_stats = []
for (
critical_hour_start,
critical_hour_end,
fuel_type_id,
threshold_id,
area,
fuel_area,
percent_conifer,
) in hfi_fuel_type_ids_for_zone:
hfi_threshold = hfi_thresholds_by_id.get(threshold_id)
if hfi_threshold is None:
logger.error(f"No hfi threshold for id: {threshold_id}")
continue
fuel_type_area_stats = get_fuel_type_area_stats(
for_date,
fuel_types,
hfi_threshold,
percent_conifer,
critical_hour_start,
critical_hour_end,
fuel_type_id,
area,
fuel_area,
)
zone_fuel_stats.append(fuel_type_area_stats)

all_zone_data[int(zone_source_id)] = FireZoneHFIStats(
min_wind_stats=zone_wind_stats_by_source_id.get(int(zone_source_id), []),
fuel_area_stats=zone_fuel_stats,
)
return all_zone_data


@router.get("/fire-centers", response_model=FireCenterListResponse)
async def get_all_fire_centers(_=Depends(asa_authentication_required)):
"""Returns fire centers for all active stations."""
Expand All @@ -81,7 +168,6 @@ async def get_shapes(
async with get_async_read_session_scope() as session:
fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year)
shapes = []

rows = await get_hfi_area(
session, RunTypeEnum(run_type.value), run_datetime, for_date, fuel_type_raster.id
)
Expand Down Expand Up @@ -161,88 +247,19 @@ async def get_hfi_fuels_data_for_fire_centre(
)

async with get_async_read_session_scope() as session:
# get fuel type ids data
fuel_types = await get_all_sfms_fuel_type_records(session)
# get fire zone id's within a fire centre
zone_source_ids = await get_zone_source_ids_in_centre(session, fire_centre_name)
fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year)
zone_wind_stats_by_source_id = {}
hfi_thresholds_by_id = await get_all_hfi_thresholds_by_id(session)
advisory_wind_speed_by_source_id = await get_min_wind_speed_hfi_thresholds(
session, zone_source_ids, run_type, run_datetime, for_date
all_zone_data = await get_all_zone_data_for_source_ids(
session, zone_source_ids, run_type, for_date, run_datetime
)
for source_id, wind_speed_stats in advisory_wind_speed_by_source_id.items():
min_wind_stats = get_zone_wind_stats_for_source_id(
wind_speed_stats, hfi_thresholds_by_id
)
zone_wind_stats_by_source_id[source_id] = min_wind_stats

all_zone_data: dict[int, FireZoneHFIStats] = {}
for zone_source_id in zone_source_ids:
# get HFI/fuels data for specific zone
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,
source_identifier=zone_source_id,
fuel_type_raster_id=fuel_type_raster.id,
)

if hfi_fuel_type_ids_for_zone is None or len(hfi_fuel_type_ids_for_zone) == 0:
# Handle the situation where data for the current year was actually processed with
# last year's fuel grid
prev_fuel_type_raster = await get_fuel_type_raster_by_year(
session, for_date.year - 1
)
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,
source_identifier=zone_source_id,
fuel_type_raster_id=prev_fuel_type_raster.id,
)

zone_fuel_stats = []

for (
critical_hour_start,
critical_hour_end,
fuel_type_id,
threshold_id,
area,
fuel_area,
percent_conifer,
) in hfi_fuel_type_ids_for_zone:
hfi_threshold = hfi_thresholds_by_id.get(threshold_id)
if hfi_threshold is None:
logger.error(f"No hfi threshold for id: {threshold_id}")
continue
fuel_type_area_stats = get_fuel_type_area_stats(
for_date,
fuel_types,
hfi_threshold,
percent_conifer,
critical_hour_start,
critical_hour_end,
fuel_type_id,
area,
fuel_area,
)
zone_fuel_stats.append(fuel_type_area_stats)

all_zone_data[int(zone_source_id)] = FireZoneHFIStats(
min_wind_stats=zone_wind_stats_by_source_id.get(int(zone_source_id), []),
fuel_area_stats=zone_fuel_stats,
)

return {fire_centre_name: all_zone_data}
return {fire_centre_name: all_zone_data}


@router.get("/latest-sfms-run-datetime/{for_date}", response_model=LatestSFMSRunParameterResponse)
async def get_latest_sfms_run_datetime_for_date(
for_date: date, _=Depends(asa_authentication_required)
for_date: date,
_=Depends(asa_authentication_required),
):
async with get_async_read_session_scope() as session:
latest_run_parameter = await get_most_recent_run_datetime_for_date(session, for_date)
Expand All @@ -256,23 +273,6 @@ async def get_latest_sfms_run_datetime_for_date(
return LatestSFMSRunParameterResponse(run_parameter=run_parameter)


@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(asa_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."""
async with get_async_read_session_scope() as session:
datetimes = []

rows = await get_run_datetimes(session, RunTypeEnum(run_type.value), for_date)

for row in rows:
datetimes.append(row.run_datetime) # type: ignore

return datetimes


@router.get("/sfms-run-bounds", response_model=SFMSBoundsResponse)
async def get_sfms_run_bounds():
async with get_async_read_session_scope() as session:
Expand Down Expand Up @@ -305,7 +305,6 @@ async def get_fire_centre_tpi_stats(
tpi_fuel_stats = await get_fire_centre_tpi_fuel_areas(
session, fire_centre_name, fuel_type_raster.id
)

hfi_tpi_areas_by_zone = []
for row in tpi_stats_for_centre:
fire_zone_id = row.source_identifier
Expand Down Expand Up @@ -340,3 +339,125 @@ async def get_fire_centre_tpi_stats(
return FireCentreTPIResponse(
fire_centre_name=fire_centre_name, firezone_tpi_stats=hfi_tpi_areas_by_zone
)


@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(asa_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."""
async with get_async_read_session_scope() as session:
datetimes = []

rows = await get_run_datetimes(session, RunTypeEnum(run_type.value), for_date)

for row in rows:
datetimes.append(row.run_datetime) # type: ignore

return datetimes


#### ASA Go Specific Routes ####


@router.get(
"/latest-sfms-run-parameters/{start_date}/{end_date}",
response_model=LatestSFMSRunParameterRangeResponse,
)
async def get_latest_sfms_run_datetime_for_date_range(
start_date: date,
end_date: date,
_=Depends(asa_authentication_required),
):
async with get_async_read_session_scope() as session:
result = await get_most_recent_run_datetime_for_date_range(session, start_date, end_date)
latest_run_parameters = {}
for row in result:
run_parameter = SFMSRunParameter(
for_date=row.for_date, run_type=row.run_type, run_datetime=row.run_datetime
)
latest_run_parameters[row.for_date] = run_parameter
return LatestSFMSRunParameterRangeResponse(run_parameters=latest_run_parameters)


@router.get(
"/hfi-stats/{run_type}/{run_datetime}/{for_date}",
response_model=HFIStatsResponse,
)
async def get_hfi_fuels_data_for_run_parameter(
run_type: RunType,
run_datetime: datetime,
for_date: date,
_=Depends(asa_authentication_required),
):
"""
Fetch fuel type and critical hours data for all fire zones units
"""
logger.info(
"hfi-stats/%s/%s/%s",
run_type.value,
for_date,
run_datetime,
)

async with get_async_read_session_scope() as session:
# get fire zone id's within a fire centre
zone_source_ids = await get_all_zone_source_ids(session)
all_zone_data = await get_all_zone_data_for_source_ids(
session, zone_source_ids, run_type, for_date, run_datetime
)

return HFIStatsResponse(zone_data=all_zone_data)


@router.get(
"/tpi-stats/{run_type}/{run_datetime}/{for_date}",
response_model=TPIResponse,
)
async def get_tpi_stats_for_run_parameter(
run_type: RunType,
run_datetime: datetime,
for_date: date,
_=Depends(asa_authentication_required),
):
"""Return the elevation TPI statistics for each advisory threshold for all fire shapes"""
logger.info("/fba/tpi-stats/")
async with get_async_read_session_scope() as session:
tpi_stats = await get_tpi_stats(session, run_type, run_datetime, for_date)
fuel_type_raster = await get_fuel_type_raster_by_year(session, for_date.year)
tpi_fuel_stats = await get_tpi_fuel_areas(session, fuel_type_raster.id)
hfi_tpi_areas_by_zone = []
for row in tpi_stats:
fire_zone_id = row.source_identifier
square_metres = math.pow(row.pixel_size_metres, 2)
tpi_fuel_stats_for_zone = [
stats for stats in tpi_fuel_stats if stats[2] == fire_zone_id
]
valley_bottom_tpi = None
mid_slope_tpi = None
upper_slope_tpi = None

for tpi_fuel_stat in tpi_fuel_stats_for_zone:
if tpi_fuel_stat[0] == TPIClassEnum.valley_bottom:
valley_bottom_tpi = tpi_fuel_stat[1]
elif tpi_fuel_stat[0] == TPIClassEnum.mid_slope:
mid_slope_tpi = tpi_fuel_stat[1]
elif tpi_fuel_stat[0] == TPIClassEnum.upper_slope:
upper_slope_tpi = tpi_fuel_stat[1]

hfi_tpi_areas_by_zone.append(
FireZoneTPIStats(
fire_zone_id=fire_zone_id,
valley_bottom_hfi=row.valley_bottom * square_metres,
valley_bottom_tpi=valley_bottom_tpi,
mid_slope_hfi=row.mid_slope * square_metres,
mid_slope_tpi=mid_slope_tpi,
upper_slope_hfi=row.upper_slope * square_metres,
upper_slope_tpi=upper_slope_tpi,
)
)

return TPIResponse(firezone_tpi_stats=hfi_tpi_areas_by_zone)
Loading
Loading