diff --git a/.vscode/settings.json b/.vscode/settings.json index df600b1f5..787b1a898 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -88,6 +88,7 @@ "HRDPS", "idir", "Indeterminates", + "Kamloops", "luxon", "maxx", "maxy", @@ -108,6 +109,7 @@ "PROJCS", "pydantic", "RDPS", + "reduxjs", "reproject", "rocketchat", "rollup", @@ -116,6 +118,7 @@ "sfms", "sqlalchemy", "starlette", + "testid", "tobytes", "upsampled", "uvicorn", diff --git a/api/app/db/crud/auto_spatial_advisory.py b/api/app/db/crud/auto_spatial_advisory.py index 371d1404d..b34654ef5 100644 --- a/api/app/db/crud/auto_spatial_advisory.py +++ b/api/app/db/crud/auto_spatial_advisory.py @@ -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. @@ -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 diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index fb232cb68..6717fb30f 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -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, @@ -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 @@ -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, ) ) @@ -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""" diff --git a/api/app/schemas/fba.py b/api/app/schemas/fba.py index 4a3c60a82..ec8740950 100644 --- a/api/app/schemas/fba.py +++ b/api/app/schemas/fba.py @@ -92,6 +92,13 @@ 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 @@ -99,6 +106,7 @@ class ClassifiedHfiThresholdFuelTypeArea(BaseModel): fuel_type: SFMSFuelType threshold: HfiThreshold + critical_hours: AdvisoryCriticalHours area: float diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index 86998e625..8768b85b3 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -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)) ] @@ -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(*_, **__): @@ -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""" @@ -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) @@ -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") diff --git a/web/src/api/fbaAPI.ts b/web/src/api/fbaAPI.ts index 461f6137b..e294be237 100644 --- a/web/src/api/fbaAPI.ts +++ b/web/src/api/fbaAPI.ts @@ -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 } @@ -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[] } } @@ -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> { - 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 { - const url = `fba/fire-centre-hfi-fuels/${run_type.toLowerCase()}/${for_date}/${run_datetime}/${fire_centre}` +): Promise { + const url = `fba/fire-centre-hfi-stats/${run_type.toLowerCase()}/${for_date}/${run_datetime}/${fire_centre}` const { data } = await axios.get(url) return data } diff --git a/web/src/app/rootReducer.ts b/web/src/app/rootReducer.ts index 5731ed03c..1153a6667 100644 --- a/web/src/app/rootReducer.ts +++ b/web/src/app/rootReducer.ts @@ -13,7 +13,6 @@ import fireCentersSlice from 'commonSlices/fireCentersSlice' import fireShapeAreasSlice from 'features/fba/slices/fireZoneAreasSlice' import valueAtCoordinateSlice from 'features/fba/slices/valueAtCoordinateSlice' import runDatesSlice from 'features/fba/slices/runDatesSlice' -import hfiFuelTypesSlice from 'features/fba/slices/hfiFuelTypesSlice' import fireZoneElevationInfoSlice from 'features/fba/slices/fireZoneElevationInfoSlice' import stationGroupsSlice from 'commonSlices/stationGroupsSlice' import selectedStationGroupsMembersSlice from 'commonSlices/selectedStationGroupMembers' @@ -21,7 +20,7 @@ import dataSlice from 'features/moreCast2/slices/dataSlice' import selectedStationsSlice from 'features/moreCast2/slices/selectedStationsSlice' import provincialSummarySlice from 'features/fba/slices/provincialSummarySlice' import fireCentreTPIStatsSlice from 'features/fba/slices/fireCentreTPIStatsSlice' -import fireCentreHfiFuelTypesSlice from 'features/fba/slices/fireCentreHfiFuelTypesSlice' +import fireCentreHFIFuelStatsSlice from 'features/fba/slices/fireCentreHFIFuelStatsSlice' const rootReducer = combineReducers({ percentileStations: stationReducer, @@ -38,8 +37,7 @@ const rootReducer = combineReducers({ fireShapeAreas: fireShapeAreasSlice, runDates: runDatesSlice, valueAtCoordinate: valueAtCoordinateSlice, - hfiFuelTypes: hfiFuelTypesSlice, - fireCentreHfiFuelTypes: fireCentreHfiFuelTypesSlice, + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice, fireZoneElevationInfo: fireZoneElevationInfoSlice, fireCentreTPIStats: fireCentreTPIStatsSlice, stationGroups: stationGroupsSlice, @@ -68,8 +66,7 @@ export const selectFireCenters = (state: RootState) => state.fireCenters export const selectFireShapeAreas = (state: RootState) => state.fireShapeAreas export const selectRunDates = (state: RootState) => state.runDates export const selectValueAtCoordinate = (state: RootState) => state.valueAtCoordinate -export const selectHFIFuelTypes = (state: RootState) => state.hfiFuelTypes -export const selectFireCentreHFIFuelTypes = (state: RootState) => state.fireCentreHfiFuelTypes +export const selectFireCentreHFIFuelTypes = (state: RootState) => state.fireCentreHFIFuelStats export const selectFireZoneElevationInfo = (state: RootState) => state.fireZoneElevationInfo export const selectFireCentreTPIStats = (state: RootState) => state.fireCentreTPIStats export const selectHFIDailiesLoading = (state: RootState): boolean => state.hfiCalculatorDailies.fireCentresLoading diff --git a/web/src/features/fba/components/infoPanel/FireZoneUnitSummary.tsx b/web/src/features/fba/components/infoPanel/FireZoneUnitSummary.tsx index 21d037580..58b337939 100644 --- a/web/src/features/fba/components/infoPanel/FireZoneUnitSummary.tsx +++ b/web/src/features/fba/components/infoPanel/FireZoneUnitSummary.tsx @@ -1,18 +1,18 @@ import React from 'react' import { Grid, Typography } from '@mui/material' import { isUndefined } from 'lodash' -import { FireShape, FireZoneTPIStats, FireZoneThresholdFuelTypeArea } from 'api/fbaAPI' +import { FireShape, FireZoneTPIStats, FireZoneFuelStats } from 'api/fbaAPI' import ElevationStatus from 'features/fba/components/viz/ElevationStatus' import { useTheme } from '@mui/material/styles' import FuelSummary from 'features/fba/components/viz/FuelSummary' interface FireZoneUnitSummaryProps { selectedFireZoneUnit: FireShape | undefined - fuelTypeInfo: Record + fireZoneFuelStats: Record fireZoneTPIStats: FireZoneTPIStats | undefined } -const FireZoneUnitSummary = ({ fuelTypeInfo, fireZoneTPIStats, selectedFireZoneUnit }: FireZoneUnitSummaryProps) => { +const FireZoneUnitSummary = ({ fireZoneFuelStats, fireZoneTPIStats, selectedFireZoneUnit }: FireZoneUnitSummaryProps) => { const theme = useTheme() if (isUndefined(selectedFireZoneUnit)) { @@ -27,7 +27,7 @@ const FireZoneUnitSummary = ({ fuelTypeInfo, fireZoneTPIStats, selectedFireZoneU sx={{ paddingBottom: theme.spacing(2), paddingTop: theme.spacing(2) }} > - + {!fireZoneTPIStats || diff --git a/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx b/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx index ada611a1e..0a9404c7e 100644 --- a/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx +++ b/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx @@ -27,7 +27,7 @@ const FireZoneUnitTabs = ({ setSelectedFireShape }: FireZoneUnitTabs) => { const { fireCentreTPIStats } = useSelector(selectFireCentreTPIStats) - const { fireCentreHfiFuelTypes } = useSelector(selectFireCentreHFIFuelTypes) + const { fireCentreHFIFuelStats } = useSelector(selectFireCentreHFIFuelTypes) const [tabNumber, setTabNumber] = useState(0) const sortedGroupedFireZoneUnits = useFireCentreDetails(selectedFireCenter) @@ -76,9 +76,9 @@ const FireZoneUnitTabs = ({ const hfiFuelStats = useMemo(() => { if (selectedFireCenter) { - return fireCentreHfiFuelTypes?.[selectedFireCenter?.name] + return fireCentreHFIFuelStats?.[selectedFireCenter?.name] } - }, [fireCentreHfiFuelTypes, selectedFireCenter]) + }, [fireCentreHFIFuelStats, selectedFireCenter]) if (isUndefined(selectedFireCenter) || isNull(selectedFireCenter)) { return
@@ -129,7 +129,7 @@ const FireZoneUnitTabs = ({ {sortedGroupedFireZoneUnits.map((zone, index) => ( stats.fire_zone_id == zone.fire_shape_id) : undefined } diff --git a/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx b/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx index 52059e929..a1c586914 100644 --- a/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx +++ b/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx @@ -24,7 +24,7 @@ describe('FireZoneUnitSummary', () => { window.ResizeObserver = ResizeObserver it('should not render empty div if selectedFireZoneUnit is undefined', () => { const { getByTestId } = render( - + ) const fireZoneUnitInfo = getByTestId('fire-zone-unit-summary-empty') expect(fireZoneUnitInfo).toBeInTheDocument() @@ -37,7 +37,7 @@ describe('FireZoneUnitSummary', () => { area_sqm: 10 } const { getByTestId } = render( - + ) const fireZoneUnitInfo = getByTestId('fire-zone-unit-summary') expect(fireZoneUnitInfo).toBeInTheDocument() @@ -50,7 +50,7 @@ describe('FireZoneUnitSummary', () => { area_sqm: 10 } const { queryByTestId } = render( - + ) const fireZoneUnitInfo = queryByTestId('elevation-status') expect(fireZoneUnitInfo).not.toBeInTheDocument() @@ -63,7 +63,7 @@ describe('FireZoneUnitSummary', () => { area_sqm: 10 } const { getByTestId } = render( - + ) const fireZoneUnitInfo = getByTestId('elevation-status') expect(fireZoneUnitInfo).toBeInTheDocument() @@ -78,7 +78,7 @@ describe('FireZoneUnitSummary', () => { } const { queryByTestId } = render( { +const buildTestStore = (hfiInitialState: FireCentreHFIFuelStatsState, tpiInitialState: CentreTPIStatsState) => { const rootReducer = combineReducers({ - fireCentreHfiFuelTypes: fireCentreHfiFuelTypesSlice, + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice, fireCentreTPIStats: fireCentreTPIStatsSlice }) const testStore = configureStore({ reducer: rootReducer, preloadedState: { - fireCentreHfiFuelTypes: hfiInitialState, + fireCentreHFIFuelStats: hfiInitialState, fireCentreTPIStats: tpiInitialState } }) @@ -78,13 +78,14 @@ const mockFireCentreTPIStats: Record = { [fireCentre1]: [{ fire_zone_id: 1, valley_bottom: 10, mid_slope: 90, upper_slope: 10 }] } -const mockFireCentreHfiFuelTypes: FireCentreHfiFuelsData = { +const mockFireCentreHFIFuelStats: FireCentreHFIStats = { 'Centre 1': { 1: [ { fuel_type: { fuel_type_id: 1, fuel_type_code: 'C', description: 'fuel type' }, area: 10, - threshold: { id: 1, name: 'threshold', description: 'description' } + threshold: { id: 1, name: 'threshold', description: 'description' }, + critical_hours: {start_time: 8, end_time: 11} } ] } @@ -127,7 +128,7 @@ const renderComponent = (testStore: any) => describe('FireZoneUnitTabs', () => { const testStore = buildTestStore( - { ...hfiInitialState, fireCentreHfiFuelTypes: mockFireCentreHfiFuelTypes }, + { ...hfiInitialState, fireCentreHFIFuelStats: mockFireCentreHFIFuelStats }, { ...tpiInitialState, fireCentreTPIStats: mockFireCentreTPIStats } ) it('should render', () => { diff --git a/web/src/features/fba/components/viz/CriticalHours.tsx b/web/src/features/fba/components/viz/CriticalHours.tsx new file mode 100644 index 000000000..02a1cfbbf --- /dev/null +++ b/web/src/features/fba/components/viz/CriticalHours.tsx @@ -0,0 +1,20 @@ +import { Typography } from '@mui/material' +import React from 'react' +import { isNull, isUndefined } from 'lodash' + +interface CriticalHoursProps { + start?: number + end?: number +} + +const CriticalHours = ({ start, end }: CriticalHoursProps) => { + const formattedCriticalHours = + isNull(start) || isUndefined(start) || isNull(end) || isUndefined(end) ? '-' : `${start}:00 - ${end}:00` + return ( + + {formattedCriticalHours} + + ) +} + +export default React.memo(CriticalHours) diff --git a/web/src/features/fba/components/viz/FuelSummary.tsx b/web/src/features/fba/components/viz/FuelSummary.tsx index a51bdd4bf..09394449c 100644 --- a/web/src/features/fba/components/viz/FuelSummary.tsx +++ b/web/src/features/fba/components/viz/FuelSummary.tsx @@ -1,16 +1,16 @@ import React, { useEffect, useState } from 'react' -import { FireShape, FireZoneThresholdFuelTypeArea } from 'api/fbaAPI' +import { FireShape, FireZoneFuelStats } from 'api/fbaAPI' import { Box, Tooltip, Typography } from '@mui/material' import { groupBy, isUndefined } from 'lodash' -import { DateTime } from 'luxon' import FuelDistribution from 'features/fba/components/viz/FuelDistribution' import { DataGridPro, GridColDef, GridColumnHeaderParams, GridRenderCellParams } from '@mui/x-data-grid-pro' import { styled, useTheme } from '@mui/material/styles' +import CriticalHours from 'features/fba/components/viz/CriticalHours' export interface FuelTypeInfoSummary { area: number - criticalHoursStart?: DateTime - criticalHoursEnd?: DateTime + criticalHoursStart?: number + criticalHoursEnd?: number id: number code: string description: string @@ -19,7 +19,7 @@ export interface FuelTypeInfoSummary { } interface FuelSummaryProps { - fuelTypeInfo: Record + fireZoneFuelStats: Record selectedFireZoneUnit: FireShape | undefined } @@ -38,7 +38,7 @@ const columns: GridColDef[] = [ headerClassName: 'fuel-summary-header', headerName: 'Fuel Type', sortable: false, - width: 120, + minWidth: 80, renderHeader: (params: GridColumnHeaderParams) => {params.colDef.headerName}, renderCell: (params: GridRenderCellParams) => ( @@ -48,29 +48,39 @@ const columns: GridColDef[] = [ }, { field: 'area', - flex: 3, + flex: 1, headerClassName: 'fuel-summary-header', headerName: 'Distribution > 4k kW/m', - minWidth: 200, sortable: false, renderHeader: (params: GridColumnHeaderParams) => {params.colDef.headerName}, renderCell: (params: GridRenderCellParams) => { return } + }, + { + field: 'criticalHours', + headerClassName: 'fuel-summary-header', + headerName: 'Critical Hours', + minWidth: 110, + sortable: false, + renderHeader: (params: GridColumnHeaderParams) => {params.colDef.headerName}, + renderCell: (params: GridRenderCellParams) => { + return + } } ] -const FuelSummary = ({ fuelTypeInfo, selectedFireZoneUnit }: FuelSummaryProps) => { +const FuelSummary = ({ fireZoneFuelStats, selectedFireZoneUnit }: FuelSummaryProps) => { const theme = useTheme() const [fuelTypeInfoRollup, setFuelTypeInfoRollup] = useState([]) useEffect(() => { - if (isUndefined(fuelTypeInfo) || isUndefined(selectedFireZoneUnit)) { + if (isUndefined(fireZoneFuelStats) || isUndefined(selectedFireZoneUnit)) { setFuelTypeInfoRollup([]) return } const shapeId = selectedFireZoneUnit.fire_shape_id - const fuelDetails = fuelTypeInfo[shapeId] + const fuelDetails = fireZoneFuelStats[shapeId] if (isUndefined(fuelDetails)) { setFuelTypeInfoRollup([]) return @@ -87,10 +97,14 @@ const FuelSummary = ({ fuelTypeInfo, selectedFireZoneUnit }: FuelSummaryProps) = if (groupedFuelDetail.length) { const area = groupedFuelDetail.reduce((acc, { area }) => acc + area, 0) const fuelType = groupedFuelDetail[0].fuel_type + const startTime = groupedFuelDetail[0].critical_hours.start_time + const endTime = groupedFuelDetail[0].critical_hours.end_time const fuelInfo: FuelTypeInfoSummary = { area, code: fuelType.fuel_type_code, description: fuelType.description, + criticalHoursStart: startTime, + criticalHoursEnd: endTime, id: fuelType.fuel_type_id, percent: totalHFIArea4K ? (area / totalHFIArea4K) * 100 : 0, selected: false @@ -99,7 +113,7 @@ const FuelSummary = ({ fuelTypeInfo, selectedFireZoneUnit }: FuelSummaryProps) = } } setFuelTypeInfoRollup(rollUp) - }, [fuelTypeInfo]) // eslint-disable-line react-hooks/exhaustive-deps + }, [fireZoneFuelStats]) // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/web/src/features/fba/components/viz/criticalHours.test.tsx b/web/src/features/fba/components/viz/criticalHours.test.tsx new file mode 100644 index 000000000..006612b7c --- /dev/null +++ b/web/src/features/fba/components/viz/criticalHours.test.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { render } from '@testing-library/react' +import CriticalHours from '@/features/fba/components/viz/CriticalHours' + +describe('CriticalHours', () => { + it('should render hours in 24 hour format', () => { + const { getByTestId } = render( + + ) + + const element = getByTestId('critical-hours') + expect(element).toBeInTheDocument() + expect(element).toHaveTextContent("8:00 - 11:00") + }) + + it('should render no critical hours', () => { + const { getByTestId } = render( + + ) + + const element = getByTestId('critical-hours') + expect(element).toBeInTheDocument() + expect(element).toHaveTextContent("-") + }) +}) diff --git a/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx b/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx index a653ab24c..0c213856e 100644 --- a/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx +++ b/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx @@ -25,7 +25,7 @@ import AdvisoryReport from 'features/fba/components/infoPanel/AdvisoryReport' import FireZoneUnitTabs from 'features/fba/components/infoPanel/FireZoneUnitTabs' import { fetchFireCentreTPIStats } from 'features/fba/slices/fireCentreTPIStatsSlice' import AboutDataPopover from 'features/fba/components/AboutDataPopover' -import { fetchFireCentreHfiFuelTypes } from 'features/fba/slices/fireCentreHfiFuelTypesSlice' +import { fetchFireCentreHFIFuelStats } from 'features/fba/slices/fireCentreHFIFuelStatsSlice' const ADVISORY_THRESHOLD = 20 @@ -118,7 +118,7 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { !isNull(fireCenter) ) { dispatch(fetchFireCentreTPIStats(fireCenter.name, runType, doiISODate, mostRecentRunDate.toString())) - dispatch(fetchFireCentreHfiFuelTypes(fireCenter.name, runType, doiISODate, mostRecentRunDate.toString())) + dispatch(fetchFireCentreHFIFuelStats(fireCenter.name, runType, doiISODate, mostRecentRunDate.toString())) } }, [fireCenter, mostRecentRunDate]) diff --git a/web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts b/web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts new file mode 100644 index 000000000..51274f750 --- /dev/null +++ b/web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts @@ -0,0 +1,52 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import { AppThunk } from 'app/store' +import { logError } from 'utils/error' +import { FireCentreHFIStats, getFireCentreHFIStats } from 'api/fbaAPI' +import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' + +export interface FireCentreHFIFuelStatsState { + error: string | null + fireCentreHFIFuelStats: FireCentreHFIStats +} + +export const initialState: FireCentreHFIFuelStatsState = { + error: null, + fireCentreHFIFuelStats: {} +} + +const fireCentreHFIFuelStatsSlice = createSlice({ + name: 'fireCentreHfiFuelStats', + initialState, + reducers: { + getFireCentreHFIFuelStatsStart(state: FireCentreHFIFuelStatsState) { + state.error = null + state.fireCentreHFIFuelStats = {} + }, + getFireCentreHFIFuelStatsFailed(state: FireCentreHFIFuelStatsState, action: PayloadAction) { + state.error = action.payload + }, + getFireCentreHFIFuelStatsSuccess(state: FireCentreHFIFuelStatsState, action: PayloadAction) { + state.error = null + state.fireCentreHFIFuelStats = action.payload + } + } +}) + +export const { getFireCentreHFIFuelStatsStart, getFireCentreHFIFuelStatsFailed, getFireCentreHFIFuelStatsSuccess } = + fireCentreHFIFuelStatsSlice.actions + +export default fireCentreHFIFuelStatsSlice.reducer + +export const fetchFireCentreHFIFuelStats = + (fireCentre: string, runType: RunType, forDate: string, runDatetime: string): AppThunk => + async dispatch => { + try { + dispatch(getFireCentreHFIFuelStatsStart()) + const data = await getFireCentreHFIStats(runType, forDate, runDatetime, fireCentre) + dispatch(getFireCentreHFIFuelStatsSuccess(data)) + } catch (err) { + dispatch(getFireCentreHFIFuelStatsFailed((err as Error).toString())) + logError(err) + } + } diff --git a/web/src/features/fba/slices/fireCentreHfiFuelTypesSlice.ts b/web/src/features/fba/slices/fireCentreHfiFuelTypesSlice.ts deleted file mode 100644 index 62a2d489e..000000000 --- a/web/src/features/fba/slices/fireCentreHfiFuelTypesSlice.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' - -import { AppThunk } from 'app/store' -import { logError } from 'utils/error' -import { FireCentreHfiFuelsData, getHFIThresholdsFuelTypesForCentre } from 'api/fbaAPI' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' - -export interface CentreHFIFuelTypeState { - error: string | null - fireCentreHfiFuelTypes: FireCentreHfiFuelsData -} - -export const initialState: CentreHFIFuelTypeState = { - error: null, - fireCentreHfiFuelTypes: {} -} - -const fireCentreHfiFuelTypesSlice = createSlice({ - name: 'fireCentreHfiFuelTypes', - initialState, - reducers: { - getFireCentreHfiFuelTypesStart(state: CentreHFIFuelTypeState) { - state.error = null - state.fireCentreHfiFuelTypes = {} - }, - getFireCentreHfiFuelTypesFailed(state: CentreHFIFuelTypeState, action: PayloadAction) { - state.error = action.payload - }, - getFireCentreHfiFuelTypesSuccess(state: CentreHFIFuelTypeState, action: PayloadAction) { - state.error = null - state.fireCentreHfiFuelTypes = action.payload - } - } -}) - -export const { getFireCentreHfiFuelTypesStart, getFireCentreHfiFuelTypesFailed, getFireCentreHfiFuelTypesSuccess } = - fireCentreHfiFuelTypesSlice.actions - -export default fireCentreHfiFuelTypesSlice.reducer - -export const fetchFireCentreHfiFuelTypes = - (fireCentre: string, runType: RunType, forDate: string, runDatetime: string): AppThunk => - async dispatch => { - try { - dispatch(getFireCentreHfiFuelTypesStart()) - const data = await getHFIThresholdsFuelTypesForCentre(runType, forDate, runDatetime, fireCentre) - dispatch(getFireCentreHfiFuelTypesSuccess(data)) - } catch (err) { - dispatch(getFireCentreHfiFuelTypesFailed((err as Error).toString())) - logError(err) - } - } diff --git a/web/src/features/fba/slices/hfiFuelTypesSlice.ts b/web/src/features/fba/slices/hfiFuelTypesSlice.ts deleted file mode 100644 index 35477d7ff..000000000 --- a/web/src/features/fba/slices/hfiFuelTypesSlice.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' - -import { AppThunk } from 'app/store' -import { logError } from 'utils/error' -import { FireZoneThresholdFuelTypeArea, getHFIThresholdsFuelTypesForZone } from 'api/fbaAPI' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' - -export interface HFIFuelTypeState { - loading: boolean - error: string | null - hfiThresholdsFuelTypes: Record -} - -const initialState: HFIFuelTypeState = { - loading: false, - error: null, - hfiThresholdsFuelTypes: {} -} - -const hfiFuelTypesSlice = createSlice({ - name: 'hfiFuelTypes', - initialState, - reducers: { - getHFIFuelsStart(state: HFIFuelTypeState) { - state.error = null - state.loading = true - state.hfiThresholdsFuelTypes = {} - }, - getHFIFuelsFailed(state: HFIFuelTypeState, action: PayloadAction) { - state.error = action.payload - state.loading = false - }, - getHFIFuelsStartSuccess( - state: HFIFuelTypeState, - action: PayloadAction> - ) { - state.error = null - state.hfiThresholdsFuelTypes = action.payload - state.loading = false - } - } -}) - -export const { getHFIFuelsStart, getHFIFuelsFailed, getHFIFuelsStartSuccess } = hfiFuelTypesSlice.actions - -export default hfiFuelTypesSlice.reducer - -export const fetchHighHFIFuels = - (runType: RunType, forDate: string, runDatetime: string, zoneID: number): AppThunk => - async dispatch => { - try { - dispatch(getHFIFuelsStart()) - const data = await getHFIThresholdsFuelTypesForZone(runType, forDate, runDatetime, zoneID) - dispatch(getHFIFuelsStartSuccess(data)) - } catch (err) { - dispatch(getHFIFuelsFailed((err as Error).toString())) - logError(err) - } - }