diff --git a/api/app/fire_behaviour/cffdrs.py b/api/app/fire_behaviour/cffdrs.py index b03de5026..1963ae338 100644 --- a/api/app/fire_behaviour/cffdrs.py +++ b/api/app/fire_behaviour/cffdrs.py @@ -550,7 +550,73 @@ def fine_fuel_moisture_code(ffmc: float, temperature: float, relative_humidity: raise CFFDRSException("Failed to calculate ffmc") -def initial_spread_index(ffmc: float, wind_speed: float, fbpMod: bool = False): +def duff_moisture_code(dmc: float, temperature: float, relative_humidity: float, + precipitation: float, latitude: float = 55, month: int = 7, + latitude_adjust: bool = True): + """ + Computes Duff Moisture Code (DMC) by delegating to the cffdrs R package. + + R function signature: + function (dmc_yda, temp, rh, prec, lat, mon, lat.adjust = TRUE) + + :param dmc: The Duff Moisture Code (unitless) of the previous day + :type dmc: float + :param temperature: Temperature (centigrade) + :type temperature: float + :param relative_humidity: Relative humidity (%) + :type relative_humidity: float + :param precipitation: 24-hour rainfall (mm) + :type precipitation: float + :param latitude: Latitude (decimal degrees), defaults to 55 + :type latitude: float + :param month: Month of the year (1-12), defaults to 7 (July) + :type month: int, optional + :param latitude_adjust: Options for whether day length adjustments should be applied to + the calculation, defaults to True + :type latitude_adjust: bool, optional + """ + if dmc is None: + dmc = NULL + result = CFFDRS.instance().cffdrs._dmcCalc(dmc, temperature, relative_humidity, precipitation, + latitude, month, latitude_adjust) + if isinstance(result[0], float): + return result[0] + raise CFFDRSException("Failed to calculate dmc") + + +def drought_code(dc: float, temperature: float, relative_humidity: float, precipitation: float, + latitude: float = 55, month: int = 7, latitude_adjust: bool = True) -> None: + """ + Computes Drought Code (DC) by delegating to the cffdrs R package. + + :param dc: The Drought Code (unitless) of the previous day + :type dc: float + :param temperature: Temperature (centigrade) + :type temperature: float + :param relative_humidity: Relative humidity (%) + :type relative_humidity: float + :param precipitation: 24-hour rainfall (mm) + :type precipitation: float + :param latitude: Latitude (decimal degrees), defaults to 55 + :type latitude: float + :param month: Month of the year (1-12), defaults to 7 (July) + :type month: int, optional + :param latitude_adjust: Options for whether day length adjustments should be applied to + the calculation, defaults to True + :type latitude_adjust: bool, optional + :raises CFFDRSException: + :return: None + """ + if dc is None: + dc = NULL + result = CFFDRS.instance().cffdrs._dcCalc(dc, temperature, relative_humidity, precipitation, + latitude, month, latitude_adjust) + if isinstance(result[0], float): + return result[0] + raise CFFDRSException("Failed to calculate dmc") + + +def initial_spread_index(ffmc: float, wind_speed: float, fbp_mod: bool = False): """ Computes Initial Spread Index (ISI) by delegating to cffdrs R package. This is necessary when recalculating ROS/HFI for modified FFMC values. Otherwise, should be using the ISI value retrieved from WFWX. @@ -565,7 +631,7 @@ def initial_spread_index(ffmc: float, wind_speed: float, fbpMod: bool = False): """ if ffmc is None: ffmc = NULL - result = CFFDRS.instance().cffdrs._ISIcalc(ffmc=ffmc, ws=wind_speed, fbpMod=fbpMod) + result = CFFDRS.instance().cffdrs._ISIcalc(ffmc=ffmc, ws=wind_speed, fbpMod=fbp_mod) if isinstance(result[0], float): return result[0] raise CFFDRSException("Failed to calculate ISI") diff --git a/api/app/morecast_v2/forecasts.py b/api/app/morecast_v2/forecasts.py index 002426b68..3be5acb61 100644 --- a/api/app/morecast_v2/forecasts.py +++ b/api/app/morecast_v2/forecasts.py @@ -1,5 +1,5 @@ -from datetime import datetime, time +from datetime import datetime, time, timedelta from urllib.parse import urljoin from app import config @@ -7,16 +7,20 @@ from collections import defaultdict from app.utils.time import vancouver_tz -from typing import List, Optional +from typing import List, Optional, Tuple from sqlalchemy.orm import Session from app.db.crud.morecast_v2 import get_forecasts_in_range from app.db.models.morecast_v2 import MorecastForecastRecord -from app.schemas.morecast_v2 import MoreCastForecastOutput, StationDailyFromWF1, WF1ForecastRecordType, WF1PostForecast, WeatherIndeterminate +from app.schemas.morecast_v2 import MoreCastForecastOutput, StationDailyFromWF1, WF1ForecastRecordType, WF1PostForecast, WeatherIndeterminate, WeatherDeterminate from app.wildfire_one.schema_parsers import WFWXWeatherStation from app.wildfire_one.wfwx_api import get_auth_header, get_forecasts_for_stations_by_date_range, get_wfwx_stations_from_station_codes +from app.fire_behaviour import cffdrs -def get_forecasts(db_session: Session, start_time: datetime, end_time: datetime, station_codes: List[int]) -> List[MoreCastForecastOutput]: +def get_forecasts(db_session: Session, start_time: Optional[datetime], end_time: Optional[datetime], station_codes: List[int]) -> List[MoreCastForecastOutput]: + if start_time is None or end_time is None: + return [] + result = get_forecasts_in_range(db_session, start_time, end_time, station_codes) forecasts: List[WeatherIndeterminate] = [MoreCastForecastOutput(station_code=forecast.station_code, @@ -105,3 +109,91 @@ def filter_for_api_forecasts(forecasts: List[WeatherIndeterminate], actuals: Lis if actual_exists(forecast, actuals): filtered_forecasts.append(forecast) return filtered_forecasts + + +def get_fwi_values(actuals: List[WeatherIndeterminate], forecasts: List[WeatherIndeterminate]) -> Tuple[List[WeatherIndeterminate], List[WeatherIndeterminate]]: + """ + Calculates actuals and forecasts with Fire Weather Index System values by calculating based off previous actuals and subsequent forecasts. + + :param actuals: List of actual weather values + :type actuals: List[WeatherIndeterminate] + :param forecasts: List of existing forecasted values + :type forecasts: List[WeatherIndeterminate] + :return: Actuals and forecasts with calculated fire weather index values + :rtype: Tuple[List[WeatherIndeterminate], List[WeatherIndeterminate] + """ + all_indeterminates = actuals + forecasts + indeterminates_dict = defaultdict(dict) + + # Shape indeterminates into nested dicts for quick and easy look ups by station code and date + for indeterminate in all_indeterminates: + indeterminates_dict[indeterminate.station_code][indeterminate.utc_timestamp.date()] = indeterminate + + for idx, indeterminate in enumerate(all_indeterminates): + last_indeterminate = indeterminates_dict[indeterminate.station_code].get( + indeterminate.utc_timestamp.date() - timedelta(days=1), None) + if last_indeterminate is not None: + updated_forecast = calculate_fwi_values(last_indeterminate, indeterminate) + all_indeterminates[idx] = updated_forecast + + updated_forecasts = [indeterminate for indeterminate in all_indeterminates if indeterminate.determinate == + WeatherDeterminate.FORECAST] + updated_actuals = [ + indeterminate for indeterminate in all_indeterminates if indeterminate.determinate == WeatherDeterminate.ACTUAL] + + return updated_actuals, updated_forecasts + + +def calculate_fwi_values(yesterday: WeatherIndeterminate, today: WeatherIndeterminate) -> WeatherIndeterminate: + """ + Uses CFFDRS library to calculate Fire Weather Index System values + + :param yesterday: The WeatherIndeterminate from the day before the date to calculate + :type yesterday: WeatherIndeterminate + :param today: The WeatherIndeterminate from the date to calculate + :type today: WeatherIndeterminate + :return: Updated WeatherIndeterminate + :rtype: WeatherIndeterminate + """ + + # weather params for calculation date + month_to_calculate_for = int(today.utc_timestamp.strftime('%m')) + latitude = today.latitude + temp = today.temperature + rh = today.relative_humidity + precip = today.precipitation + wind_spd = today.wind_speed + + if yesterday.fine_fuel_moisture_code is not None: + today.fine_fuel_moisture_code = cffdrs.fine_fuel_moisture_code(ffmc=yesterday.fine_fuel_moisture_code, + temperature=temp, + relative_humidity=rh, + precipitation=precip, + wind_speed=wind_spd) + if yesterday.duff_moisture_code is not None: + today.duff_moisture_code = cffdrs.duff_moisture_code(dmc=yesterday.duff_moisture_code, + temperature=temp, + relative_humidity=rh, + precipitation=precip, + latitude=latitude, + month=month_to_calculate_for, + latitude_adjust=True + ) + if yesterday.drought_code is not None: + today.drought_code = cffdrs.drought_code(dc=yesterday.drought_code, + temperature=temp, + relative_humidity=rh, + precipitation=precip, + latitude=latitude, + month=month_to_calculate_for, + latitude_adjust=True + ) + if today.fine_fuel_moisture_code is not None: + today.initial_spread_index = cffdrs.initial_spread_index(ffmc=today.fine_fuel_moisture_code, + wind_speed=today.wind_speed) + if today.duff_moisture_code is not None and today.drought_code is not None: + today.build_up_index = cffdrs.bui_calc(dmc=today.duff_moisture_code, dc=today.drought_code) + if today.initial_spread_index is not None and today.build_up_index is not None: + today.fire_weather_index = cffdrs.fire_weather_index(isi=today.initial_spread_index, bui=today.build_up_index) + + return today diff --git a/api/app/routers/morecast_v2.py b/api/app/routers/morecast_v2.py index ab1aa4aa9..5a0398803 100644 --- a/api/app/routers/morecast_v2.py +++ b/api/app/routers/morecast_v2.py @@ -13,14 +13,17 @@ from app.db.crud.morecast_v2 import get_forecasts_in_range, get_user_forecasts_for_date, save_all_forecasts from app.db.database import get_read_session_scope, get_write_session_scope from app.db.models.morecast_v2 import MorecastForecastRecord -from app.morecast_v2.forecasts import filter_for_api_forecasts, get_forecasts +from app.morecast_v2.forecasts import filter_for_api_forecasts, get_forecasts, get_fwi_values from app.schemas.morecast_v2 import (IndeterminateDailiesResponse, MoreCastForecastOutput, MoreCastForecastRequest, MorecastForecastResponse, ObservedDailiesForStations, StationDailiesResponse, - WeatherIndeterminate) + WeatherIndeterminate, + WeatherDeterminate, + SimulateIndeterminateIndices, + SimulatedWeatherIndeterminateResponse) from app.schemas.shared import StationsRequest from app.wildfire_one.schema_parsers import transform_morecastforecastoutput_to_weatherindeterminate from app.utils.time import get_hour_20_from_date, get_utc_now @@ -182,21 +185,29 @@ async def get_determinates_for_date_range(start_date: date, end_time = vancouver_tz.localize(datetime.combine(end_date, time.max)) start_date_of_interest = get_hour_20_from_date(start_date) end_date_of_interest = get_hour_20_from_date(end_date) + start_date_for_fwi_calc = start_date_of_interest - timedelta(days=1) async with ClientSession() as session: header = await get_auth_header(session) # get station information from the wfwx api wfwx_stations = await get_wfwx_stations_from_station_codes(session, header, unique_station_codes) wf1_actuals, wf1_forecasts = await get_daily_determinates_for_stations_and_date(session, header, - start_date_of_interest, + start_date_for_fwi_calc, end_date_of_interest, unique_station_codes) + + wf1_actuals, wf1_forecasts = get_fwi_values(wf1_actuals, wf1_forecasts) + + # drop the days before the date of interest that were needed to calculate fwi values + wf1_actuals = [actual for actual in wf1_actuals if actual.utc_timestamp >= start_date_of_interest] + wf1_forecasts = [forecast for forecast in wf1_forecasts if forecast.utc_timestamp >= start_date_of_interest] + # Find the min and max dates for actuals from wf1. These define the range of dates for which # we need to retrieve forecasts from our API database. Note that not all stations report actuals # at the same time, so every station won't necessarily have an actual for each date in the range. wf1_actuals_dates = [actual.utc_timestamp for actual in wf1_actuals] - min_wf1_actuals_date = min(wf1_actuals_dates) - max_wf1_actuals_date = max(wf1_actuals_dates) + min_wf1_actuals_date = min(wf1_actuals_dates, default=None) + max_wf1_actuals_date = max(wf1_actuals_dates, default=None) with get_read_session_scope() as db_session: forecasts_from_db: List[MoreCastForecastOutput] = get_forecasts( @@ -220,3 +231,20 @@ async def get_determinates_for_date_range(start_date: date, actuals=wf1_actuals, predictions=predictions, forecasts=wf1_forecasts) + + +@router.post('/simulate-indices/', response_model=SimulatedWeatherIndeterminateResponse) +async def calculate_forecasted_indices(simulate_records: SimulateIndeterminateIndices): + """ + Returns forecasts with all Fire Weather Index System values calculated using the CFFDRS R library + """ + indeterminates = simulate_records.simulate_records + logger.info( + f'/simulate-indices/ - simulating forecast records for stations: {set(indeterminate.station_name for indeterminate in indeterminates)}') + + forecasts = [indeterminate for indeterminate in indeterminates if indeterminate.determinate == + WeatherDeterminate.FORECAST] + actuals = [indeterminate for indeterminate in indeterminates if indeterminate.determinate == WeatherDeterminate.ACTUAL] + + _, forecasts = get_fwi_values(actuals, forecasts) + return (SimulatedWeatherIndeterminateResponse(simulated_forecasts=forecasts)) diff --git a/api/app/schemas/morecast_v2.py b/api/app/schemas/morecast_v2.py index f53755f2c..14efeb54c 100644 --- a/api/app/schemas/morecast_v2.py +++ b/api/app/schemas/morecast_v2.py @@ -121,6 +121,8 @@ class WeatherIndeterminate(BaseModel): station_name: str determinate: WeatherDeterminate utc_timestamp: datetime + latitude: Optional[float] = None + longitude: Optional[float] = None temperature: Optional[float] = None relative_humidity: Optional[float] = None precipitation: Optional[float] = None @@ -141,6 +143,14 @@ class IndeterminateDailiesResponse(BaseModel): forecasts: List[WeatherIndeterminate] +class SimulateIndeterminateIndices(BaseModel): + simulate_records: List[WeatherIndeterminate] + + +class SimulatedWeatherIndeterminateResponse(BaseModel): + simulated_forecasts: List[WeatherIndeterminate] + + class WF1ForecastRecordType(BaseModel): id: str = "FORECAST" displayLabel: str = "Forecast" diff --git a/api/app/tests/fixtures/wf1/lookup.json b/api/app/tests/fixtures/wf1/lookup.json index 5b074a399..6d6a265c9 100644 --- a/api/app/tests/fixtures/wf1/lookup.json +++ b/api/app/tests/fixtures/wf1/lookup.json @@ -124,6 +124,9 @@ }, "{'size': '1000', 'page': 0, 'startingTimestamp': 1678910400000, 'endingTimestamp': 1679256000000, 'stationIds': ['bfe0a6e2-e269-0210-e053-259e228e58c7', 'bfe0a6e2-e26b-0210-e053-259e228e58c7', 'bfe0a6e2-e3bc-0210-e053-259e228e58c7']}": { "None": "wfwx/v1/dailies/search__findDailiesByStationIdIsInAndWeatherTimestampBetweenOrderByStationIdAscWeatherTimestampAsc__size_1000_page_0_startingTimestamp_1678910400000_endingTimestamp_1679256000000_statio.json" + }, + "{'size': '1000', 'page': 0, 'startingTimestamp': 1678824000000, 'endingTimestamp': 1679256000000, 'stationIds': ['bfe0a6e2-e269-0210-e053-259e228e58c7', 'bfe0a6e2-e26b-0210-e053-259e228e58c7', 'bfe0a6e2-e3bc-0210-e053-259e228e58c7']}": { + "None": "wfwx/v1/dailies/search__findDailiesByStationIdIsInAndWeatherTimestampBetweenOrderByStationIdAscWeatherTimestampAsc__size_1000_page_0_startingTimestamp_1678824000000_endingTimestamp_1679256000000_statio.json" } } } diff --git a/api/app/tests/fixtures/wf1/wfwx/v1/dailies/search__findDailiesByStationIdIsInAndWeatherTimestampBetweenOrderByStationIdAscWeatherTimestampAsc__size_1000_page_0_startingTimestamp_1678824000000_endingTimestamp_1679256000000_statio.json b/api/app/tests/fixtures/wf1/wfwx/v1/dailies/search__findDailiesByStationIdIsInAndWeatherTimestampBetweenOrderByStationIdAscWeatherTimestampAsc__size_1000_page_0_startingTimestamp_1678824000000_endingTimestamp_1679256000000_statio.json new file mode 100644 index 000000000..26d4eebff --- /dev/null +++ b/api/app/tests/fixtures/wf1/wfwx/v1/dailies/search__findDailiesByStationIdIsInAndWeatherTimestampBetweenOrderByStationIdAscWeatherTimestampAsc__size_1000_page_0_startingTimestamp_1678824000000_endingTimestamp_1679256000000_statio.json @@ -0,0 +1,581 @@ +{ + "_embedded": { + "dailies": [ + { + "id": "9dccebf5-c0b0-49ad-9074-cfde7e6d71f9", + "createdBy": "GPEARCE", + "lastEntityUpdateTimestamp": 1665087010888, + "updateDate": "2022-10-06T20:10:10.000+0000", + "lastModifiedBy": "WFWX_WEATHER_API", + "archive": false, + "station": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bfe0a6e2-e269-0210-e053-259e228e58c7", + "stationId": "bfe0a6e2-e269-0210-e053-259e228e58c7", + "stationData": { + "id": "bfe0a6e2-e269-0210-e053-259e228e58c7", + "displayLabel": "ALEXIS CREEK", + "createdBy": "LEGACY_DATA_LOAD", + "lastModifiedBy": "WFWX_WEATHER_API", + "lastEntityUpdateTimestamp": 1677961862675, + "updateDate": "2023-03-04T20:31:02.000+0000", + "stationCode": 209, + "stationAcronym": "FAC", + "stationStatus": { + "id": "ACTIVE", + "displayLabel": "Active", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "dataSource": { + "id": "AUTO_CALLR", + "displayLabel": "Auto-Caller", + "displayOrder": 1, + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "dataLoggerType": { + "id": "FWS12S", + "displayLabel": "FWS12S", + "displayOrder": 6, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "siteType": { + "id": "WXSTN_GOES", + "displayLabel": "Weather Station - GOES", + "displayOrder": 5, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "weatherZone": { + "id": 17, + "displayLabel": "Zone 17", + "dangerRegion": 2, + "displayOrder": 17 + }, + "stationAccessTypeCode": { + "id": "4WD", + "displayLabel": "4 Wheel Drive", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "hourly": true, + "pluvioSnowGuage": false, + "latitude": 52.08377, + "longitude": -123.2732667, + "geometry": { + "crs": { + "type": "name", + "properties": { + "name": "EPSG:4326" + } + }, + "coordinates": [ + -123.2732667, + 52.08377 + ], + "type": "Point" + }, + "elevation": 791, + "windspeedAdjRoughness": 1.0, + "windspeedHeight": { + "id": "9.0_11.9", + "displayLabel": "9.0 - 11.9 (m)", + "displayOrder": 7, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "surfaceType": null, + "overWinterPrecipAdj": true, + "influencingSlope": 2, + "installationDate": 315558000000, + "decommissionDate": null, + "influencingAspect": null, + "owner": { + "id": "a9843bb7-0f44-4a80-8a62-79cca2a5c758", + "displayLabel": "BC Wildfire Service", + "displayOrder": 2, + "ownerName": "BCWS", + "ownerDisplayName": "BC Wildfire Service", + "ownerComment": null, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATAFIX - WFWX-891", + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "fireCentre": { + "id": 2, + "displayLabel": "Cariboo Fire Centre", + "alias": 7, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "zone": { + "id": 7, + "displayLabel": "Chilcotin Zone", + "alias": 5, + "fireCentre": "Cariboo Fire Centre", + "fireCentreAlias": 7, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "comments": "1st Daily 19800501; coordinates updated 2007/10/03 (P2)", + "ecccFileBaseUrlTemplate": null, + "lastProcessedSourceTime": "2023-03-04T12:31:02", + "crdStationName": null, + "stationAccessDescription": "Alexis creek Wx station is located 150M N/W of the Chilcolin FLNRO field office. Head west on Highway 20 from Williams Lake for an hour and thirty minutes or 112 Kilometers turning right on to Stum Lake Road for 100m and turn right again." + }, + "weatherTimestamp": 1665086400000, + "temperature": 18.7, + "dewPoint": 5.8, + "temperatureMin": null, + "temperatureMax": null, + "relativeHumidity": 43.0, + "relativeHumidityMin": null, + "relativeHumidityMax": null, + "windSpeed": 6.2, + "adjustedWindSpeed": 6.2, + "precipitation": 0.0, + "dangerForest": 4, + "dangerGrassland": 1, + "dangerScrub": 5, + "recordType": { + "id": "ACTUAL", + "displayLabel": "Actual", + "displayOrder": 1, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATA_LOAD" + }, + "windDirection": 163.0, + "fineFuelMoistureCode": 89.849, + "duffMoistureCode": 117.819, + "droughtCode": 874.588, + "initialSpreadIndex": 5.734, + "buildUpIndex": 176.272, + "fireWeatherIndex": 26.261, + "dailySeverityRating": null, + "grasslandCuring": null, + "observationValidInd": true, + "observationValidComment": null, + "missingHourlyData": false, + "previousState": null, + "businessKey": "1665086400000-bfe0a6e2-e269-0210-e053-259e228e58c7", + "_links": { + "self": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/9dccebf5-c0b0-49ad-9074-cfde7e6d71f9" + }, + "daily": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/9dccebf5-c0b0-49ad-9074-cfde7e6d71f9" + }, + "station": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bfe0a6e2-e269-0210-e053-259e228e58c7" + } + } + }, + { + "id": "535ce76b-e485-41a8-bd42-7400bc98ae83", + "createdBy": "GPEARCE", + "lastEntityUpdateTimestamp": 1665087635657, + "updateDate": "2022-10-06T20:20:35.000+0000", + "lastModifiedBy": "WFWX_WEATHER_API", + "archive": false, + "station": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bfe0a6e2-e26b-0210-e053-259e228e58c7", + "stationId": "bfe0a6e2-e26b-0210-e053-259e228e58c7", + "stationData": { + "id": "bfe0a6e2-e26b-0210-e053-259e228e58c7", + "displayLabel": "NAZKO", + "createdBy": "LEGACY_DATA_LOAD", + "lastModifiedBy": "WFWX_WEATHER_API", + "lastEntityUpdateTimestamp": 1677961870471, + "updateDate": "2023-03-04T20:31:10.000+0000", + "stationCode": 211, + "stationAcronym": "FNZ", + "stationStatus": { + "id": "ACTIVE", + "displayLabel": "Active", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "dataSource": { + "id": "AUTO_CALLR", + "displayLabel": "Auto-Caller", + "displayOrder": 1, + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "dataLoggerType": { + "id": "FWS12S", + "displayLabel": "FWS12S", + "displayOrder": 6, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "siteType": { + "id": "WXSTN_GOES", + "displayLabel": "Weather Station - GOES", + "displayOrder": 5, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "weatherZone": { + "id": 17, + "displayLabel": "Zone 17", + "dangerRegion": 2, + "displayOrder": 17 + }, + "stationAccessTypeCode": { + "id": "4WD", + "displayLabel": "4 Wheel Drive", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "hourly": true, + "pluvioSnowGuage": false, + "latitude": 52.9575, + "longitude": -123.5958, + "geometry": { + "crs": { + "type": "name", + "properties": { + "name": "EPSG:4326" + } + }, + "coordinates": [ + -123.5958, + 52.9575 + ], + "type": "Point" + }, + "elevation": 910, + "windspeedAdjRoughness": 1.0, + "windspeedHeight": { + "id": "9.0_11.9", + "displayLabel": "9.0 - 11.9 (m)", + "displayOrder": 7, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "surfaceType": null, + "overWinterPrecipAdj": true, + "influencingSlope": 0, + "installationDate": 801122400000, + "decommissionDate": null, + "influencingAspect": null, + "owner": { + "id": "a9843bb7-0f44-4a80-8a62-79cca2a5c758", + "displayLabel": "BC Wildfire Service", + "displayOrder": 2, + "ownerName": "BCWS", + "ownerDisplayName": "BC Wildfire Service", + "ownerComment": null, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATAFIX - WFWX-891", + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "fireCentre": { + "id": 2, + "displayLabel": "Cariboo Fire Centre", + "alias": 7, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "zone": { + "id": 3, + "displayLabel": "Quesnel Zone", + "alias": 1, + "fireCentre": "Cariboo Fire Centre", + "fireCentreAlias": 7, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "comments": "1st daily 19810501; CRMP- GOES NESID BCF594CA F6 LOGGER (1M SP)", + "ecccFileBaseUrlTemplate": null, + "lastProcessedSourceTime": "2023-03-04T12:31:10", + "crdStationName": null, + "stationAccessDescription": "Nazko highway out of Quesnel." + }, + "weatherTimestamp": 1665086400000, + "temperature": 15.6, + "dewPoint": 6.4, + "temperatureMin": null, + "temperatureMax": null, + "relativeHumidity": 54.0, + "relativeHumidityMin": null, + "relativeHumidityMax": null, + "windSpeed": 5.9, + "adjustedWindSpeed": 5.9, + "precipitation": 0.0, + "dangerForest": 3, + "dangerGrassland": 1, + "dangerScrub": 5, + "recordType": { + "id": "ACTUAL", + "displayLabel": "Actual", + "displayOrder": 1, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATA_LOAD" + }, + "windDirection": 318.0, + "fineFuelMoistureCode": 87.996, + "duffMoistureCode": 60.072, + "droughtCode": 600.487, + "initialSpreadIndex": 4.329, + "buildUpIndex": 96.108, + "fireWeatherIndex": 17.102, + "dailySeverityRating": null, + "grasslandCuring": null, + "observationValidInd": true, + "observationValidComment": null, + "missingHourlyData": false, + "previousState": null, + "businessKey": "1665086400000-bfe0a6e2-e26b-0210-e053-259e228e58c7", + "_links": { + "self": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/535ce76b-e485-41a8-bd42-7400bc98ae83" + }, + "daily": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/535ce76b-e485-41a8-bd42-7400bc98ae83" + }, + "station": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bfe0a6e2-e26b-0210-e053-259e228e58c7" + } + } + }, + { + "id": "5ae182a2-1ec6-4324-b9f0-9c4c6f7a1416", + "createdBy": "WFWX_WEATHER_API", + "lastEntityUpdateTimestamp": 1665086701632, + "updateDate": "2022-10-06T20:05:01.000+0000", + "lastModifiedBy": "WFWX_WEATHER_API", + "archive": true, + "station": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bfe0a6e2-e3bc-0210-e053-259e228e58c7", + "stationId": "bfe0a6e2-e3bc-0210-e053-259e228e58c7", + "stationData": { + "id": "bfe0a6e2-e3bc-0210-e053-259e228e58c7", + "displayLabel": "ASPEN GROVE", + "createdBy": "LEGACY_DATA_LOAD", + "lastModifiedBy": "WFWX_WEATHER_API", + "lastEntityUpdateTimestamp": 1677961854286, + "updateDate": "2023-03-04T20:30:54.000+0000", + "stationCode": 302, + "stationAcronym": "FSG", + "stationStatus": { + "id": "ACTIVE", + "displayLabel": "Active", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "dataSource": { + "id": "AUTO_CALLR", + "displayLabel": "Auto-Caller", + "displayOrder": 1, + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "dataLoggerType": { + "id": "FWS12S", + "displayLabel": "FWS12S", + "displayOrder": 6, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "siteType": { + "id": "WXSTN_CELL", + "displayLabel": "Weather Station - Cell", + "displayOrder": 6, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "weatherZone": { + "id": 20, + "displayLabel": "Zone 20", + "dangerRegion": 3, + "displayOrder": 20 + }, + "stationAccessTypeCode": { + "id": "4WD", + "displayLabel": "4 Wheel Drive", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "hourly": true, + "pluvioSnowGuage": false, + "latitude": 49.94811, + "longitude": -120.62107, + "geometry": { + "crs": { + "type": "name", + "properties": { + "name": "EPSG:4326" + } + }, + "coordinates": [ + -120.62107, + 49.94811 + ], + "type": "Point" + }, + "elevation": 1065, + "windspeedAdjRoughness": 1.0, + "windspeedHeight": { + "id": "9.0_11.9", + "displayLabel": "9.0 - 11.9 (m)", + "displayOrder": 7, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "surfaceType": null, + "overWinterPrecipAdj": false, + "influencingSlope": 0, + "installationDate": 912322800000, + "decommissionDate": null, + "influencingAspect": null, + "owner": { + "id": "a9843bb7-0f44-4a80-8a62-79cca2a5c758", + "displayLabel": "BC Wildfire Service", + "displayOrder": 2, + "ownerName": "BCWS", + "ownerDisplayName": "BC Wildfire Service", + "ownerComment": null, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATAFIX - WFWX-891", + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "fireCentre": { + "id": 25, + "displayLabel": "Kamloops Fire Centre", + "alias": 5, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "zone": { + "id": 31, + "displayLabel": "Merritt Zone", + "alias": 6, + "fireCentre": "Kamloops Fire Centre", + "fireCentreAlias": 5, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "comments": "Installed Cell Modem June 2021.", + "ecccFileBaseUrlTemplate": null, + "lastProcessedSourceTime": "2023-03-04T12:30:54", + "crdStationName": null, + "stationAccessDescription": "Just off Hwy #5A by Aspen Grove near the junction with 97C, through the cattle gate over guard, 200m to site." + }, + "weatherTimestamp": 1665086400000, + "temperature": 19.5, + "dewPoint": 4.3, + "temperatureMin": -60.0, + "temperatureMax": 55.0, + "relativeHumidity": 37.0, + "relativeHumidityMin": 0.0, + "relativeHumidityMax": 105.0, + "windSpeed": 10.6, + "adjustedWindSpeed": 10.6, + "precipitation": 0.0, + "dangerForest": 4, + "dangerGrassland": 1, + "dangerScrub": 5, + "recordType": { + "id": "ACTUAL", + "displayLabel": "Actual", + "displayOrder": 1, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATA_LOAD" + }, + "windDirection": 5.0, + "fineFuelMoistureCode": 91.439, + "duffMoistureCode": 104.704, + "droughtCode": 778.345, + "initialSpreadIndex": 8.981, + "buildUpIndex": 156.707, + "fireWeatherIndex": 34.633, + "dailySeverityRating": null, + "grasslandCuring": null, + "observationValidInd": true, + "observationValidComment": null, + "missingHourlyData": false, + "previousState": null, + "businessKey": "1665086400000-bfe0a6e2-e3bc-0210-e053-259e228e58c7", + "_links": { + "self": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/5ae182a2-1ec6-4324-b9f0-9c4c6f7a1416" + }, + "daily": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/5ae182a2-1ec6-4324-b9f0-9c4c6f7a1416" + }, + "station": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bfe0a6e2-e3bc-0210-e053-259e228e58c7" + } + } + } + ] + }, + "_links": { + "self": { + "href": "https://bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/search/findDailiesByStationIdIsInAndWeatherTimestampBetweenOrderByStationIdAscWeatherTimestampAsc?stationIds=bfe0a6e2-e269-0210-e053-259e228e58c7,bfe0a6e2-e26b-0210-e053-259e228e58c7,bfe0a6e2-e3bc-0210-e053-259e228e58c7&startingTimestamp=1665086400000&endingTimestamp=1665086400000" + } + } +} \ No newline at end of file diff --git a/api/app/tests/morecast_v2/test_forecasts.py b/api/app/tests/morecast_v2/test_forecasts.py index 266646941..548546cea 100644 --- a/api/app/tests/morecast_v2/test_forecasts.py +++ b/api/app/tests/morecast_v2/test_forecasts.py @@ -2,8 +2,10 @@ from typing import Optional from unittest.mock import Mock, patch import pytest +from math import isclose from app.db.models.morecast_v2 import MorecastForecastRecord -from app.morecast_v2.forecasts import actual_exists, construct_wf1_forecast, construct_wf1_forecasts, filter_for_api_forecasts, get_forecasts +from app.morecast_v2.forecasts import (actual_exists, construct_wf1_forecast, + construct_wf1_forecasts, filter_for_api_forecasts, get_forecasts, get_fwi_values) from app.schemas.morecast_v2 import (StationDailyFromWF1, WeatherDeterminate, WeatherIndeterminate, WF1ForecastRecordType, WF1PostForecast) from app.wildfire_one.schema_parsers import WFWXWeatherStation @@ -40,6 +42,82 @@ update_timestamp=end_time, update_user='test2') +actual_indeterminate_1 = WeatherIndeterminate(station_code=123, + station_name="TEST_STATION", + determinate=WeatherDeterminate.ACTUAL, + utc_timestamp=start_time, + latitude=51.507, + longitude=-121.162, + temperature=4.1, + relative_humidity=34.0, + precipitation=0.0, + wind_direction=184.0, + wind_speed=8.9, + fine_fuel_moisture_code=62, + duff_moisture_code=27, + drought_code=487, + initial_spread_index=4, + build_up_index=52, + fire_weather_index=14, + danger_rating=2) + +forecast_indeterminate_1 = WeatherIndeterminate(station_code=123, + station_name="TEST_STATION", + determinate=WeatherDeterminate.FORECAST, + utc_timestamp=end_time, + latitude=51.507, + longitude=-121.162, + temperature=6.3, + relative_humidity=35.0, + precipitation=0.0, + wind_direction=176.0, + wind_speed=8.9, + fine_fuel_moisture_code=None, + duff_moisture_code=None, + drought_code=None, + initial_spread_index=None, + build_up_index=None, + fire_weather_index=None, + danger_rating=None) + +actual_indeterminate_2 = WeatherIndeterminate(station_code=321, + station_name="TEST_STATION2", + determinate=WeatherDeterminate.ACTUAL, + utc_timestamp=start_time, + latitude=49.4358, + longitude=-116.7464, + temperature=28.3, + relative_humidity=34.0, + precipitation=0.0, + wind_direction=180.0, + wind_speed=5.6, + fine_fuel_moisture_code=91, + duff_moisture_code=91, + drought_code=560, + initial_spread_index=7, + build_up_index=130, + fire_weather_index=28, + danger_rating=3) + +forecast_indeterminate_2 = WeatherIndeterminate(station_code=321, + station_name="TEST_STATION2", + determinate=WeatherDeterminate.FORECAST, + utc_timestamp=end_time, + latitude=49.4358, + longitude=-116.7464, + temperature=27.0, + relative_humidity=50.0, + precipitation=1.0, + wind_direction=176.0, + wind_speed=12, + fine_fuel_moisture_code=None, + duff_moisture_code=None, + drought_code=None, + initial_spread_index=None, + build_up_index=None, + fire_weather_index=None, + danger_rating=None) + wfwx_weather_stations = [ WFWXWeatherStation( wfwx_id='1', @@ -87,10 +165,35 @@ def assert_wf1_forecast(result: WF1PostForecast, assert result.recordType == WF1ForecastRecordType() +def test_get_fwi_values(): + actuals, forecasts = get_fwi_values([actual_indeterminate_1, actual_indeterminate_2], [ + forecast_indeterminate_1, forecast_indeterminate_2]) + assert len(forecasts) == 2 + assert len(actuals) == 2 + # The below values were calculated using the CFFDRS library and the values from the test indeterminates as input + assert isclose(forecasts[0].fine_fuel_moisture_code, 76.59454201861331) + assert isclose(forecasts[0].duff_moisture_code, 27.5921591) + assert isclose(forecasts[0].drought_code, 487.838) + assert isclose(forecasts[0].initial_spread_index, 1.3234484847240926) + assert isclose(forecasts[0].build_up_index, 48.347912947622426) + assert isclose(forecasts[0].fire_weather_index, 3.841725745428403) + + assert isclose(forecasts[1].fine_fuel_moisture_code, 87.00116939852603) + assert isclose(forecasts[1].duff_moisture_code, 92.7296955) + assert isclose(forecasts[1].drought_code, 564.564) + assert isclose(forecasts[1].initial_spread_index, 5.1025731818740345) + assert isclose(forecasts[1].build_up_index, 131.47318170452328) + assert isclose(forecasts[1].fire_weather_index, 22.263212983628037) + + @patch('app.morecast_v2.forecasts.get_forecasts_in_range', return_value=[]) def test_get_forecasts_empty(_): result = get_forecasts(Mock(), start_time, end_time, []) assert len(result) == 0 + result = get_forecasts(Mock(), None, end_time, []) + assert len(result) == 0 + result = get_forecasts(Mock(), start_time, None, []) + assert len(result) == 0 @patch('app.morecast_v2.forecasts.get_forecasts_in_range', return_value=[morecast_record_1, morecast_record_2]) diff --git a/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py b/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py index 4b605d6ed..98168007e 100644 --- a/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py +++ b/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py @@ -17,6 +17,7 @@ today = '2022-10-07' morecast_v2_post_yesterday_dailies_url = f'/api/morecast-v2/yesterday-dailies/{today}' morecast_v2_post_determinates_url = '/api/morecast-v2/determinates/2023-03-15/2023-03-19' +morecast_v2_post_simulate_url = 'api/morecast-v2/simulate-indices/' decode_fn = "jwt.decode" @@ -158,3 +159,58 @@ def mock_admin_role_function(*_, **__): response = await async_client.post(morecast_v2_post_determinates_url, json={"stations": [209, 211, 302]}) assert response.status_code == 200 + + +def test_simulate_indeterminates_unauthorized(client: TestClient): + response = client.post(morecast_v2_post_simulate_url, json=[]) + assert response.status_code == 401 + + +@pytest.mark.anyio +async def test_simulate_indeterminates_authorized(anyio_backend, async_client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + def mock_admin_role_function(*_, **__): + return MockJWTDecodeWithRole('morecast2_write_forecast') + + monkeypatch.setattr(decode_fn, mock_admin_role_function) + + simulate_records = [ + {"station_code": 1203, + "station_name": "DARKWOODS", + "determinate": "Actual", + "utc_timestamp": "2023-10-30T20:00:00Z", + "latitude": 49.3576111, + "longitude": -116.95025, + "temperature": -0.8, + "relative_humidity": 65.0, + "precipitation": 0.8, + "wind_direction": 201.0, + "wind_speed": 6.7, + "fine_fuel_moisture_code": 72.26436115751054, + "duff_moisture_code": 4.5262768, + "drought_code": 293.47, + "initial_spread_index": 0.9472828989641714, + "build_up_index": 8.716462008301884, + "fire_weather_index": 0.5312701384624857, + "danger_rating": 1}, + {"station_code": 1203, + "station_name": "DARKWOODS", + "determinate": "Actual", + "utc_timestamp": "2023-10-31T20:00:00Z", + "latitude": 49.3576111, + "longitude": -116.95025, + "temperature": 3.6, + "relative_humidity": 47.0, + "precipitation": 0.0, + "wind_direction": 214.0, + "wind_speed": 8.2, + "fine_fuel_moisture_code": 78.95635139203418, + "duff_moisture_code": 4.90371312, + "drought_code": 294.822, + "initial_spread_index": 1.5488967924410735, + "build_up_index": 9.41589468613904, + "fire_weather_index": 0.9046887731834204, + "danger_rating": 1} + ] + + response = await async_client.post(morecast_v2_post_simulate_url, json={"simulate_records": simulate_records}) + assert response.status_code == 200 diff --git a/api/app/wildfire_one/schema_parsers.py b/api/app/wildfire_one/schema_parsers.py index 36b062ae4..cf7423154 100644 --- a/api/app/wildfire_one/schema_parsers.py +++ b/api/app/wildfire_one/schema_parsers.py @@ -83,6 +83,8 @@ async def weather_indeterminate_list_mapper(raw_dailies: Generator[dict, None, N async for raw_daily in raw_dailies: station_code = raw_daily.get('stationData').get('stationCode') station_name = raw_daily.get('stationData').get('displayLabel') + latitude = raw_daily.get('stationData').get('latitude') + longitude = raw_daily.get('stationData').get('longitude') utc_timestamp = datetime.fromtimestamp(raw_daily.get('weatherTimestamp') / 1000, tz=timezone.utc) precip = raw_daily.get('precipitation') rh = raw_daily.get('relativeHumidity') @@ -101,6 +103,8 @@ async def weather_indeterminate_list_mapper(raw_dailies: Generator[dict, None, N observed_dailies.append(WeatherIndeterminate( station_code=station_code, station_name=station_name, + latitude=latitude, + longitude=longitude, determinate=WeatherDeterminate.ACTUAL, utc_timestamp=utc_timestamp, temperature=temp, @@ -120,6 +124,8 @@ async def weather_indeterminate_list_mapper(raw_dailies: Generator[dict, None, N forecasts.append(WeatherIndeterminate( station_code=station_code, station_name=station_name, + latitude=latitude, + longitude=longitude, determinate=WeatherDeterminate.FORECAST, utc_timestamp=utc_timestamp, temperature=temp, diff --git a/web/src/api/moreCast2API.ts b/web/src/api/moreCast2API.ts index 2f6f96687..f8278993f 100644 --- a/web/src/api/moreCast2API.ts +++ b/web/src/api/moreCast2API.ts @@ -1,7 +1,8 @@ import axios from 'api/axios' import { isEqual } from 'lodash' import { DateTime } from 'luxon' -import { MoreCast2ForecastRow } from 'features/moreCast2/interfaces' +import { MoreCast2ForecastRow, MoreCast2Row } from 'features/moreCast2/interfaces' +import { isForecastRowPredicate } from 'features/moreCast2/saveForecasts' export enum ModelChoice { ACTUAL = 'ACTUAL', @@ -121,6 +122,8 @@ export interface WeatherIndeterminate { id: string station_code: number station_name: string + latitude: number + longitude: number determinate: WeatherDeterminateType utc_timestamp: string precipitation: number | null @@ -149,6 +152,10 @@ export interface WeatherIndeterminateResponse { predictions: WeatherIndeterminate[] } +export interface UpdatedWeatherIndeterminateResponse { + simulated_forecasts: WeatherIndeterminate[] +} + export const ModelOptions: ModelType[] = ModelChoices.filter(choice => !isEqual(choice, ModelChoice.MANUAL)) export interface MoreCast2ForecastRecord { @@ -232,3 +239,43 @@ export async function fetchWeatherIndeterminates( return payload } + +export async function fetchCalculatedIndices( + recordsToSimulate: MoreCast2Row[] +): Promise { + const url = 'morecast-v2/simulate-indices/' + const determinatesToSimulate = mapMoreCast2RowsToIndeterminates(recordsToSimulate) + const { data } = await axios.post(url, { + simulate_records: determinatesToSimulate + }) + + return data +} + +export const mapMoreCast2RowsToIndeterminates = (rows: MoreCast2Row[]): WeatherIndeterminate[] => { + const mappedIndeterminates = rows.map(r => { + const isForecast = isForecastRowPredicate(r) + return { + id: r.id, + station_code: r.stationCode, + station_name: r.stationName, + determinate: isForecast ? WeatherDeterminate.FORECAST : WeatherDeterminate.ACTUAL, + latitude: r.latitude, + longitude: r.longitude, + utc_timestamp: r.forDate.toString(), + precipitation: isForecast ? r.precipForecast!.value : r.precipActual, + relative_humidity: isForecast ? r.rhForecast!.value : r.rhActual, + temperature: isForecast ? r.tempForecast!.value : r.tempActual, + wind_direction: isForecast ? r.windDirectionForecast!.value : r.windDirectionActual, + wind_speed: isForecast ? r.windSpeedForecast!.value : r.windSpeedActual, + fine_fuel_moisture_code: isForecast ? r.ffmcCalcForecast!.value : r.ffmcCalcActual, + duff_moisture_code: isForecast ? r.dmcCalcForecast!.value : r.dmcCalcActual, + drought_code: isForecast ? r.dcCalcForecast!.value : r.dcCalcActual, + initial_spread_index: isForecast ? r.isiCalcForecast!.value : r.isiCalcActual, + build_up_index: isForecast ? r.buiCalcForecast!.value : r.buiCalcActual, + fire_weather_index: isForecast ? r.fwiCalcForecast!.value : r.fwiCalcActual, + danger_rating: isForecast ? null : r.rhActual + } + }) + return mappedIndeterminates +} diff --git a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx index de1824a07..d1a5f17c0 100644 --- a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx +++ b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx @@ -49,11 +49,13 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato } public generateForecastColDef = (headerName?: string) => { + const isCalcField = this.field.includes('Calc') + return this.generateForecastColDefWith( `${this.field}${WeatherDeterminate.FORECAST}`, headerName ? headerName : this.headerName, this.precision, - DEFAULT_FORECAST_COLUMN_WIDTH + isCalcField ? DEFAULT_COLUMN_WIDTH : DEFAULT_FORECAST_COLUMN_WIDTH ) } @@ -101,6 +103,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato } public generateForecastColDefWith = (field: string, headerName: string, precision: number, width?: number) => { + const isCalcField = field.includes('Calc') return { field: field, disableColumnMenu: true, @@ -111,7 +114,9 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato type: 'number', width: width || 120, renderHeader: (params: GridColumnHeaderParams) => { - return this.gridComponentRenderer.renderForecastHeaderWith(params) + return isCalcField + ? this.gridComponentRenderer.renderHeaderWith(params) + : this.gridComponentRenderer.renderForecastHeaderWith(params) }, renderCell: (params: Pick) => { return this.gridComponentRenderer.renderForecastCellWith(params, field) diff --git a/web/src/features/moreCast2/components/DataGridColumns.tsx b/web/src/features/moreCast2/components/DataGridColumns.tsx index 63b76622a..ee06b8807 100644 --- a/web/src/features/moreCast2/components/DataGridColumns.tsx +++ b/web/src/features/moreCast2/components/DataGridColumns.tsx @@ -55,7 +55,7 @@ export class DataGridColumns { public static getSummaryColumns(): GridColDef[] { return MORECAST2_STATION_DATE_FIELDS.map(field => field.generateColDef()).concat( MORECAST2_FORECAST_FIELDS.map(forecastField => forecastField.generateForecastColDef()).concat( - MORECAST2_INDEX_FIELDS.map(field => field.generateColDef()) + MORECAST2_INDEX_FIELDS.map(field => field.generateForecastColDef()) ) ) } diff --git a/web/src/features/moreCast2/components/ForecastSummaryDataGrid.tsx b/web/src/features/moreCast2/components/ForecastSummaryDataGrid.tsx index 3e135b6b4..787b97557 100644 --- a/web/src/features/moreCast2/components/ForecastSummaryDataGrid.tsx +++ b/web/src/features/moreCast2/components/ForecastSummaryDataGrid.tsx @@ -6,6 +6,10 @@ import { MoreCast2Row } from 'features/moreCast2/interfaces' import { LinearProgress } from '@mui/material' import ApplyToColumnMenu from 'features/moreCast2/components/ApplyToColumnMenu' import { DataGridColumns } from 'features/moreCast2/components/DataGridColumns' +import { storeUserEditedRows, getSimulatedIndices } from 'features/moreCast2/slices/dataSlice' +import { AppDispatch } from 'app/store' +import { useDispatch } from 'react-redux' +import { filterRowsForSimulationFromEdited } from 'features/moreCast2/rowFilters' const PREFIX = 'ForecastSummaryDataGrid' @@ -42,6 +46,18 @@ const ForecastSummaryDataGrid = ({ handleColumnHeaderClick, handleClose }: ForecastSummaryDataGridProps) => { + const dispatch: AppDispatch = useDispatch() + const processRowUpdate = async (editedRow: MoreCast2Row) => { + dispatch(storeUserEditedRows([editedRow])) + + const rowsForSimulation = filterRowsForSimulationFromEdited(editedRow, rows) + if (rowsForSimulation) { + dispatch(getSimulatedIndices(rowsForSimulation)) + } + + return editedRow + } + return ( params.row[params.field] !== ModelChoice.ACTUAL} + processRowUpdate={processRowUpdate} /> { - const index = field.indexOf('Forecast') - const prefix = field.slice(0, index) - const actualField = `${prefix}Actual` + const actualField = field.replace('Forecast', 'Actual') return actualField } @@ -66,12 +64,14 @@ export class GridComponentRenderer { // We need the prefix to help us grab the correct 'actual' field (eg. tempACTUAL, precipACTUAL, etc.) const actualField = this.getActualField(field) + const isCalcField = field.includes('Calc') + const isActual = !isNaN(params.row[actualField]) return ( ) diff --git a/web/src/features/moreCast2/components/MoreCast2Column.tsx b/web/src/features/moreCast2/components/MoreCast2Column.tsx index c3058c36e..9e3888b5d 100644 --- a/web/src/features/moreCast2/components/MoreCast2Column.tsx +++ b/web/src/features/moreCast2/components/MoreCast2Column.tsx @@ -112,13 +112,13 @@ export const rhForecastField = new IndeterminateField('rh', 'RH', 'number', 0, t export const windDirForecastField = new IndeterminateField('windDirection', 'Wind Dir', 'number', 0, true) export const windSpeedForecastField = new IndeterminateField('windSpeed', 'Wind Speed', 'number', 1, true) export const precipForecastField = new IndeterminateField('precip', 'Precip', 'number', 1, true) -export const buiField = new IndeterminateField('bui', 'BUI', 'number', 0, false) -export const isiField = new IndeterminateField('isi', 'ISI', 'number', 1, false) -export const fwiField = new IndeterminateField('fwi', 'FWI', 'number', 0, false) -export const ffmcField = new IndeterminateField('ffmc', 'FFMC', 'number', 1, false) -export const dmcField = new IndeterminateField('dmc', 'DMC', 'number', 0, false) -export const dcField = new IndeterminateField('dc', 'DC', 'number', 0, false) -export const dgrField = new IndeterminateField('dgr', 'DGR', 'number', 0, false) +export const buiField = new IndeterminateField('buiCalc', 'BUI', 'number', 0, false) +export const isiField = new IndeterminateField('isiCalc', 'ISI', 'number', 1, false) +export const fwiField = new IndeterminateField('fwiCalc', 'FWI', 'number', 0, false) +export const ffmcField = new IndeterminateField('ffmcCalc', 'FFMC', 'number', 1, false) +export const dmcField = new IndeterminateField('dmcCalc', 'DMC', 'number', 0, false) +export const dcField = new IndeterminateField('dcCalc', 'DC', 'number', 0, false) +export const dgrField = new IndeterminateField('dgrCalc', 'DGR', 'number', 0, false) export const MORECAST2_STATION_DATE_FIELDS: ColDefGenerator[] = [ StationForecastField.getInstance(), @@ -143,7 +143,7 @@ export const MORECAST2_FORECAST_FIELDS: ForecastColDefGenerator[] = [ precipForecastField ] -export const MORECAST2_INDEX_FIELDS: ColDefGenerator[] = [ +export const MORECAST2_INDEX_FIELDS: ForecastColDefGenerator[] = [ ffmcField, dmcField, dcField, diff --git a/web/src/features/moreCast2/components/TabbedDataGrid.tsx b/web/src/features/moreCast2/components/TabbedDataGrid.tsx index 5146f58b2..c8341d162 100644 --- a/web/src/features/moreCast2/components/TabbedDataGrid.tsx +++ b/web/src/features/moreCast2/components/TabbedDataGrid.tsx @@ -6,9 +6,14 @@ import { DataGridColumns, columnGroupingModel } from 'features/moreCast2/compone import ForecastDataGrid from 'features/moreCast2/components/ForecastDataGrid' import ForecastSummaryDataGrid from 'features/moreCast2/components/ForecastSummaryDataGrid' import SelectableButton from 'features/moreCast2/components/SelectableButton' -import { selectWeatherIndeterminatesLoading } from 'features/moreCast2/slices/dataSlice' +import { + getSimulatedIndices, + selectUserEditedRows, + selectWeatherIndeterminatesLoading, + storeUserEditedRows +} from 'features/moreCast2/slices/dataSlice' import React, { useEffect, useState } from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { MoreCast2ForecastRow, MoreCast2Row, PredictionItem } from 'features/moreCast2/interfaces' import { selectSelectedStations } from 'features/moreCast2/slices/selectedStationsSlice' import { groupBy, isEqual, isUndefined } from 'lodash' @@ -19,6 +24,10 @@ import { DateRange } from 'components/dateRangePicker/types' import MoreCast2Snackbar from 'features/moreCast2/components/MoreCast2Snackbar' import { isForecastRowPredicate, getRowsToSave, isForecastValid } from 'features/moreCast2/saveForecasts' import MoreCast2DateRangePicker from 'features/moreCast2/components/MoreCast2DateRangePicker' +import { AppDispatch } from 'app/store' +import { deepClone } from '@mui/x-data-grid/utils/utils' +import { filterAllVisibleRowsForSimulation } from 'features/moreCast2/rowFilters' +import { mapForecastChoiceLabels } from 'features/moreCast2/util' export const Root = styled('div')({ display: 'flex', @@ -43,10 +52,12 @@ interface TabbedDataGridProps { } const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProps) => { + const dispatch: AppDispatch = useDispatch() const selectedStations = useSelector(selectSelectedStations) const loading = useSelector(selectWeatherIndeterminatesLoading) const { roles, isAuthenticated } = useSelector(selectAuthentication) const { wf1Token } = useSelector(selectWf1Authentication) + const userEditedRows = useSelector(selectUserEditedRows) // A copy of the sortedMoreCast2Rows as local state const [allRows, setAllRows] = useState(morecast2Rows) @@ -74,7 +85,11 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp } | null>(null) const handleColumnHeaderClick: GridEventListener<'columnHeaderClick'> = (params, event) => { - if (!isEqual(params.colDef.field, 'stationName') && !isEqual(params.colDef.field, 'forDate')) { + if ( + !isEqual(params.colDef.field, 'stationName') && + !isEqual(params.colDef.field, 'forDate') && + !params.colDef.field.includes('Calc') + ) { setClickedColDef(params.colDef) setContextMenu(contextMenu === null ? { mouseX: event.clientX, mouseY: event.clientY } : null) } @@ -85,8 +100,9 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp } useEffect(() => { - setAllRows([...morecast2Rows]) - }, [morecast2Rows]) + const labelledRows = mapForecastChoiceLabels(morecast2Rows, deepClone(userEditedRows)) + setAllRows(labelledRows) + }, [userEditedRows, morecast2Rows]) useEffect(() => { const newVisibleRows: MoreCast2Row[] = [] @@ -215,6 +231,12 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp predictionItem.value = mostRecentValue as number }) } + const rowsForSimulation = filterAllVisibleRowsForSimulation(newRows) + if (rowsForSimulation) { + dispatch(getSimulatedIndices(rowsForSimulation)) + } + + dispatch(storeUserEditedRows(newRows)) setVisibleRows(newRows) } @@ -236,6 +258,12 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp predictionItem.value = (row[sourceKey] as number) ?? NaN } } + const rowsForSimulation = filterAllVisibleRowsForSimulation(newRows) + if (rowsForSimulation) { + dispatch(getSimulatedIndices(rowsForSimulation)) + } + + dispatch(storeUserEditedRows(newRows)) setVisibleRows(newRows) } diff --git a/web/src/features/moreCast2/interfaces.ts b/web/src/features/moreCast2/interfaces.ts index 11fca7696..03587acc6 100644 --- a/web/src/features/moreCast2/interfaces.ts +++ b/web/src/features/moreCast2/interfaces.ts @@ -24,17 +24,26 @@ export interface BaseRow { stationCode: number stationName: string forDate: DateTime + latitude: number + longitude: number } export interface MoreCast2Row extends BaseRow { // Fire weather indices - ffmc: number - dmc: number - dc: number - isi: number - bui: number - fwi: number - dgr: number + ffmcCalcActual: number + dmcCalcActual: number + dcCalcActual: number + isiCalcActual: number + buiCalcActual: number + fwiCalcActual: number + dgrCalcActual: number + ffmcCalcForecast?: PredictionItem + dmcCalcForecast?: PredictionItem + dcCalcForecast?: PredictionItem + isiCalcForecast?: PredictionItem + buiCalcForecast?: PredictionItem + fwiCalcForecast?: PredictionItem + dgrCalcForecast?: PredictionItem // Forecast properties precipForecast?: PredictionItem diff --git a/web/src/features/moreCast2/rowFilters.test.ts b/web/src/features/moreCast2/rowFilters.test.ts new file mode 100644 index 000000000..b2af1ae38 --- /dev/null +++ b/web/src/features/moreCast2/rowFilters.test.ts @@ -0,0 +1,112 @@ +import { DateTime } from 'luxon' +import { createEmptyMoreCast2Row } from 'features/moreCast2/slices/dataSlice' +import { MoreCast2Row } from 'features/moreCast2/interfaces' +import { filterRowsForSimulationFromEdited, filterAllVisibleRowsForSimulation } from 'features/moreCast2/rowFilters' +import { ModelType } from 'api/moreCast2API' +import { rowIDHasher } from 'features/moreCast2/util' + +const TEST_DATE = DateTime.fromISO('2023-02-16T20:00:00+00:00') + +export const buildValidForecastRow = ( + stationCode: number, + forDate: DateTime, + choice: ModelType = 'FORECAST' +): MoreCast2Row => { + const id = rowIDHasher(stationCode, forDate) + const forecastRow = createEmptyMoreCast2Row(id, stationCode, 'stationName', forDate, 1, 2) + forecastRow.precipForecast = { choice: choice, value: 2 } + forecastRow.tempForecast = { choice: choice, value: 2 } + forecastRow.rhForecast = { choice: choice, value: 2 } + forecastRow.windSpeedForecast = { choice: choice, value: 2 } + forecastRow.id = id + + return forecastRow +} + +const buildValidActualRow = (stationCode: number, forDate: DateTime): MoreCast2Row => { + const actualRow = createEmptyMoreCast2Row('id', stationCode, 'stationName', forDate, 1, 2) + actualRow.precipActual = 1 + actualRow.tempActual = 1 + actualRow.rhActual = 1 + actualRow.windSpeedActual = 1 + + return actualRow +} + +const buildInvalidForecastRow = (stationCode: number, forDate: DateTime): MoreCast2Row => { + const forecastRow = createEmptyMoreCast2Row('id', stationCode, 'stationName', forDate, 1, 2) + + return forecastRow +} + +const actual1B = buildValidActualRow(1, TEST_DATE.minus({ days: 1 })) // exclude +const actual1A = buildValidActualRow(1, TEST_DATE) // include +const forecast1A = buildValidForecastRow(1, TEST_DATE.plus({ days: 1 })) // edited row +const forecast1B = buildValidForecastRow(1, TEST_DATE.plus({ days: 2 })) // include +const forecast1C = buildInvalidForecastRow(1, TEST_DATE.plus({ days: 3 })) // exclude + +const actual2B = buildValidActualRow(2, TEST_DATE.minus({ days: 1 })) // exclude +const actual2A = buildValidActualRow(2, TEST_DATE) // include +const forecast2A = buildValidForecastRow(2, TEST_DATE.plus({ days: 1 })) // include +const forecast2B = buildValidForecastRow(2, TEST_DATE.plus({ days: 2 })) // include +const forecast2C = buildInvalidForecastRow(2, TEST_DATE.plus({ days: 3 })) // exclude +const forecast2D = buildInvalidForecastRow(2, TEST_DATE.plus({ days: 4 })) // exclude + +const rows = [ + actual1A, + actual1B, + forecast1A, + forecast1B, + forecast1C, + actual2A, + forecast2A, + actual2B, + forecast2B, + forecast2C, + forecast2D +] + +describe('filterRowsForSimulationFromEdited', () => { + const filteredRows = filterRowsForSimulationFromEdited(forecast1A, rows) + it('should filter for valid rows before and after the edited row ', () => { + expect(filteredRows).toEqual(expect.arrayContaining([actual1A, forecast1A, forecast1B])) + }) + it('should not contain invalid forecasts', () => { + expect(filteredRows).not.toContain(forecast1C) + }) + it('should not contain unnecessary actuals', () => { + expect(filteredRows).not.toContain(actual1B) + }) + it('should not contain rows from other stations', () => { + expect(filteredRows).not.toContain(forecast2A) + expect(filteredRows).not.toContain(forecast2B) + expect(filteredRows).not.toContain(actual2A) + }) + it('should return undefined if yesterday does not contain a valid row', () => { + actual1A.precipActual = NaN + const filteredRows = filterRowsForSimulationFromEdited(forecast1A, rows) + expect(filteredRows).toBe(undefined) + }) +}) +describe('filterAllVisibleRowsForSimulation', () => { + const filteredRows = filterAllVisibleRowsForSimulation(rows) + it('should only include valid forecasts and most recent actuals for each station', () => { + expect(filteredRows).toEqual( + expect.arrayContaining([actual1A, forecast1A, forecast1B, actual2A, forecast2A, forecast2B]) + ) + }) + it('should not contain invalid forecasts', () => { + expect(filteredRows).not.toContain(forecast1C) + expect(filteredRows).not.toContain(forecast2C) + expect(filteredRows).not.toContain(forecast2D) + }) + it('should not contain unnecessary actuals', () => { + expect(filteredRows).not.toContain(actual2B) + expect(filteredRows).not.toContain(actual1B) + }) + it('should return undefined if there are no valid forecasts', () => { + const rows = [actual1A, forecast1C, forecast2C, forecast2D] + const filteredRows = filterAllVisibleRowsForSimulation(rows) + expect(filteredRows).toBe(undefined) + }) +}) diff --git a/web/src/features/moreCast2/rowFilters.ts b/web/src/features/moreCast2/rowFilters.ts new file mode 100644 index 000000000..2b0052f6d --- /dev/null +++ b/web/src/features/moreCast2/rowFilters.ts @@ -0,0 +1,42 @@ +import { MoreCast2Row } from 'features/moreCast2/interfaces' +import { validForecastPredicate, validActualPredicate, validActualOrForecastPredicate } from 'features/moreCast2/util' + +export const filterAllVisibleRowsForSimulation = (rows: MoreCast2Row[]): MoreCast2Row[] | undefined => { + const forecasts = rows.filter(validForecastPredicate) + const actuals = rows.filter(validActualPredicate) + const mostRecentActualMap = new Map() + let rowsForSimulation = undefined + + if (forecasts.length > 0) { + for (const row of actuals) { + const recentActual = mostRecentActualMap.get(row.stationCode) + if (!recentActual || recentActual.forDate < row.forDate) { + mostRecentActualMap.set(row.stationCode, row) + } + } + const mostRecentActuals = Array.from(mostRecentActualMap.values()) + rowsForSimulation = [...mostRecentActuals, ...forecasts] + } + + return rowsForSimulation +} + +export const filterRowsForSimulationFromEdited = ( + editedRow: MoreCast2Row, + allRows: MoreCast2Row[] +): MoreCast2Row[] | undefined => { + if (validForecastPredicate(editedRow)) { + const validRowsForStation = allRows.filter( + row => row.stationCode === editedRow.stationCode && validActualOrForecastPredicate(row) + ) + + const yesterday = editedRow.forDate.minus({ days: 1 }) + const yesterdayRow = validRowsForStation.find(row => row.forDate.toISODate() === yesterday.toISODate()) + + if (yesterdayRow) { + const rowsForSimulation = validRowsForStation.filter(row => row.forDate >= yesterday) + return rowsForSimulation + } + } + return undefined +} diff --git a/web/src/features/moreCast2/saveForecast.test.ts b/web/src/features/moreCast2/saveForecast.test.ts index 9d702740e..c11b66b1b 100644 --- a/web/src/features/moreCast2/saveForecast.test.ts +++ b/web/src/features/moreCast2/saveForecast.test.ts @@ -1,6 +1,7 @@ import { ModelChoice } from 'api/moreCast2API' import { MoreCast2Row } from 'features/moreCast2/interfaces' -import { getRowsToSave, isForecastValid, validForecastPredicate } from 'features/moreCast2/saveForecasts' +import { getRowsToSave, isForecastValid } from 'features/moreCast2/saveForecasts' +import { validForecastPredicate } from 'features/moreCast2/util' import { DateTime } from 'luxon' const baseRow = { @@ -59,13 +60,13 @@ const baseRow = { windSpeedNAM_BIAS: 0, windSpeedRDPS: 0, windSpeedRDPS_BIAS: 0, - ffmc: 0, - dmc: 0, - dc: 0, - isi: 0, - bui: 0, - fwi: 0, - dgr: 0 + ffmcCalcActual: 0, + dmcCalcActual: 0, + dcCalcActual: 0, + isiCalcActual: 0, + buiCalcActual: 0, + fwiCalcActual: 0, + dgrCalcActual: 0 } const baseRowWithActuals = { @@ -83,12 +84,16 @@ const buildCompleteForecast = ( id: string, forDate: DateTime, stationCode: number, - stationName: string + stationName: string, + latitude: number, + longitude: number ): MoreCast2Row => ({ id, forDate, stationCode, stationName, + latitude, + longitude, ...baseRow, precipForecast: { choice: ModelChoice.GDPS, value: 0 }, rhForecast: { choice: ModelChoice.GDPS, value: 0 }, @@ -101,12 +106,16 @@ const buildForecastMissingWindDirection = ( id: string, forDate: DateTime, stationCode: number, - stationName: string + stationName: string, + latitude: number, + longitude: number ): MoreCast2Row => ({ id, forDate, stationCode, stationName, + latitude, + longitude, ...baseRow, precipForecast: { choice: ModelChoice.GDPS, value: 0 }, rhForecast: { choice: ModelChoice.GDPS, value: 0 }, @@ -119,20 +128,33 @@ const buildInvalidForecast = ( id: string, forDate: DateTime, stationCode: number, - stationName: string + stationName: string, + latitude: number, + longitude: number ): MoreCast2Row => ({ id, forDate, stationCode, stationName, + latitude, + longitude, ...baseRow }) -const buildNAForecast = (id: string, forDate: DateTime, stationCode: number, stationName: string): MoreCast2Row => ({ +const buildNAForecast = ( + id: string, + forDate: DateTime, + stationCode: number, + stationName: string, + latitude: number, + longitude: number +): MoreCast2Row => ({ id, forDate, stationCode, stationName, + latitude, + longitude, ...baseRow, precipForecast: { choice: ModelChoice.NULL, value: NaN }, rhForecast: { choice: ModelChoice.NULL, value: NaN }, @@ -145,12 +167,16 @@ const buildForecastWithActuals = ( id: string, forDate: DateTime, stationCode: number, - stationName: string + stationName: string, + latitude: number, + longitude: number ): MoreCast2Row => ({ id, forDate, stationCode, stationName, + latitude, + longitude, ...baseRowWithActuals, precipForecast: { choice: ModelChoice.GDPS, value: 0 }, rhForecast: { choice: ModelChoice.GDPS, value: 0 }, @@ -164,16 +190,16 @@ describe('saveForecasts', () => { it('should return true if all forecasts fields are set', () => { expect( isForecastValid([ - buildCompleteForecast('1', mockForDate, 1, 'one'), - buildCompleteForecast('2', mockForDate, 2, 'two') + buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), + buildCompleteForecast('2', mockForDate, 2, 'two', 2, 2) ]) ).toBe(true) }) it('should return true if all forecasts fields are set except windDirectionForecast', () => { expect( isForecastValid([ - buildForecastMissingWindDirection('1', mockForDate, 1, 'one'), - buildForecastMissingWindDirection('2', mockForDate, 2, 'two') + buildForecastMissingWindDirection('1', mockForDate, 1, 'one', 1, 1), + buildForecastMissingWindDirection('2', mockForDate, 2, 'two', 2, 2) ]) ).toBe(true) }) @@ -181,47 +207,47 @@ describe('saveForecasts', () => { it('should return false if any forecasts have missing forecast fields', () => { expect( isForecastValid([ - buildCompleteForecast('1', mockForDate, 1, 'one'), - buildInvalidForecast('2', mockForDate, 2, 'two') + buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), + buildInvalidForecast('2', mockForDate, 2, 'two', 2, 2) ]) ).toBe(false) }) it('should return false if any forecasts have missing forecast fields set other than windDirectionForecast', () => { - expect(isForecastValid([buildNAForecast('1', mockForDate, 2, 'one')])).toBe(false) + expect(isForecastValid([buildNAForecast('1', mockForDate, 2, 'one', 1, 1)])).toBe(false) }) }) describe('validForecastPredicate', () => { it('should return false for a forecast with missing forecast fields', () => { - expect(validForecastPredicate(buildInvalidForecast('1', mockForDate, 1, 'one'))).toBe(false) + expect(validForecastPredicate(buildInvalidForecast('1', mockForDate, 1, 'one', 1, 1))).toBe(false) }) it('should return false for a forecast with forecasts but N/A values', () => { - expect(validForecastPredicate(buildNAForecast('1', mockForDate, 1, 'one'))).toBe(false) + expect(validForecastPredicate(buildNAForecast('1', mockForDate, 1, 'one', 1, 1))).toBe(false) }) }) describe('getRowsToSave', () => { it('should filter out invalid forecasts', () => { const res = getRowsToSave([ - buildCompleteForecast('1', mockForDate, 1, 'one'), - buildInvalidForecast('2', mockForDate, 2, 'two') + buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), + buildInvalidForecast('2', mockForDate, 2, 'two', 2, 2) ]) expect(res).toHaveLength(1) expect(res[0].id).toBe('1') }) it('should filter out N/A forecasts', () => { const res = getRowsToSave([ - buildCompleteForecast('1', mockForDate, 1, 'one'), - buildNAForecast('2', mockForDate, 2, 'two') + buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), + buildNAForecast('2', mockForDate, 2, 'two', 2, 2) ]) expect(res).toHaveLength(1) expect(res[0].id).toBe('1') }) it('should filter out rows with actuals', () => { - const forecastWithActual = buildCompleteForecast('2', mockForDate, 2, 'two') + const forecastWithActual = buildCompleteForecast('2', mockForDate, 2, 'two', 2, 2) forecastWithActual.precipActual = 1 const res = getRowsToSave([ - buildCompleteForecast('1', mockForDate, 1, 'one'), - buildForecastWithActuals('2', mockForDate, 2, 'two') + buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), + buildForecastWithActuals('2', mockForDate, 2, 'two', 2, 2) ]) expect(res).toHaveLength(1) expect(res[0].id).toBe('1') diff --git a/web/src/features/moreCast2/saveForecasts.ts b/web/src/features/moreCast2/saveForecasts.ts index d13fe7430..475822c9a 100644 --- a/web/src/features/moreCast2/saveForecasts.ts +++ b/web/src/features/moreCast2/saveForecasts.ts @@ -1,6 +1,6 @@ import { ModelChoice } from 'api/moreCast2API' import { MoreCast2ForecastRow, MoreCast2Row } from 'features/moreCast2/interfaces' -import { isUndefined } from 'lodash' +import { validForecastPredicate } from 'features/moreCast2/util' // Forecast rows contain all NaN values in their 'actual' fields export const isForecastRowPredicate = (row: MoreCast2Row) => @@ -10,17 +10,6 @@ export const isForecastRowPredicate = (row: MoreCast2Row) => isNaN(row.windDirectionActual) && isNaN(row.windSpeedActual) -// A valid forecast row has values for precipForecast, rhForecast, tempForecast and windSpeedForecast -export const validForecastPredicate = (row: MoreCast2Row) => - !isUndefined(row.precipForecast) && - !isNaN(row.precipForecast.value) && - !isUndefined(row.rhForecast) && - !isNaN(row.rhForecast.value) && - !isUndefined(row.tempForecast) && - !isNaN(row.tempForecast.value) && - !isUndefined(row.windSpeedForecast) && - !isNaN(row.windSpeedForecast.value) - export const getForecastRows = (rows: MoreCast2Row[]): MoreCast2Row[] => { return rows ? rows.filter(isForecastRowPredicate) : [] } diff --git a/web/src/features/moreCast2/slices/dataSlice.test.ts b/web/src/features/moreCast2/slices/dataSlice.test.ts index fe7253316..b4f79a875 100644 --- a/web/src/features/moreCast2/slices/dataSlice.test.ts +++ b/web/src/features/moreCast2/slices/dataSlice.test.ts @@ -11,14 +11,20 @@ import dataSliceReducer, { initialState, getWeatherIndeterminatesFailed, getWeatherIndeterminatesStart, - getWeatherIndeterminatesSuccess + getWeatherIndeterminatesSuccess, + simulateWeatherIndeterminatesSuccess, + simulateWeatherIndeterminatesFailed, + storeUserEditedRows } from 'features/moreCast2/slices/dataSlice' +import { rowIDHasher } from 'features/moreCast2/util' import { DateTime } from 'luxon' const FROM_DATE_STRING = '2023-04-27T20:00:00+00:00' const TO_DATE_STRING = '2023-04-28T20:00:00+00:00' const FROM_DATE_TIME = DateTime.fromISO(FROM_DATE_STRING) const TO_DATE_TIME = DateTime.fromISO(TO_DATE_STRING) +const LAT = 1.1 +const LONG = 2.2 const PRECIP = 1 const RH = 75 const TEMP = 10 @@ -47,11 +53,13 @@ const weatherIndeterminateGenerator = ( precipValue?: number ) => { return { - id: `${station_code}${utc_timestamp}`, + id: rowIDHasher(station_code, DateTime.fromISO(utc_timestamp)), station_code, station_name, determinate, utc_timestamp, + latitude: LAT, + longitude: LONG, precipitation: precipValue ?? PRECIP, relative_humidity: RH, temperature: TEMP, @@ -104,6 +112,8 @@ describe('dataSlice', () => { station_name: 'station', determinate: WeatherDeterminate.ACTUAL, utc_timestamp: '2023-04-21', + latitude: 1.1, + longitude: 2.2, precipitation: 0.5, relative_humidity: 55, temperature: 12, @@ -126,6 +136,8 @@ describe('dataSlice', () => { station_name: 'prediction station', determinate: WeatherDeterminate.GDPS, utc_timestamp: '2023-04-22', + latitude: 1.1, + longitude: 2.2, precipitation: 1.5, relative_humidity: 75, temperature: 5, @@ -155,6 +167,83 @@ describe('dataSlice', () => { it('should set a value for error state when getWeatherIndeterminatesFailed is called', () => { expect(dataSliceReducer(initialState, getWeatherIndeterminatesFailed(dummyError)).error).not.toBeNull() }) + + it('should handle missing forcasts for calculated indices update', () => { + expect( + dataSliceReducer( + initialState, + simulateWeatherIndeterminatesSuccess({ + simulated_forecasts: [] + }) + ).forecasts + ).toEqual([]) + }) + + it('should only overwrite updated forecasts', () => { + const weatherIndeterminate1 = weatherIndeterminateGenerator( + 1, + 'test1', + WeatherDeterminate.FORECAST, + FROM_DATE_STRING + ) + + const weatherIndeterminate2 = weatherIndeterminateGenerator( + 2, + 'test', + WeatherDeterminate.FORECAST, + TO_DATE_STRING + ) + + const updatedWeatherIndeterminate2 = { + ...weatherIndeterminate2, + fine_fuel_moisture_code: 1, + duff_moisture_code: 1, + drought_code: 1, + initial_spread_index: 1, + build_up_index: 1, + fire_weather_index: 1, + danger_rating: 1 + } + + expect( + dataSliceReducer( + { ...initialState, forecasts: [weatherIndeterminate1, weatherIndeterminate2] }, + simulateWeatherIndeterminatesSuccess({ + simulated_forecasts: [updatedWeatherIndeterminate2] + }) + ).forecasts + ).toEqual([weatherIndeterminate1, updatedWeatherIndeterminate2]) + }) + it('should set a value for error state when simulateWeatherIndeterminatesFailed is called', () => { + expect(dataSliceReducer(initialState, simulateWeatherIndeterminatesFailed(dummyError)).error).not.toBeNull() + }) + it('should store the edited rows', () => { + const rows = createMoreCast2Rows( + [], + [weatherIndeterminateGenerator(1, 'test1', WeatherDeterminate.FORECAST, FROM_DATE_STRING)], + [] + ) + expect(dataSliceReducer(initialState, storeUserEditedRows(rows)).userEditedRows).toEqual(rows) + }) + + it('should updated the edited rows', () => { + const forecast = weatherIndeterminateGenerator(1, 'test1', WeatherDeterminate.FORECAST, FROM_DATE_STRING) + const rows = createMoreCast2Rows([], [forecast], []) + expect(dataSliceReducer(initialState, storeUserEditedRows(rows)).userEditedRows).toEqual(rows) + + const updatedForecast = { + ...forecast, + fine_fuel_moisture_code: 1, + duff_moisture_code: 1, + drought_code: 1, + initial_spread_index: 1, + build_up_index: 1, + fire_weather_index: 1, + danger_rating: 1 + } + const updatedRows = createMoreCast2Rows([], [updatedForecast], []) + expect(dataSliceReducer(initialState, storeUserEditedRows(updatedRows)).userEditedRows).toEqual(updatedRows) + }) }) describe('fillMissingWeatherIndeterminates', () => { const fromDate = DateTime.fromISO('2023-04-27T20:00:00+00:00') diff --git a/web/src/features/moreCast2/slices/dataSlice.ts b/web/src/features/moreCast2/slices/dataSlice.ts index 87dd35472..079eef5b6 100644 --- a/web/src/features/moreCast2/slices/dataSlice.ts +++ b/web/src/features/moreCast2/slices/dataSlice.ts @@ -7,7 +7,9 @@ import { WeatherIndeterminatePayload, WeatherDeterminate, WeatherDeterminateChoices, - WeatherDeterminateType + WeatherDeterminateType, + UpdatedWeatherIndeterminateResponse, + fetchCalculatedIndices } from 'api/moreCast2API' import { AppThunk } from 'app/store' import { createDateInterval, rowIDHasher } from 'features/moreCast2/util' @@ -23,6 +25,7 @@ interface State { actuals: WeatherIndeterminate[] forecasts: WeatherIndeterminate[] predictions: WeatherIndeterminate[] + userEditedRows: MoreCast2Row[] } export const initialState: State = { @@ -30,7 +33,8 @@ export const initialState: State = { error: null, actuals: [], forecasts: [], - predictions: [] + predictions: [], + userEditedRows: [] } const dataSlice = createSlice({ @@ -42,6 +46,7 @@ const dataSlice = createSlice({ state.actuals = [] state.forecasts = [] state.predictions = [] + state.userEditedRows = [] state.loading = true }, getWeatherIndeterminatesFailed(state: State, action: PayloadAction) { @@ -54,12 +59,42 @@ const dataSlice = createSlice({ state.forecasts = action.payload.forecasts state.predictions = action.payload.predictions state.loading = false + }, + simulateWeatherIndeterminatesSuccess(state: State, action: PayloadAction) { + const updatedForecasts = addUniqueIds(action.payload.simulated_forecasts) + + state.forecasts = state.forecasts.map(forecast => { + const updatedForecast = updatedForecasts.find(item => item.id === forecast.id) + return updatedForecast ?? forecast + }) + }, + simulateWeatherIndeterminatesFailed(state: State, action: PayloadAction) { + state.error = action.payload + }, + storeUserEditedRows(state: State, action: PayloadAction) { + const storedRows = [...state.userEditedRows] + + for (const row of action.payload) { + const existingIndex = storedRows.findIndex(storedRow => storedRow.id === row.id) + if (existingIndex !== -1) { + storedRows[existingIndex] = row + } else { + storedRows.push(row) + } + } + state.userEditedRows = storedRows } } }) -export const { getWeatherIndeterminatesStart, getWeatherIndeterminatesFailed, getWeatherIndeterminatesSuccess } = - dataSlice.actions +export const { + getWeatherIndeterminatesStart, + getWeatherIndeterminatesFailed, + getWeatherIndeterminatesSuccess, + simulateWeatherIndeterminatesSuccess, + simulateWeatherIndeterminatesFailed, + storeUserEditedRows +} = dataSlice.actions export default dataSlice.reducer @@ -69,7 +104,7 @@ export default dataSlice.reducer * @param stations The list of stations to retreive data for. * @param fromDate The start date from which to retrieve data from (inclusive). * @param toDate The end date from which to retrieve data from (inclusive). - * @returns An array or WeatherIndeterminates. + * @returns An array of WeatherIndeterminates. */ export const getWeatherIndeterminates = (stations: StationGroupMember[], fromDate: DateTime, toDate: DateTime): AppThunk => @@ -111,6 +146,25 @@ export const getWeatherIndeterminates = } } +/** + * Use the morecast2API to get simulated Fire Weather Index value from the backend. + * Results are stored in the Redux store. + * @param rowsForSimulation List of MoreCast2Row's to simulate. The first row in the array must contain + * valid values for all Fire Weather Indices. + * @returns Array of MoreCast2Rows + */ +export const getSimulatedIndices = + (rowsForSimulation: MoreCast2Row[]): AppThunk => + async dispatch => { + try { + const simulatedForecasts = await fetchCalculatedIndices(rowsForSimulation) + dispatch(simulateWeatherIndeterminatesSuccess(simulatedForecasts)) + } catch (err) { + dispatch(simulateWeatherIndeterminatesFailed((err as Error).toString())) + logError(err) + } + } + export const createMoreCast2Rows = ( actuals: WeatherIndeterminate[], forecasts: WeatherIndeterminate[], @@ -131,7 +185,9 @@ export const createMoreCast2Rows = ( firstItem.id, firstItem.station_code, firstItem.station_name, - DateTime.fromISO(firstItem.utc_timestamp) + DateTime.fromISO(firstItem.utc_timestamp), + firstItem.latitude, + firstItem.longitude ) for (const value of values) { @@ -142,13 +198,13 @@ export const createMoreCast2Rows = ( row.tempActual = getNumberOrNaN(value.temperature) row.windDirectionActual = getNumberOrNaN(value.wind_direction) row.windSpeedActual = getNumberOrNaN(value.wind_speed) - row.ffmc = getNumberOrNaN(value.fine_fuel_moisture_code) - row.dmc = getNumberOrNaN(value.duff_moisture_code) - row.dc = getNumberOrNaN(value.drought_code) - row.isi = getNumberOrNaN(value.initial_spread_index) - row.bui = getNumberOrNaN(value.build_up_index) - row.fwi = getNumberOrNaN(value.fire_weather_index) - row.dgr = getNumberOrNaN(value.danger_rating) + row.ffmcCalcActual = getNumberOrNaN(value.fine_fuel_moisture_code) + row.dmcCalcActual = getNumberOrNaN(value.duff_moisture_code) + row.dcCalcActual = getNumberOrNaN(value.drought_code) + row.isiCalcActual = getNumberOrNaN(value.initial_spread_index) + row.buiCalcActual = getNumberOrNaN(value.build_up_index) + row.fwiCalcActual = getNumberOrNaN(value.fire_weather_index) + row.dgrCalcActual = getNumberOrNaN(value.danger_rating) break case WeatherDeterminate.FORECAST: case WeatherDeterminate.NULL: @@ -172,6 +228,30 @@ export const createMoreCast2Rows = ( choice: forecastOrNull(value.determinate), value: getNumberOrNaN(value.wind_speed) } + row.ffmcCalcForecast = { + choice: forecastOrNull(ModelChoice.NULL), + value: getNumberOrNaN(value.fine_fuel_moisture_code) + } + row.dmcCalcForecast = { + choice: forecastOrNull(ModelChoice.NULL), + value: getNumberOrNaN(value.duff_moisture_code) + } + row.dcCalcForecast = { + choice: forecastOrNull(ModelChoice.NULL), + value: getNumberOrNaN(value.drought_code) + } + row.isiCalcForecast = { + choice: forecastOrNull(ModelChoice.NULL), + value: getNumberOrNaN(value.initial_spread_index) + } + row.buiCalcForecast = { + choice: forecastOrNull(ModelChoice.NULL), + value: getNumberOrNaN(value.build_up_index) + } + row.fwiCalcForecast = { + choice: forecastOrNull(ModelChoice.NULL), + value: getNumberOrNaN(value.fire_weather_index) + } break case WeatherDeterminate.GDPS: row.precipGDPS = getNumberOrNaN(value.precipitation) @@ -276,7 +356,7 @@ const forecastOrNull = (determinate: WeatherDeterminateType): ModelChoice.FORECA * @returns Returns an array of WeatherIndeterminates where each item has an ID derived * from its station_code and utc_timestamp. */ -const addUniqueIds = (items: WeatherIndeterminate[]) => { +export const addUniqueIds = (items: WeatherIndeterminate[]) => { return items.map(item => ({ ...item, id: rowIDHasher(item.station_code, DateTime.fromISO(item.utc_timestamp)) @@ -312,11 +392,20 @@ export const fillMissingWeatherIndeterminates = ( for (const [key, values] of Object.entries(groupedByStationCode)) { const stationCode = parseInt(key) const stationName = stationMap.get(stationCode) ?? '' + const latitude = values[0]?.latitude + const longitude = values[0]?.longitude // We expect one actual per date in our date interval if (values.length < dateInterval.length) { for (const date of dateInterval) { if (!values.some(value => isEqual(DateTime.fromISO(value.utc_timestamp), DateTime.fromISO(date)))) { - const missing = createEmptyWeatherIndeterminate(stationCode, stationName, date, determinate) + const missing = createEmptyWeatherIndeterminate( + stationCode, + stationName, + date, + determinate, + latitude, + longitude + ) weatherIndeterminates.push(missing) } } @@ -355,6 +444,8 @@ export const fillMissingPredictions = ( for (const [stationCodeAsString, weatherIndeterminatesByStationCode] of Object.entries(groupedByStationCode)) { const stationCode = parseInt(stationCodeAsString) const stationName = stationMap.get(stationCode) ?? '' + const latitude = weatherIndeterminatesByStationCode[0]?.latitude ?? 0 + const longitude = weatherIndeterminatesByStationCode[0]?.longitude ?? 0 const groupedByUtcTimestamp = createUtcTimeStampToWeatherIndeterminateGroups( weatherIndeterminatesByStationCode, dateInterval @@ -368,7 +459,9 @@ export const fillMissingPredictions = ( stationCode, stationName, utcTimestamp, - determinate + determinate, + latitude, + longitude ) allPredictions.push(missingDeterminate) } @@ -420,6 +513,11 @@ export const selectAllMoreCast2Rows = createSelector([selectWeatherIndeterminate return sortedRows }) +export const selectUserEditedRows = createSelector([selectWeatherIndeterminates], weatherIndeterminates => { + const rows = weatherIndeterminates.userEditedRows + return rows +}) + export const selectForecastMoreCast2Rows = createSelector([selectAllMoreCast2Rows], allMorecast2Rows => allMorecast2Rows?.map(row => ({ id: row.id, @@ -485,17 +583,21 @@ const getNumberOrNaN = (value: number | null) => { * @param forDate The date the row is for. * @returns */ -const createEmptyMoreCast2Row = ( +export const createEmptyMoreCast2Row = ( id: string, stationCode: number, stationName: string, - forDate: DateTime + forDate: DateTime, + latitude: number, + longitude: number ): MoreCast2Row => { return { id, stationCode, stationName, forDate, + latitude, + longitude, precipActual: NaN, rhActual: NaN, tempActual: NaN, @@ -503,13 +605,13 @@ const createEmptyMoreCast2Row = ( windSpeedActual: NaN, // Indices - ffmc: NaN, - dmc: NaN, - dc: NaN, - isi: NaN, - bui: NaN, - fwi: NaN, - dgr: NaN, + ffmcCalcActual: NaN, + dmcCalcActual: NaN, + dcCalcActual: NaN, + isiCalcActual: NaN, + buiCalcActual: NaN, + fwiCalcActual: NaN, + dgrCalcActual: NaN, // GDPS model predictions precipGDPS: NaN, @@ -595,12 +697,16 @@ const createEmptyWeatherIndeterminate = ( station_code: number, station_name: string, utc_timestamp: string, - determinate: WeatherDeterminateType + determinate: WeatherDeterminateType, + latitude: number, + longitude: number ): WeatherIndeterminate => { return { id: '', station_code, station_name, + latitude, + longitude, determinate, utc_timestamp, precipitation: null, diff --git a/web/src/features/moreCast2/util.test.ts b/web/src/features/moreCast2/util.test.ts index 72cc74ce2..738d1425f 100644 --- a/web/src/features/moreCast2/util.test.ts +++ b/web/src/features/moreCast2/util.test.ts @@ -1,10 +1,21 @@ import { DateTime } from 'luxon' import { ModelChoice } from 'api/moreCast2API' -import { createDateInterval, createWeatherModelLabel, parseForecastsHelper, rowIDHasher } from 'features/moreCast2/util' +import { createEmptyMoreCast2Row } from 'features/moreCast2/slices/dataSlice' +import { + createDateInterval, + createWeatherModelLabel, + mapForecastChoiceLabels, + parseForecastsHelper, + rowIDHasher, + validActualPredicate, + validForecastPredicate +} from 'features/moreCast2/util' +import { buildValidForecastRow } from 'features/moreCast2/rowFilters.test' const TEST_DATE = '2023-02-16T20:00:00+00:00' const TEST_DATE2 = '2023-02-17T20:00:00+00:00' const TEST_CODE = 209 +const TEST_DATETIME = DateTime.fromISO(TEST_DATE) describe('createDateInterval', () => { it('should return array with single date when fromDate and toDate are the same', () => { @@ -152,3 +163,54 @@ describe('createWeatherModelLabel', () => { expect(result).toBe('GDPS bias') }) }) +describe('validActualPredicate', () => { + const row = createEmptyMoreCast2Row('id', 123, 'testStation', DateTime.fromISO('2023-05-25T09:08:34.123'), 56, -123) + it('should return true if a row contains valid Actual values', () => { + row.precipActual = 1 + row.tempActual = 1 + row.rhActual = 1 + row.windSpeedActual = 1 + const result = validActualPredicate(row) + expect(result).toBe(true) + }) + it('should return false if a row does not contain valid Actual values', () => { + row.precipActual = NaN + const result = validActualPredicate(row) + expect(result).toBe(false) + }) +}) +describe('validForecastPredicate', () => { + const row = createEmptyMoreCast2Row('id', 123, 'testStation', DateTime.fromISO('2023-05-25T09:08:34.123'), 56, -123) + it('should return true if a row contains valid Forecast values', () => { + row.precipForecast = { choice: 'FORECAST', value: 2 } + row.tempForecast = { choice: 'FORECAST', value: 2 } + row.rhForecast = { choice: 'FORECAST', value: 2 } + row.windSpeedForecast = { choice: 'FORECAST', value: 2 } + const result = validForecastPredicate(row) + expect(result).toBe(true) + }) + it('should return false if a row does not contain valid Forecast values', () => { + row.precipForecast = undefined + const result = validForecastPredicate(row) + expect(result).toBe(false) + }) +}) +describe('mapForecastChoiceLabels', () => { + const forecast1A = buildValidForecastRow(123, TEST_DATETIME, 'FORECAST') + const forecast1B = buildValidForecastRow(123, TEST_DATETIME.plus({ days: 1 }), 'FORECAST') + const newRows = [forecast1A, forecast1B] + + const forecast2A = buildValidForecastRow(123, TEST_DATETIME, 'GDPS') + const forecast2B = buildValidForecastRow(123, TEST_DATETIME.plus({ days: 1 }), 'MANUAL') + forecast2A.tempForecast!.choice = 'HRDPS' + forecast2B.precipForecast!.choice = 'GFS' + const storedRows = [forecast2A, forecast2B] + + it('should map the correct label to the correct row', () => { + const labelledRows = mapForecastChoiceLabels(newRows, storedRows) + expect(labelledRows[0].tempForecast!.choice).toBe('HRDPS') + expect(labelledRows[0].precipForecast!.choice).toBe('GDPS') + expect(labelledRows[1].precipForecast!.choice).toBe('GFS') + expect(labelledRows[1].rhForecast!.choice).toBe('MANUAL') + }) +}) diff --git a/web/src/features/moreCast2/util.ts b/web/src/features/moreCast2/util.ts index 77e63882a..8b9109e65 100644 --- a/web/src/features/moreCast2/util.ts +++ b/web/src/features/moreCast2/util.ts @@ -1,7 +1,8 @@ import { DateTime, Interval } from 'luxon' import { ModelChoice, MoreCast2ForecastRecord } from 'api/moreCast2API' -import { MoreCast2ForecastRow } from 'features/moreCast2/interfaces' +import { MoreCast2ForecastRow, MoreCast2Row } from 'features/moreCast2/interfaces' import { StationGroupMember } from 'api/stationAPI' +import { isUndefined } from 'lodash' export const parseForecastsHelper = ( forecasts: MoreCast2ForecastRecord[], @@ -79,3 +80,40 @@ export const createLabel = (isActual: boolean, label: string) => { return createWeatherModelLabel(label) } + +export const validActualOrForecastPredicate = (row: MoreCast2Row) => + validForecastPredicate(row) || validActualPredicate(row) + +export const validActualPredicate = (row: MoreCast2Row) => + !isNaN(row.precipActual) && !isNaN(row.rhActual) && !isNaN(row.tempActual) && !isNaN(row.windSpeedActual) + +// A valid forecast row has values for precipForecast, rhForecast, tempForecast and windSpeedForecast +export const validForecastPredicate = (row: MoreCast2Row) => + !isUndefined(row.precipForecast) && + !isNaN(row.precipForecast.value) && + !isUndefined(row.rhForecast) && + !isNaN(row.rhForecast.value) && + !isUndefined(row.tempForecast) && + !isNaN(row.tempForecast.value) && + !isUndefined(row.windSpeedForecast) && + !isNaN(row.windSpeedForecast.value) + +export const mapForecastChoiceLabels = (newRows: MoreCast2Row[], storedRows: MoreCast2Row[]): MoreCast2Row[] => { + const storedRowChoicesMap = new Map() + + for (const row of storedRows) { + storedRowChoicesMap.set(row.id, row) + } + + for (const row of newRows) { + const matchingRow = storedRowChoicesMap.get(row.id) + if (matchingRow) { + row.precipForecast = matchingRow.precipForecast + row.tempForecast = matchingRow.tempForecast + row.rhForecast = matchingRow.rhForecast + row.windDirectionForecast = matchingRow.windDirectionForecast + row.windSpeedForecast = matchingRow.windSpeedForecast + } + } + return newRows +}