diff --git a/services/griddle/griddle/dispatch.py b/services/griddle/griddle/dispatch.py index 7b113429..3865e989 100644 --- a/services/griddle/griddle/dispatch.py +++ b/services/griddle/griddle/dispatch.py @@ -146,6 +146,7 @@ def handle_landfire( remove_non_burnable = source.get("remove_non_burnable") return landfire.fetch_fbfm13( domain_gdf, + progress, version, remove_non_burnable=remove_non_burnable, extent_buffer_cells=extent_buffer_cells, @@ -159,13 +160,13 @@ def handle_landfire( season = source.get("season") return landfire.fetch_fbfm40( domain_gdf, + progress, version, remove_non_burnable=remove_non_burnable, extent_buffer_cells=extent_buffer_cells, alignment=alignment, target_grid_doc=target_grid_doc, season=season, - progress=progress, ) case "fccs": version = source.get("version", LANDFIRE_VERSIONS["fccs"]["default"]) @@ -173,6 +174,7 @@ def handle_landfire( remove_bare_ground = source.get("remove_bare_ground", False) return landfire.fetch_fccs( domain_gdf, + progress, version, remove_bare_ground=remove_bare_ground, extent_buffer_cells=extent_buffer_cells, diff --git a/services/griddle/griddle/handlers/landfire.py b/services/griddle/griddle/handlers/landfire.py index 75e9b333..21a3270e 100644 --- a/services/griddle/griddle/handlers/landfire.py +++ b/services/griddle/griddle/handlers/landfire.py @@ -54,6 +54,11 @@ def _landfire_cog_url(product: str, version: str) -> str: return f"gs://{RASTERS_BUCKET}/LF{version}_{product}_CONUS.tif" +def _needs_lfps(product: str, version: str) -> bool: + """Whether `version` is only served on-demand via LFPS, not as a staged COG.""" + return version in LANDFIRE_VERSIONS[product].get("lfps_available", ()) + + def _fetch_landfire_raster( roi: gpd.GeoDataFrame, url: str, @@ -140,6 +145,7 @@ def _to_dataset(variables: dict[str, DataArray]) -> xr.Dataset: def fetch_fbfm13( roi: gpd.GeoDataFrame, + progress: Callable[[str, int | None], None], version: str = LANDFIRE_VERSIONS["fbfm13"]["default"], remove_non_burnable: list[str] | None = None, extent_buffer_cells: int = 0, @@ -159,23 +165,38 @@ def fetch_fbfm13( ``{"target": "domain"}`` when omitted. target_grid_doc: Loaded grid document used when ``alignment["target"] == "grid"``. + progress: Progress callback, only used when `version` is fetched via + LFPS (submit/wait/download reports through it). Returns: Dataset with a single "fbfm13" variable (int16 categorical codes, 1-13 plus non-burnable 91/92/93/98/99) """ - product = "fbfm13" - validate_landfire_version(product, version) alignment = alignment or {"target": "domain"} - url = _landfire_cog_url(product, version) - data = _fetch_landfire_raster( - roi, - url, - extent_buffer_cells, - alignment, - target_grid_doc, - is_categorical=True, - ) + product = "fbfm13" + if _needs_lfps(product, version): + source = landfire_lfps.fetch_lfps( + roi, + product, + version, + alignment, + target_grid_doc, + extent_buffer_cells, + progress, + ) + else: + validate_landfire_version(product, version) + source = nullcontext(_landfire_cog_url(product, version)) + + with source as url: + data = _fetch_landfire_raster( + roi, + url, + extent_buffer_cells, + alignment, + target_grid_doc, + is_categorical=True, + ) if remove_non_burnable: non_burnable_keys = [NB_CODE_MAP[code] for code in remove_non_burnable] @@ -187,12 +208,12 @@ def fetch_fbfm13( def fetch_fbfm40( roi: gpd.GeoDataFrame, + progress: Callable[[str, int | None], None], version: str = LANDFIRE_VERSIONS["fbfm40"]["default"], remove_non_burnable: list[str] | None = None, extent_buffer_cells: int = 0, alignment: dict | None = None, target_grid_doc: dict | None = None, - progress: Callable[[str, int | None], None] | None = None, season: str | None = None, ) -> xr.Dataset: """Fetch LANDFIRE FBFM40 fuel model codes. @@ -218,7 +239,7 @@ def fetch_fbfm40( """ alignment = alignment or {"target": "domain"} product = "fbfm40" - if season is not None: + if season is not None or _needs_lfps(product, version): source = landfire_lfps.fetch_lfps( roi, product, @@ -254,6 +275,7 @@ def fetch_fbfm40( def fetch_fccs( roi: gpd.GeoDataFrame, + progress: Callable[[str, int | None], None], version: str = LANDFIRE_VERSIONS["fccs"]["default"], remove_bare_ground: bool = False, extent_buffer_cells: int = 0, @@ -273,22 +295,37 @@ def fetch_fccs( ``{"target": "domain"}`` when omitted. target_grid_doc: Loaded grid document used when ``alignment["target"] == "grid"``. + progress: Progress callback, only used when `version` is fetched via + LFPS (submit/wait/download reports through it). Returns: Dataset with a single "fccs" variable (int32 categorical codes) """ - product = "fccs" - validate_landfire_version(product, version) alignment = alignment or {"target": "domain"} - url = _landfire_cog_url(product, version) - data = _fetch_landfire_raster( - roi, - url, - extent_buffer_cells, - alignment, - target_grid_doc, - is_categorical=True, - ) + product = "fccs" + if _needs_lfps(product, version): + source = landfire_lfps.fetch_lfps( + roi, + product, + version, + alignment, + target_grid_doc, + extent_buffer_cells, + progress, + ) + else: + validate_landfire_version(product, version) + source = nullcontext(_landfire_cog_url(product, version)) + + with source as url: + data = _fetch_landfire_raster( + roi, + url, + extent_buffer_cells, + alignment, + target_grid_doc, + is_categorical=True, + ) if remove_bare_ground: filtered = _remove_non_burnable_blocks(data.values, [0]) diff --git a/services/griddle/tests/handlers/test_alignment_handlers.py b/services/griddle/tests/handlers/test_alignment_handlers.py index 011e82c8..6f7d89a3 100644 --- a/services/griddle/tests/handlers/test_alignment_handlers.py +++ b/services/griddle/tests/handlers/test_alignment_handlers.py @@ -81,9 +81,11 @@ class TestLandfireAlignmentDomain: def test_domain_target_passes_destination_kwargs(self, mock_cls): mock_cls.return_value = _mock_raster() roi = _domain_gdf() + progress = MagicMock() fetch_fbfm40( roi, + progress, version="2024", alignment={"target": "domain", "resolution": 2.0}, ) @@ -103,8 +105,9 @@ def test_domain_target_passes_destination_kwargs(self, mock_cls): def test_domain_target_default_uses_source_native_resolution(self, mock_cls): mock_cls.return_value = _mock_raster(source_resolution=30.0) roi = _domain_gdf() + progress = MagicMock() - fetch_fbfm40(roi, version="2024", alignment={"target": "domain"}) + fetch_fbfm40(roi, progress, version="2024", alignment={"target": "domain"}) kwargs = mock_cls.return_value.extract_window.call_args[1] transform = kwargs["destination_transform"] @@ -122,8 +125,9 @@ def test_domain_target_default_uses_source_native_in_roi_crs_units(self, mock_cl native_in_roi_resolution=30.0, ) roi = _domain_gdf() + progress = MagicMock() - fetch_fbfm40(roi, version="2024", alignment={"target": "domain"}) + fetch_fbfm40(roi, progress, version="2024", alignment={"target": "domain"}) kwargs = mock_cls.return_value.extract_window.call_args[1] transform = kwargs["destination_transform"] @@ -140,8 +144,9 @@ class TestLandfireAlignmentNative: def test_native_target_no_resolution_passes_no_destination(self, mock_cls): mock_cls.return_value = _mock_raster() roi = _domain_gdf() + progress = MagicMock() - fetch_fbfm40(roi, version="2024", alignment={"target": "native"}) + fetch_fbfm40(roi, progress, version="2024", alignment={"target": "native"}) kwargs = mock_cls.return_value.extract_window.call_args[1] assert "destination_transform" not in kwargs @@ -154,9 +159,11 @@ def test_native_target_no_resolution_passes_no_destination(self, mock_cls): def test_native_target_with_resolution_passes_resolution(self, mock_cls): mock_cls.return_value = _mock_raster() roi = _domain_gdf() + progress = MagicMock() fetch_fbfm40( roi, + progress, version="2024", alignment={"target": "native", "resolution": 5.0}, ) @@ -173,6 +180,7 @@ class TestLandfireAlignmentGrid: def test_grid_target_exact_match(self, mock_cls): mock_cls.return_value = _mock_raster() roi = _domain_gdf() + progress = MagicMock() target_grid_doc = { "georeference": { "crs": "EPSG:32611", @@ -183,6 +191,7 @@ def test_grid_target_exact_match(self, mock_cls): fetch_fbfm40( roi, + progress, version="2024", alignment={"target": "grid", "grid_id": "x"}, target_grid_doc=target_grid_doc, @@ -199,6 +208,7 @@ def test_grid_target_exact_match(self, mock_cls): def test_grid_target_with_resolution_recomputes_shape(self, mock_cls): mock_cls.return_value = _mock_raster() roi = _domain_gdf() + progress = MagicMock() # Target grid: 30m cells, 10x10, anchored lower-left at (720100, 5190200). target_grid_doc = { "georeference": { @@ -210,6 +220,7 @@ def test_grid_target_with_resolution_recomputes_shape(self, mock_cls): fetch_fbfm40( roi, + progress, version="2024", alignment={"target": "grid", "grid_id": "x", "resolution": 1.0}, target_grid_doc=target_grid_doc, diff --git a/services/griddle/tests/handlers/test_landfire.py b/services/griddle/tests/handlers/test_landfire.py index 9373efcf..e243069a 100644 --- a/services/griddle/tests/handlers/test_landfire.py +++ b/services/griddle/tests/handlers/test_landfire.py @@ -15,6 +15,7 @@ LANDFIRE_EXTRA_NODATA, _fetch_landfire_raster, _most_frequent, + _needs_lfps, _remove_non_burnable_blocks, fetch_fbfm13, fetch_fbfm40, @@ -184,42 +185,113 @@ def test_direction_only_extra_sentinel_cells_change(self, mock_raster_cls, roi): np.testing.assert_array_equal(result.values, [[55, 55], [55, 55]]) +class TestNeedsLfps: + """Unit tests for _needs_lfps.""" + + def test_staged_version_does_not_need_lfps(self): + assert _needs_lfps("fbfm40", "2024") is False + + def test_lfps_only_version_needs_lfps(self): + assert _needs_lfps("fbfm40", "2025") is True + + def test_product_without_lfps_available_key_returns_false(self): + """Falls back cleanly if a product's config has no lfps_available key + at all, rather than KeyError.""" + assert _needs_lfps("fbfm13", "1999") is False + + class TestFetchFbfm13: """Integration tests for fetch_fbfm13.""" def test_returns_dataset(self, roi): """fetch_fbfm13 returns a Dataset.""" - result = fetch_fbfm13(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm13( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert isinstance(result, xr.Dataset) def test_has_fbfm13_variable(self, roi): """Dataset contains an 'fbfm13' variable.""" - result = fetch_fbfm13(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm13( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert "fbfm13" in result.data_vars def test_fbfm13_shape(self, test_domain, roi): """The fbfm13 variable has the expected spatial shape.""" - result = fetch_fbfm13(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm13( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert result["fbfm13"].shape == test_domain.expected_shape def test_fbfm13_dtype(self, roi): """The fbfm13 variable is int16 (categorical codes).""" - result = fetch_fbfm13(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm13( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert result["fbfm13"].dtype == "int16" def test_crs_preserved(self, roi): """CRS is preserved via rioxarray.""" - result = fetch_fbfm13(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm13( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert result.rio.crs == roi.crs def test_fbfm13_values_in_valid_set(self, roi): """FBFM13 codes are limited to the Anderson 13 models plus non-burnable.""" - result = fetch_fbfm13(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm13( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) values = result["fbfm13"].values valid_codes = set(range(1, 14)) | {91, 92, 93, 98, 99} assert set(np.unique(values)).issubset(valid_codes) +class TestFetchFbfm13LfpsPath: + """Unit tests for fetch_fbfm13's LFPS branch -- routing on lfps_available versions.""" + + @patch("griddle.handlers.landfire._fetch_landfire_raster") + @patch("griddle.handlers.landfire.landfire_lfps.fetch_lfps") + def test_lfps_available_version_routes_through_lfps( + self, mock_fetch_lfps, mock_fetch_raster, roi + ): + mock_cm = MagicMock() + mock_cm.__enter__.return_value = "/tmp/lfps_xyz/result.tif" + mock_fetch_lfps.return_value = mock_cm + mock_fetch_raster.return_value = _make_canopy_raster( + np.zeros((4, 4), dtype=np.int16) + ) + progress = MagicMock() + + result = fetch_fbfm13(roi, progress, version="2025") + + mock_fetch_lfps.assert_called_once_with( + roi, "fbfm13", "2025", {"target": "domain"}, None, 0, progress + ) + mock_fetch_raster.assert_called_once_with( + roi, + "/tmp/lfps_xyz/result.tif", + 0, + {"target": "domain"}, + None, + is_categorical=True, + ) + assert "fbfm13" in result.data_vars + + @patch("griddle.handlers.landfire.landfire_lfps.fetch_lfps") + def test_staged_version_does_not_call_lfps(self, mock_fetch_lfps, roi): + progress = MagicMock() + fetch_fbfm13(roi, progress, version="2024") + mock_fetch_lfps.assert_not_called() + + class TestFetchFbfm40: """Integration tests for fetch_fbfm40.""" @@ -279,37 +351,56 @@ def test_extent_buffer_cells_threaded_through(self, mock_raster_cls, roi, buffer def test_returns_dataset(self, roi): """fetch_fbfm40 returns a Dataset.""" - result = fetch_fbfm40(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm40( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert isinstance(result, xr.Dataset) def test_has_fbfm_variable(self, roi): """Dataset contains a 'fbfm' variable.""" - result = fetch_fbfm40(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm40( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert "fbfm" in result.data_vars def test_fbfm_shape(self, test_domain, roi): """The fbfm variable has the expected spatial shape.""" - result = fetch_fbfm40(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm40( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert result["fbfm"].shape == test_domain.expected_shape def test_fbfm_dtype(self, roi): """The fbfm variable is int16 (categorical codes).""" - result = fetch_fbfm40(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm40( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert result["fbfm"].dtype == "int16" def test_crs_preserved(self, roi): """CRS is preserved via rioxarray.""" - result = fetch_fbfm40(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm40( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert result.rio.crs == roi.crs def test_fbfm_values_in_range(self, roi): """FBFM40 codes should be <= 204.""" - result = fetch_fbfm40(roi=roi, version="2024", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fbfm40( + roi=roi, progress=progress, version="2024", extent_buffer_cells=8 + ) assert result["fbfm"].values.max() <= 204 -class TestFetchFbfm40SeasonalPath: - """Unit tests for fetch_fbfm40's season branch -- routing to LFPS.""" +class TestFetchFbfm40LfpsPath: + """Unit tests for fetch_fbfm40's LFPS branch -- routing to LFPS for a + season, an lfps_available version, or both.""" @patch("griddle.handlers.landfire._fetch_landfire_raster") @patch("griddle.handlers.landfire.landfire_lfps.fetch_lfps") @@ -322,7 +413,7 @@ def test_season_routes_through_lfps(self, mock_fetch_lfps, mock_fetch_raster, ro ) progress = MagicMock() - result = fetch_fbfm40(roi, version="2025", season="SP", progress=progress) + result = fetch_fbfm40(roi, progress, version="2025", season="SP") mock_fetch_lfps.assert_called_once_with( roi, "fbfm40", "2025", {"target": "domain"}, None, 0, progress, "SP" @@ -337,9 +428,30 @@ def test_season_routes_through_lfps(self, mock_fetch_lfps, mock_fetch_raster, ro ) assert "fbfm" in result.data_vars + @patch("griddle.handlers.landfire._fetch_landfire_raster") + @patch("griddle.handlers.landfire.landfire_lfps.fetch_lfps") + def test_lfps_available_version_without_season_routes_through_lfps( + self, mock_fetch_lfps, mock_fetch_raster, roi + ): + mock_cm = MagicMock() + mock_cm.__enter__.return_value = "/tmp/lfps_xyz/result.tif" + mock_fetch_lfps.return_value = mock_cm + mock_fetch_raster.return_value = _make_canopy_raster( + np.zeros((4, 4), dtype=np.int16) + ) + progress = MagicMock() + + result = fetch_fbfm40(roi, progress, version="2025") + + mock_fetch_lfps.assert_called_once_with( + roi, "fbfm40", "2025", {"target": "domain"}, None, 0, progress, None + ) + assert "fbfm" in result.data_vars + @patch("griddle.handlers.landfire.landfire_lfps.fetch_lfps") def test_no_season_does_not_call_lfps(self, mock_fetch_lfps, roi): - fetch_fbfm40(roi, version="2024") + progress = MagicMock() + fetch_fbfm40(roi, progress, version="2024") mock_fetch_lfps.assert_not_called() @@ -348,32 +460,50 @@ class TestFetchFccs: def test_returns_dataset(self, roi): """fetch_fccs returns a Dataset.""" - result = fetch_fccs(roi=roi, version="2023", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fccs( + roi=roi, progress=progress, version="2023", extent_buffer_cells=8 + ) assert isinstance(result, xr.Dataset) def test_has_fccs_variable(self, roi): """Dataset contains a 'fccs' variable.""" - result = fetch_fccs(roi=roi, version="2023", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fccs( + roi=roi, progress=progress, version="2023", extent_buffer_cells=8 + ) assert "fccs" in result.data_vars def test_fccs_shape(self, test_domain, roi): """The fccs variable has the expected spatial shape.""" - result = fetch_fccs(roi=roi, version="2023", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fccs( + roi=roi, progress=progress, version="2023", extent_buffer_cells=8 + ) assert result["fccs"].shape == test_domain.expected_shape def test_fccs_dtype(self, roi): """The fccs variable is int32 (codes up to 12990133 exceed int16 range).""" - result = fetch_fccs(roi=roi, version="2023", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fccs( + roi=roi, progress=progress, version="2023", extent_buffer_cells=8 + ) assert result["fccs"].dtype == "int32" def test_crs_preserved(self, roi): """CRS is preserved via rioxarray.""" - result = fetch_fccs(roi=roi, version="2023", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fccs( + roi=roi, progress=progress, version="2023", extent_buffer_cells=8 + ) assert result.rio.crs == roi.crs def test_fccs_valid_values_in_range(self, roi): """Mapped FCCS codes should be between 0 and 12990133.""" - result = fetch_fccs(roi=roi, version="2023", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fccs( + roi=roi, progress=progress, version="2023", extent_buffer_cells=8 + ) values = result["fccs"].values valid_mask = ~np.isin(values, [-1111, -9999]) assert values[valid_mask].min() >= 0 @@ -381,28 +511,73 @@ def test_fccs_valid_values_in_range(self, roi): def test_fccs_fill_values_are_expected(self, roi): """Any negative values are only the known fill values (-1111, -9999).""" - result = fetch_fccs(roi=roi, version="2023", extent_buffer_cells=8) + progress = MagicMock() + result = fetch_fccs( + roi=roi, progress=progress, version="2023", extent_buffer_cells=8 + ) values = result["fccs"].values negative_values = np.unique(values[values < 0]) assert set(negative_values).issubset({-1111, -9999}) def test_remove_bare_ground_removes_zeros(self, roi): """When remove_bare_ground=True, no bare ground cells (code 0) remain.""" - result = fetch_fccs(roi=roi, remove_bare_ground=True) + progress = MagicMock() + result = fetch_fccs(roi=roi, progress=progress, remove_bare_ground=True) values = result["fccs"].values valid_mask = ~np.isin(values, [-1111, -9999]) assert not np.any(values[valid_mask] == 0) def test_remove_bare_ground_false_by_default(self, roi): """remove_bare_ground defaults to False and does not alter the data.""" - default_result = fetch_fccs(roi=roi) - explicit_result = fetch_fccs(roi=roi, remove_bare_ground=False) + progress = MagicMock() + default_result = fetch_fccs(roi=roi, progress=progress) + explicit_result = fetch_fccs( + roi=roi, progress=progress, remove_bare_ground=False + ) np.testing.assert_array_equal( default_result["fccs"].values, explicit_result["fccs"].values, ) +class TestFetchFccsLfpsPath: + """Unit tests for fetch_fccs's LFPS branch -- routing on lfps_available versions.""" + + @patch("griddle.handlers.landfire._fetch_landfire_raster") + @patch("griddle.handlers.landfire.landfire_lfps.fetch_lfps") + def test_lfps_available_version_routes_through_lfps( + self, mock_fetch_lfps, mock_fetch_raster, roi + ): + mock_cm = MagicMock() + mock_cm.__enter__.return_value = "/tmp/lfps_xyz/result.tif" + mock_fetch_lfps.return_value = mock_cm + mock_fetch_raster.return_value = _make_canopy_raster( + np.zeros((4, 4), dtype=np.int16) + ) + progress = MagicMock() + + result = fetch_fccs(roi, progress, version="2025") + + mock_fetch_lfps.assert_called_once_with( + roi, "fccs", "2025", {"target": "domain"}, None, 0, progress + ) + mock_fetch_raster.assert_called_once_with( + roi, + "/tmp/lfps_xyz/result.tif", + 0, + {"target": "domain"}, + None, + is_categorical=True, + ) + assert "fccs" in result.data_vars + + @patch("griddle.handlers.landfire.landfire_lfps.fetch_lfps") + def test_staged_version_does_not_call_lfps(self, mock_fetch_lfps, roi): + progress = MagicMock() + fetch_fccs(roi, progress, version="2023") + mock_fetch_lfps.assert_not_called() + + class TestFetchTopography: """Integration tests for fetch_topography.""" diff --git a/services/griddle/tests/integration/test_landfire_lfps.py b/services/griddle/tests/integration/test_landfire_lfps.py index 20a26ce1..af7edd7c 100644 --- a/services/griddle/tests/integration/test_landfire_lfps.py +++ b/services/griddle/tests/integration/test_landfire_lfps.py @@ -12,7 +12,7 @@ import pytest from griddle.handlers import landfire -from lib.landfire import list_products +from lib.landfire import LANDFIRE_VERSIONS, list_products from lib.testing import SHARED_TEST_DOMAINS_DIR @@ -72,3 +72,28 @@ def test_seasonal_fbfm40(roi): fbfm_valid = _assert_valid_data(ds, "fbfm") assert fbfm_valid.max() <= 204 # matches test_landfire.py's FBFM40 range check + + +@pytest.mark.parametrize( + ("product", "fetch_fn", "band"), + [ + ("fbfm13", landfire.fetch_fbfm13, "fbfm13"), + ("fbfm40", landfire.fetch_fbfm40, "fbfm"), + ("fccs", landfire.fetch_fccs, "fccs"), + ], +) +def test_annual_lfps_available_version(roi, product, fetch_fn, band): + """Submit a real annual (non-seasonal) LFPS job for each product's + configured lfps_available version -- confirms LANDFIRE Product Service + is actually serving what our config says it should.""" + lfps_versions = LANDFIRE_VERSIONS[product].get("lfps_available", []) + if not lfps_versions: + pytest.skip(f"No lfps_available version configured for {product}.") + + ds = fetch_fn(roi, version=lfps_versions[0], progress=lambda *a, **k: None) + + assert band in ds.data_vars + assert ds[band].dims == ("y", "x") + assert ds[band].rio.nodata is not None + assert ds.rio.height > 0 + assert ds.rio.width > 0 diff --git a/services/griddle/tests/test_dispatch.py b/services/griddle/tests/test_dispatch.py index 4dc7c4b5..2bcb6794 100644 --- a/services/griddle/tests/test_dispatch.py +++ b/services/griddle/tests/test_dispatch.py @@ -53,6 +53,7 @@ def test_routes_fbfm13_to_handler(self, mock_fetch): mock_fetch.assert_called_once_with( mock_gdf, + progress, "2023", remove_non_burnable=None, extent_buffer_cells=0, @@ -72,7 +73,7 @@ def test_fbfm13_default_version(self, mock_fetch): handle_landfire(mock_gdf, source, progress) _, call_kwargs = mock_fetch.call_args - assert call_kwargs == {} or mock_fetch.call_args[0][1] == "2024" + assert call_kwargs == {} or mock_fetch.call_args[0][2] == "2024" @patch("griddle.dispatch.landfire.fetch_fbfm13") def test_fbfm13_calls_progress_callback(self, mock_fetch): @@ -103,13 +104,13 @@ def test_routes_fbfm40_to_handler(self, mock_fetch): mock_fetch.assert_called_once_with( mock_gdf, + progress, "2022", remove_non_burnable=None, extent_buffer_cells=0, alignment={"target": "domain"}, target_grid_doc=None, season=None, - progress=progress, ) assert result == mock_result @@ -123,7 +124,7 @@ def test_fbfm40_default_version(self, mock_fetch): handle_landfire(mock_gdf, source, progress) - assert mock_fetch.call_args[0][1] == "2024" + assert mock_fetch.call_args[0][2] == "2024" @patch("griddle.dispatch.landfire.fetch_fbfm40") def test_fbfm40_calls_progress_callback(self, mock_fetch): @@ -152,7 +153,7 @@ def test_fbfm40_threads_season_and_progress(self, mock_fetch): call_kwargs = mock_fetch.call_args[1] assert call_kwargs["season"] == "SP" - assert call_kwargs["progress"] is progress + assert mock_fetch.call_args[0][1] is progress @patch("griddle.dispatch.landfire.fetch_fccs") def test_routes_fccs_to_handler(self, mock_fetch): @@ -168,6 +169,7 @@ def test_routes_fccs_to_handler(self, mock_fetch): mock_fetch.assert_called_once_with( mock_gdf, + progress, "2023", remove_bare_ground=False, extent_buffer_cells=0, @@ -190,6 +192,7 @@ def test_routes_fccs_with_remove_bare_ground(self, mock_fetch): mock_fetch.assert_called_once_with( mock_gdf, + progress, "2023", remove_bare_ground=True, extent_buffer_cells=0, @@ -210,7 +213,7 @@ def test_fccs_default_version(self, mock_fetch): handle_landfire(mock_gdf, source, progress) call_args = mock_fetch.call_args[0] - assert call_args[1] == "2023" + assert call_args[2] == "2023" @patch("griddle.dispatch.landfire.fetch_fccs") def test_fccs_calls_progress(self, mock_fetch): diff --git a/services/lib/lib/landfire/config.py b/services/lib/lib/landfire/config.py index 7fe65c7e..332b99ed 100644 --- a/services/lib/lib/landfire/config.py +++ b/services/lib/lib/landfire/config.py @@ -17,6 +17,7 @@ LANDFIRE_VERSIONS: dict[str, dict[str, list[str] | str]] = { "fbfm13": { "available": ["2023", "2024"], + "lfps_available": ["2025"], "default": "2024", }, "fbfm40": { @@ -26,6 +27,7 @@ }, "fccs": { "available": ["2023"], + "lfps_available": ["2025"], "default": "2023", }, } diff --git a/services/lib/tests/test_landfire_config.py b/services/lib/tests/test_landfire_config.py index 2b28ce30..69372d63 100644 --- a/services/lib/tests/test_landfire_config.py +++ b/services/lib/tests/test_landfire_config.py @@ -22,6 +22,7 @@ def test_default_is_always_in_available(self): def test_fbfm13_versions(self): assert LANDFIRE_VERSIONS["fbfm13"]["available"] == ["2023", "2024"] + assert LANDFIRE_VERSIONS["fbfm13"]["lfps_available"] == ["2025"] assert LANDFIRE_VERSIONS["fbfm13"]["default"] == "2024" def test_fbfm40_versions(self): @@ -37,6 +38,7 @@ def test_fbfm40_versions(self): def test_fccs_versions(self): assert LANDFIRE_VERSIONS["fccs"]["available"] == ["2023"] + assert LANDFIRE_VERSIONS["fccs"]["lfps_available"] == ["2025"] assert LANDFIRE_VERSIONS["fccs"]["default"] == "2023"