diff --git a/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py b/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py index 42df6f9..6580e6e 100644 --- a/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py @@ -21,18 +21,20 @@ class CreateLeafluxIrradianceRequest: grid's geometry, so there is no resolution input. Attributes: - source_grid_id (str): ID of a completed 3D grid that has a `leaf_area_density` band. + source_lad_grid_id (str): ID of the completed 3D fuel grid whose `leaf_area_density` (LAD) band drives the Beer- + Lambert light attenuation. This is the primary input the irradiance field is computed from. Named for the band + it consumes so it reads unambiguously alongside `source_terrain_grid_id`. date_time (datetime.datetime): UTC instant at which to compute irradiance. name (str | Unset): Default: ''. description (str | Unset): Default: ''. tags (list[str] | Unset): - source_terrain_grid_id (None | str | Unset): (optional) 2D terrain grid in the same domain, used for surface - irradiance. + source_terrain_grid_id (None | str | Unset): (optional) ID of a completed 2D terrain grid (with an `elevation` + band) in the same domain, used to drape the surface irradiance band over real terrain instead of a flat plane. bands (list[LeafluxBand] | Unset): Which output bands to produce. Defaults to `irradiance.surface.relative`. extinction_coefficient (float | Unset): Beer-Lambert extinction coefficient (leaflux `extn`). Default: 0.5. """ - source_grid_id: str + source_lad_grid_id: str date_time: datetime.datetime name: str | Unset = "" description: str | Unset = "" @@ -42,7 +44,7 @@ class CreateLeafluxIrradianceRequest: extinction_coefficient: float | Unset = 0.5 def to_dict(self) -> dict[str, Any]: - source_grid_id = self.source_grid_id + source_lad_grid_id = self.source_lad_grid_id date_time = self.date_time.isoformat() @@ -73,7 +75,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update( { - "source_grid_id": source_grid_id, + "source_lad_grid_id": source_lad_grid_id, "date_time": date_time, } ) @@ -95,7 +97,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) - source_grid_id = d.pop("source_grid_id") + source_lad_grid_id = d.pop("source_lad_grid_id") date_time = datetime.datetime.fromisoformat(d.pop("date_time")) @@ -128,7 +130,7 @@ def _parse_source_terrain_grid_id(data: object) -> None | str | Unset: extinction_coefficient = d.pop("extinction_coefficient", UNSET) create_leaflux_irradiance_request = cls( - source_grid_id=source_grid_id, + source_lad_grid_id=source_lad_grid_id, date_time=date_time, name=name, description=description, diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 07dd0a9..137e67d 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -3,6 +3,7 @@ """ # Core imports +import datetime import json import re from collections.abc import Mapping @@ -30,6 +31,7 @@ create_landfire_fbfm40, create_landfire_fccs, create_landfire_topography, + create_leaflux_irradiance_grid, create_meta_chm, create_naip_chm, create_netcdf_upload, @@ -64,6 +66,7 @@ CreateLandfireFbfm40Request, CreateLandfireFccsRequest, CreateLandfireTopographyRequest, + CreateLeafluxIrradianceRequest, CreateMetaChmRequest, CreateNaipChmRequest, CreateNetcdfUploadRequest, @@ -94,6 +97,7 @@ LandfireFbfm40Version, LandfireFccsVersion, LandfireTopographyVersion, + LeafluxBand, ListGridsResponse, MetaCHMVersion, NonBurnableFuelModel, @@ -137,6 +141,7 @@ "create_fuel_grid_from_fccs_lookup", "create_fuel_grid_from_fbfm13_lookup", "create_fuel_grid_from_fbfm40_lookup", + "create_irradiance_grid_from_leaflux", "list_grids", "get_grid", "check_3dep_coverage", @@ -148,6 +153,11 @@ def _domain_id(domain) -> str: return getattr(domain, "id", domain) +def _grid_id(grid) -> str: + """Resolve a Grid object or a grid-id string to the id string.""" + return getattr(grid, "id", grid) + + def _enum_list(values, enum_cls): """Coerce a list of strings/enum members to enum members, or UNSET.""" if values is None: @@ -2190,6 +2200,98 @@ def _duet_integer(name: str, value, *, minimum: int, maximum: int) -> int: return value +def create_irradiance_grid_from_leaflux( + source_grid, + date_time: datetime.datetime, + source_terrain_grid=None, + bands: Optional[list] = None, + extinction_coefficient: float = 0.5, + domain=None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Grid: + """Create a 3D LeafLux solar-irradiance grid from a source fuel grid. + + Computes solar irradiance through the canopy at a single instant, attenuating + light through the source grid's leaf area density with a Beer-Lambert + extinction coefficient. An optional 2D terrain grid supplies surface + elevation for the surface-irradiance band. + + Parameters + ---------- + source_grid : Grid or str + A completed 3D grid (or its id) carrying a ``leaf_area_density`` band. + date_time : datetime.datetime + The UTC instant at which to compute irradiance. + source_terrain_grid : Grid or str, optional + A 2D terrain grid (or its id) in the same domain, used for the + surface-irradiance band. + bands : list, optional + Irradiance bands to produce (``LeafluxBand`` members or their string + keys, e.g. "irradiance.surface.relative", + "irradiance.canopy.relative"). Defaults to + ``irradiance.surface.relative``. + extinction_coefficient : float, optional + Beer-Lambert extinction coefficient applied to the leaf area density + (default 0.5). + domain : Domain or str, optional + The domain (or its id) to create the grid in. Required only when + ``source_grid`` is given as an id; inferred from the grid otherwise. + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + + Returns + ------- + Grid + The new pending irradiance Grid. + + Raises + ------ + ValueError + If ``source_grid`` is a Grid that is not completed or lacks a + ``leaf_area_density`` band, or if ``domain`` is required but not given. + """ + if isinstance(source_grid, Grid): + source_grid._require_completed("create a LeafLux irradiance grid from") + band_keys = [band.key for band in source_grid.bands] + if "leaf_area_density" not in band_keys: + raise ValueError( + f"Grid {source_grid.id} has no 'leaf_area_density' band; pass a " + f"3D grid with leaf area density. Available bands: {band_keys}." + ) + + domain_id = ( + _domain_id(domain) + if domain is not None + else getattr(source_grid, "domain_id", None) + ) + if domain_id is None: + raise ValueError( + "Pass source_grid as a Grid, or supply domain= when source_grid is " + "an id." + ) + + request_body = CreateLeafluxIrradianceRequest( + source_lad_grid_id=_grid_id(source_grid), + date_time=date_time, + source_terrain_grid_id=( + _grid_id(source_terrain_grid) if source_terrain_grid is not None else UNSET + ), + bands=_enum_list(bands, LeafluxBand), + extinction_coefficient=extinction_coefficient, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_leaflux_irradiance_grid.sync_detailed( + domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + # --------------------------------------------------------------------------- # Listing, fetching, and utilities # --------------------------------------------------------------------------- diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 7b1e01e..7abc5d9 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -3,6 +3,7 @@ """ # Core imports +import datetime import inspect import json import math @@ -33,6 +34,7 @@ create_fuel_model_grid_from_landfire_fbfm40, create_fuel_model_grid_from_landfire_fccs, create_grid_from_geotiff, + create_irradiance_grid_from_leaflux, create_pim_grid_from_treemap, create_surface_fuel_grid_from_duet, create_topography_grid_from_3dep, @@ -60,6 +62,7 @@ GridModificationCondition, GridSource, JobStatus, + LeafluxBand, Modifier, Operator, PointCloudType, @@ -68,7 +71,10 @@ UploadBandDefinition, ) from fastfuels_sdk.v2.client_library.types import UNSET, Response -from fastfuels_sdk.v2.exceptions import NotFoundException, expect +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + expect, +) from fastfuels_sdk.v2.modifications import mask # External imports @@ -492,6 +498,165 @@ def test_create_live(self, completed_tree_inventory): voxels.delete() +class TestCreateIrradianceGridFromLeaflux: + @staticmethod + def _source_grid(status=JobStatus.COMPLETED, omit=False): + bands = ( + [] + if omit + else [Band(key="leaf_area_density", type_=BandType.CONTINUOUS, index=0)] + ) + return Grid( + id="lad-grid-id", + domain_id="domain-id", + status=status, + source=GridSource(), + bands=bands, + ) + + @staticmethod + def _patch(monkeypatch, captured): + created = Grid( + id="irradiance-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_leaflux_irradiance_grid, + "sync_detailed", + fake_create, + ) + return client + + def test_builds_request_from_completed_grid(self, monkeypatch): + captured = {} + client = self._patch(monkeypatch, captured) + when = datetime.datetime(2024, 7, 1, 18, 30, tzinfo=datetime.timezone.utc) + + grid = create_irradiance_grid_from_leaflux( + self._source_grid(), + date_time=when, + source_terrain_grid="terrain-grid-id", + bands=[ + "irradiance.canopy.relative", + LeafluxBand.IRRADIANCE_SURFACE_RELATIVE, + ], + extinction_coefficient=0.7, + name="leaflux irradiance", + tags=["test"], + ) + + assert grid.id == "irradiance-grid-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert captured["body"].source_lad_grid_id == "lad-grid-id" + assert captured["body"].date_time == when + assert captured["body"].source_terrain_grid_id == "terrain-grid-id" + assert captured["body"].bands == [ + LeafluxBand.IRRADIANCE_CANOPY_RELATIVE, + LeafluxBand.IRRADIANCE_SURFACE_RELATIVE, + ] + assert captured["body"].extinction_coefficient == 0.7 + assert captured["body"].name == "leaflux irradiance" + assert captured["body"].tags == ["test"] + + def test_defaults_leave_bands_and_terrain_unset(self, monkeypatch): + captured = {} + self._patch(monkeypatch, captured) + + create_irradiance_grid_from_leaflux( + self._source_grid(), + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + assert captured["body"].bands is UNSET + assert captured["body"].source_terrain_grid_id is UNSET + assert captured["body"].extinction_coefficient == 0.5 + + def test_accepts_ids_with_explicit_domain(self, monkeypatch): + captured = {} + self._patch(monkeypatch, captured) + + create_irradiance_grid_from_leaflux( + "lad-grid-id", + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + source_terrain_grid=SimpleNamespace(id="terrain-grid-id"), + domain=SimpleNamespace(id="domain-id"), + ) + + assert captured["domain_id"] == "domain-id" + assert captured["body"].source_lad_grid_id == "lad-grid-id" + assert captured["body"].source_terrain_grid_id == "terrain-grid-id" + + def test_requires_domain_for_id_source(self): + with pytest.raises(ValueError, match="domain="): + create_irradiance_grid_from_leaflux( + "lad-grid-id", + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + def test_requires_completed_source_grid(self): + with pytest.raises(ValueError, match=r"Call \.wait\(\)"): + create_irradiance_grid_from_leaflux( + self._source_grid(status=JobStatus.PENDING), + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + def test_requires_leaf_area_density_band(self): + with pytest.raises(ValueError, match="leaf_area_density"): + create_irradiance_grid_from_leaflux( + self._source_grid(omit=True), + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + def test_create_live(self, completed_tree_inventory): + voxels = completed_tree_inventory.voxelize( + horizontal_resolution_m=2, + vertical_resolution_m=1, + bands=["leaf_area_density"], + name="test_leaflux_source", + tags=["test"], + ) + irradiance = None + try: + voxels.wait() + irradiance = create_irradiance_grid_from_leaflux( + voxels, + date_time=datetime.datetime( + 2024, 7, 1, 18, 0, tzinfo=datetime.timezone.utc + ), + bands=["irradiance.canopy.relative"], + name="test_leaflux_irradiance", + tags=["test"], + ) + irradiance.wait() + assert irradiance.status == JobStatus.COMPLETED + assert "irradiance.canopy.relative" in { + band.key for band in irradiance.bands + } + values = irradiance.to_numpy("irradiance.canopy.relative") + assert values.ndim == 3 + assert np.isfinite(values).any() + finally: + if irradiance is not None: + irradiance.delete() + voxels.delete() + + class TestCreateFuelModelGridFromLandfireFbfm40: def test_create(self, test_domain): # remove_non_burnable exercises the string -> enum list coercion