From 9fe13ff007e5e2080e7da20684eafb5611fdba86 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 18 Jun 2026 10:47:09 -0700 Subject: [PATCH 1/2] init --- backend/packages/wps-api/src/app/tests/conftest.py | 3 +++ .../packages/wps-sfms/src/wps_sfms/tests/test_field.py | 8 ++++++++ .../src/wps_sfms/tests/test_rh_interpolation.py | 3 +++ .../wps_sfms/tests/test_temperature_interpolation.py | 3 +++ .../src/wps_sfms/tests/test_wind_interpolation.py | 6 ++++++ .../packages/wps-shared/src/wps_shared/schemas/sfms.py | 1 + backend/packages/wps-wf1/src/wps_wf1/parsers.py | 10 ++++++++++ .../packages/wps-wf1/src/wps_wf1/tests/test_parsers.py | 4 ++++ .../wps-wf1/src/wps_wf1/tests/test_wfwx_api.py | 5 +++++ 9 files changed, 43 insertions(+) diff --git a/backend/packages/wps-api/src/app/tests/conftest.py b/backend/packages/wps-api/src/app/tests/conftest.py index 34ac8da07..10b37ec05 100644 --- a/backend/packages/wps-api/src/app/tests/conftest.py +++ b/backend/packages/wps-api/src/app/tests/conftest.py @@ -1,6 +1,7 @@ import os from datetime import datetime, timezone +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum from wps_shared.schemas.sfms import SFMSDaily from wps_shared.tests.conftest import ( anyio_backend, @@ -30,6 +31,7 @@ def create_mock_sfms_actuals(): SFMSDaily( code=100, for_datetime=SFMS_DAILY_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.0, lon=-123.0, elevation=100.0, @@ -45,6 +47,7 @@ def create_mock_sfms_actuals(): SFMSDaily( code=101, for_datetime=SFMS_DAILY_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.5, lon=-123.5, elevation=200.0, diff --git a/backend/packages/wps-sfms/src/wps_sfms/tests/test_field.py b/backend/packages/wps-sfms/src/wps_sfms/tests/test_field.py index 3ce7774b3..0df3167be 100644 --- a/backend/packages/wps-sfms/src/wps_sfms/tests/test_field.py +++ b/backend/packages/wps-sfms/src/wps_sfms/tests/test_field.py @@ -2,6 +2,7 @@ import numpy as np import pytest +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum from wps_shared.schemas.sfms import SFMSDaily from wps_sfms.interpolation.field import ( @@ -20,6 +21,7 @@ def test_build_attribute_field_filters_missing_values(self): SFMSDaily( code=1, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.0, lon=-123.0, precipitation=1.5, @@ -27,6 +29,7 @@ def test_build_attribute_field_filters_missing_values(self): SFMSDaily( code=2, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.1, lon=-123.1, precipitation=None, @@ -44,6 +47,7 @@ def test_build_temperature_field_applies_sea_level_adjustment(self): SFMSDaily( code=1, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.0, lon=-123.0, elevation=100.0, @@ -60,6 +64,7 @@ def test_build_dewpoint_field_skips_missing_elevation_or_value(self): SFMSDaily( code=1, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.0, lon=-123.0, elevation=None, @@ -68,6 +73,7 @@ def test_build_dewpoint_field_skips_missing_elevation_or_value(self): SFMSDaily( code=2, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.1, lon=-123.1, elevation=100.0, @@ -88,6 +94,7 @@ def test_build_wind_vector_field_filters_unpaired_values(self): SFMSDaily( code=1, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.0, lon=-123.0, wind_speed=10.0, @@ -96,6 +103,7 @@ def test_build_wind_vector_field_filters_unpaired_values(self): SFMSDaily( code=2, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.1, lon=-123.1, wind_speed=8.0, diff --git a/backend/packages/wps-sfms/src/wps_sfms/tests/test_rh_interpolation.py b/backend/packages/wps-sfms/src/wps_sfms/tests/test_rh_interpolation.py index 19405c2cf..c70fb70a3 100644 --- a/backend/packages/wps-sfms/src/wps_sfms/tests/test_rh_interpolation.py +++ b/backend/packages/wps-sfms/src/wps_sfms/tests/test_rh_interpolation.py @@ -8,6 +8,7 @@ import uuid import pytest from osgeo import gdal +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum from wps_shared.schemas.sfms import SFMSDaily from wps_sfms.interpolation.field import build_dewpoint_field, compute_rh from wps_sfms.processors.relative_humidity import RHInterpolator @@ -23,6 +24,7 @@ def create_test_actuals(lats, lons, dewpoints, elevations): actual = SFMSDaily( code=100 + i, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=lat, lon=lon, elevation=elev, @@ -270,6 +272,7 @@ def test_interpolate_raises_when_no_valid_stations(self): SFMSDaily( code=1, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.05, lon=-123.05, elevation=100.0, diff --git a/backend/packages/wps-sfms/src/wps_sfms/tests/test_temperature_interpolation.py b/backend/packages/wps-sfms/src/wps_sfms/tests/test_temperature_interpolation.py index 508ec5ec3..6071ebc54 100644 --- a/backend/packages/wps-sfms/src/wps_sfms/tests/test_temperature_interpolation.py +++ b/backend/packages/wps-sfms/src/wps_sfms/tests/test_temperature_interpolation.py @@ -8,6 +8,7 @@ import uuid import pytest from osgeo import gdal +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum from wps_shared.schemas.sfms import SFMSDaily from wps_sfms.interpolation.field import build_temperature_field from wps_sfms.processors.temperature import TemperatureInterpolator @@ -23,6 +24,7 @@ def create_test_actuals(lats, lons, temps, elevations): actual = SFMSDaily( code=100 + i, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=lat, lon=lon, elevation=elev, @@ -206,6 +208,7 @@ def test_interpolate_raises_when_no_valid_stations(self): SFMSDaily( code=1, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.05, lon=-123.05, elevation=100.0, diff --git a/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py b/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py index 1c7b05b3e..cca7d79c7 100644 --- a/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py +++ b/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py @@ -6,6 +6,7 @@ from osgeo import gdal import wps_sfms.processors.wind as wind_module +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum from wps_shared.schemas.sfms import SFMSDaily from wps_sfms.interpolation.field import build_wind_vector_field from wps_sfms.processors.idw import ValidPixelIDWResult @@ -64,6 +65,7 @@ def test_interpolate_basic_success(self): SFMSDaily( code=100, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.05, lon=-123.05, wind_speed=10.0, @@ -72,6 +74,7 @@ def test_interpolate_basic_success(self): SFMSDaily( code=101, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.08, lon=-123.02, wind_speed=8.0, @@ -108,6 +111,7 @@ def test_interpolate_raises_without_paired_stations(self): SFMSDaily( code=100, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.05, lon=-123.05, wind_speed=10.0, @@ -139,6 +143,7 @@ def test_interpolate_raises_when_only_one_component_succeeds( SFMSDaily( code=100, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.05, lon=-123.05, wind_speed=10.0, @@ -147,6 +152,7 @@ def test_interpolate_raises_when_only_one_component_succeeds( SFMSDaily( code=101, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.08, lon=-123.02, wind_speed=8.0, diff --git a/backend/packages/wps-shared/src/wps_shared/schemas/sfms.py b/backend/packages/wps-shared/src/wps_shared/schemas/sfms.py index c1f0dad6e..74f92d958 100644 --- a/backend/packages/wps-shared/src/wps_shared/schemas/sfms.py +++ b/backend/packages/wps-shared/src/wps_shared/schemas/sfms.py @@ -25,6 +25,7 @@ class SFMSDaily(BaseModel): code: int for_datetime: datetime + run_type: RunTypeEnum lat: float lon: float elevation: Optional[float] = None diff --git a/backend/packages/wps-wf1/src/wps_wf1/parsers.py b/backend/packages/wps-wf1/src/wps_wf1/parsers.py index aecd16a4c..623bcc3c8 100644 --- a/backend/packages/wps-wf1/src/wps_wf1/parsers.py +++ b/backend/packages/wps-wf1/src/wps_wf1/parsers.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from typing import Generator, List +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum from wps_shared.db.models.forecasts import NoonForecast from wps_shared.db.models.observations import HourlyActual from wps_shared.schemas.fba import FireCenterStation, WFWXFireCentre @@ -56,6 +57,13 @@ def parse_wf1_datetime(raw_daily: dict) -> datetime: return datetime.fromtimestamp(raw_daily.get("weatherTimestamp") / 1000, tz=timezone.utc) +def parse_sfms_run_type(raw_daily: dict) -> RunTypeEnum: + record_type_id = (raw_daily.get("recordType") or {}).get("id") + if record_type_id == WF1RecordTypeEnum.FORECAST.value: + return RunTypeEnum.forecast + return RunTypeEnum.actual + + def construct_zone_code(station: any): """Constructs the 2-character zone code for a weather station, using the station's zone.alias integer value, prefixed by the fire centre-to-letter mapping. @@ -384,6 +392,7 @@ def sfms_daily_actuals_mapper(raw_dailies: List[dict]) -> List[SFMSDaily]: SFMSDaily( code=station_code, for_datetime=parse_wf1_datetime(raw_daily), + run_type=parse_sfms_run_type(raw_daily), lat=station_data.get("latitude"), lon=station_data.get("longitude"), elevation=station_data.get("elevation"), @@ -414,6 +423,7 @@ def sfms_daily_forecasts_mapper(raw_dailies: List[dict]) -> List[SFMSDaily]: SFMSDaily( code=station_code, for_datetime=parse_wf1_datetime(raw_daily), + run_type=parse_sfms_run_type(raw_daily), lat=station_data.get("latitude"), lon=station_data.get("longitude"), elevation=station_data.get("elevation"), diff --git a/backend/packages/wps-wf1/src/wps_wf1/tests/test_parsers.py b/backend/packages/wps-wf1/src/wps_wf1/tests/test_parsers.py index ee3b42658..232b7ab86 100644 --- a/backend/packages/wps-wf1/src/wps_wf1/tests/test_parsers.py +++ b/backend/packages/wps-wf1/src/wps_wf1/tests/test_parsers.py @@ -3,6 +3,7 @@ import pytest from pytest import approx +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum from wps_shared.db.models.observations import HourlyActual from wps_shared.schemas.sfms import SFMSDaily from wps_wf1.parsers import ( @@ -140,6 +141,7 @@ def test_maps_actual_with_all_weather_fields(self): assert result[0] == SFMSDaily( code=100, for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, lat=49.0, lon=-123.0, elevation=150, @@ -177,6 +179,7 @@ def test_maps_manual_record_type_as_actual(self): assert len(result) == 1 assert result[0].code == 100 assert result[0].for_datetime == TEST_FOR_DATETIME + assert result[0].run_type == RunTypeEnum.actual assert result[0].temperature == approx(15.0) def test_filters_forecast_record_type(self): @@ -234,6 +237,7 @@ def test_maps_forecast_and_computes_dewpoint(self): forecast = result[0] assert forecast.code == 100 assert forecast.for_datetime == TEST_FOR_DATETIME + assert forecast.run_type == RunTypeEnum.forecast assert forecast.lat == approx(49.0, abs=0) assert forecast.lon == approx(-123.0, abs=0) assert forecast.elevation == 150 diff --git a/backend/packages/wps-wf1/src/wps_wf1/tests/test_wfwx_api.py b/backend/packages/wps-wf1/src/wps_wf1/tests/test_wfwx_api.py index 55e805bbd..7aa343b86 100644 --- a/backend/packages/wps-wf1/src/wps_wf1/tests/test_wfwx_api.py +++ b/backend/packages/wps-wf1/src/wps_wf1/tests/test_wfwx_api.py @@ -3,6 +3,8 @@ import pytest +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum + # --------------------------- # Helpers & Fakes for testing @@ -944,6 +946,7 @@ async def test_get_sfms_daily_actuals_all_stations(monkeypatch, wfwx_api): SFMSDaily( code=100, for_datetime=for_datetime, + run_type=RunTypeEnum.actual, lat=49.0, lon=-123.0, elevation=100, @@ -952,6 +955,7 @@ async def test_get_sfms_daily_actuals_all_stations(monkeypatch, wfwx_api): SFMSDaily( code=200, for_datetime=for_datetime, + run_type=RunTypeEnum.actual, lat=50.0, lon=-124.0, elevation=300, @@ -994,6 +998,7 @@ async def test_get_sfms_daily_forecasts_all_stations(monkeypatch, wfwx_api): SFMSDaily( code=100, for_datetime=datetime(2025, 7, 16, 20, tzinfo=timezone.utc), + run_type=RunTypeEnum.forecast, lat=49.0, lon=-123.0, elevation=100, From 750bbdce7d332dbf590e9bf940fe2768058c86c0 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 18 Jun 2026 10:57:50 -0700 Subject: [PATCH 2/2] dedupe code --- .../wps_sfms/tests/test_wind_interpolation.py | 135 +++++++----------- 1 file changed, 48 insertions(+), 87 deletions(-) diff --git a/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py b/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py index cca7d79c7..ee3392bcd 100644 --- a/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py +++ b/backend/packages/wps-sfms/src/wps_sfms/tests/test_wind_interpolation.py @@ -1,4 +1,5 @@ import uuid +from contextlib import contextmanager from datetime import datetime, timezone import numpy as np @@ -14,6 +15,47 @@ from wps_sfms.tests.conftest import create_test_raster TEST_FOR_DATETIME = datetime(2025, 7, 15, 20, tzinfo=timezone.utc) +TEST_EXTENT = (-123.1, -123.0, 49.0, 49.1) + + +def make_wind_daily( + code: int, + lat: float, + lon: float, + wind_speed: float, + wind_direction: float | None, +) -> SFMSDaily: + return SFMSDaily( + code=code, + for_datetime=TEST_FOR_DATETIME, + run_type=RunTypeEnum.actual, + lat=lat, + lon=lon, + wind_speed=wind_speed, + wind_direction=wind_direction, + ) + + +def make_paired_wind_dailies() -> list[SFMSDaily]: + return [ + make_wind_daily(100, 49.05, -123.05, 10.0, 90.0), + make_wind_daily(101, 49.08, -123.02, 8.0, 180.0), + ] + + +@contextmanager +def reference_and_mask_rasters(size: int): + test_id = uuid.uuid4().hex + ref_path = f"/vsimem/reference_{test_id}.tif" + mask_path = f"/vsimem/mask_{test_id}.tif" + + try: + create_test_raster(ref_path, size, size, TEST_EXTENT, fill_value=1.0) + create_test_raster(mask_path, size, size, TEST_EXTENT, fill_value=1.0) + yield ref_path, mask_path + finally: + gdal.Unlink(ref_path) + gdal.Unlink(mask_path) class TestWindDirectionInterpolator: @@ -52,35 +94,8 @@ def test_compute_direction_from_uv_mixed_zero_and_nonzero_v(self): ) def test_interpolate_basic_success(self): - test_id = uuid.uuid4().hex - ref_path = f"/vsimem/reference_{test_id}.tif" - mask_path = f"/vsimem/mask_{test_id}.tif" - - try: - extent = (-123.1, -123.0, 49.0, 49.1) - create_test_raster(ref_path, 10, 10, extent, fill_value=1.0) - create_test_raster(mask_path, 10, 10, extent, fill_value=1.0) - - actuals = [ - SFMSDaily( - code=100, - for_datetime=TEST_FOR_DATETIME, - run_type=RunTypeEnum.actual, - lat=49.05, - lon=-123.05, - wind_speed=10.0, - wind_direction=90.0, - ), - SFMSDaily( - code=101, - for_datetime=TEST_FOR_DATETIME, - run_type=RunTypeEnum.actual, - lat=49.08, - lon=-123.02, - wind_speed=8.0, - wind_direction=180.0, - ), - ] + with reference_and_mask_rasters(size=10) as (ref_path, mask_path): + actuals = make_paired_wind_dailies() field = build_wind_vector_field(actuals) dataset = WindDirectionInterpolator(mask_path=mask_path, field=field).interpolate( @@ -93,72 +108,21 @@ def test_interpolate_basic_success(self): assert valid.size > 0 assert np.all(valid >= 0.0) assert np.all(valid <= 360.0) - finally: - gdal.Unlink(ref_path) - gdal.Unlink(mask_path) def test_interpolate_raises_without_paired_stations(self): - test_id = uuid.uuid4().hex - ref_path = f"/vsimem/reference_{test_id}.tif" - mask_path = f"/vsimem/mask_{test_id}.tif" - - try: - extent = (-123.1, -123.0, 49.0, 49.1) - create_test_raster(ref_path, 5, 5, extent, fill_value=1.0) - create_test_raster(mask_path, 5, 5, extent, fill_value=1.0) - - actuals = [ - SFMSDaily( - code=100, - for_datetime=TEST_FOR_DATETIME, - run_type=RunTypeEnum.actual, - lat=49.05, - lon=-123.05, - wind_speed=10.0, - wind_direction=None, - ) - ] + with reference_and_mask_rasters(size=5) as (ref_path, mask_path): + actuals = [make_wind_daily(100, 49.05, -123.05, 10.0, None)] field = build_wind_vector_field(actuals) with pytest.raises(RuntimeError, match="No pixels were successfully interpolated"): WindDirectionInterpolator(mask_path=mask_path, field=field).interpolate(ref_path) - finally: - gdal.Unlink(ref_path) - gdal.Unlink(mask_path) @pytest.mark.parametrize("successful_component_label", ["wind-u component", "wind-v component"]) def test_interpolate_raises_when_only_one_component_succeeds( self, monkeypatch, successful_component_label ): - test_id = uuid.uuid4().hex - ref_path = f"/vsimem/reference_{test_id}.tif" - mask_path = f"/vsimem/mask_{test_id}.tif" - - try: - extent = (-123.1, -123.0, 49.0, 49.1) - create_test_raster(ref_path, 5, 5, extent, fill_value=1.0) - create_test_raster(mask_path, 5, 5, extent, fill_value=1.0) - - actuals = [ - SFMSDaily( - code=100, - for_datetime=TEST_FOR_DATETIME, - run_type=RunTypeEnum.actual, - lat=49.05, - lon=-123.05, - wind_speed=10.0, - wind_direction=90.0, - ), - SFMSDaily( - code=101, - for_datetime=TEST_FOR_DATETIME, - run_type=RunTypeEnum.actual, - lat=49.08, - lon=-123.02, - wind_speed=8.0, - wind_direction=180.0, - ), - ] + with reference_and_mask_rasters(size=5) as (ref_path, mask_path): + actuals = make_paired_wind_dailies() field = build_wind_vector_field(actuals) def _fake_idw_on_valid_pixels(**kwargs): @@ -191,6 +155,3 @@ def _fake_idw_on_valid_pixels(**kwargs): with pytest.raises(RuntimeError, match="No pixels were successfully interpolated"): WindDirectionInterpolator(mask_path=mask_path, field=field).interpolate(ref_path) - finally: - gdal.Unlink(ref_path) - gdal.Unlink(mask_path)