Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/packages/wps-api/src/app/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions backend/packages/wps-sfms/src/wps_sfms/tests/test_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -20,13 +21,15 @@ 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,
),
SFMSDaily(
code=2,
for_datetime=TEST_FOR_DATETIME,
run_type=RunTypeEnum.actual,
lat=49.1,
lon=-123.1,
precipitation=None,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,61 @@
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone

import numpy as np
import pytest
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
from wps_sfms.processors.wind import WindDirectionInterpolator
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:
Expand Down Expand Up @@ -51,33 +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,
lat=49.05,
lon=-123.05,
wind_speed=10.0,
wind_direction=90.0,
),
SFMSDaily(
code=101,
for_datetime=TEST_FOR_DATETIME,
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(
Expand All @@ -90,69 +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,
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,
lat=49.05,
lon=-123.05,
wind_speed=10.0,
wind_direction=90.0,
),
SFMSDaily(
code=101,
for_datetime=TEST_FOR_DATETIME,
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):
Expand Down Expand Up @@ -185,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)
1 change: 1 addition & 0 deletions backend/packages/wps-shared/src/wps_shared/schemas/sfms.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class SFMSDaily(BaseModel):

code: int
for_datetime: datetime
run_type: RunTypeEnum
lat: float
lon: float
elevation: Optional[float] = None
Expand Down
10 changes: 10 additions & 0 deletions backend/packages/wps-wf1/src/wps_wf1/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions backend/packages/wps-wf1/src/wps_wf1/tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading