Source code for fhops.scenario.contract.tree

"""Scenario reference to an individual-tree inventory.

The inventory validator lives in :mod:`fhops.tree_inventory`; this model records
the stable reference and provenance that a planner keeps beside a scenario. The
section is optional, so scenarios without tree-scale data remain valid.
"""

from __future__ import annotations

import re
from typing import Literal

from pydantic import BaseModel, Field, field_validator, model_validator

__all__ = ["TreeInventoryRef"]

TreeInventoryReviewStatus = Literal[
    "not_reviewed",
    "review_pending",
    "reviewed",
    "reviewed_with_conditions",
]
_CHECKSUM = re.compile(r"^[0-9a-fA-F]{64}$")


[docs] class TreeInventoryRef(BaseModel): """A scenario-level reference to one validated tree inventory. The inventory is referenced, not copied. ``inventory_sha256`` and ``metadata_sha256`` pin the CSV and metadata bytes; the remaining fields preserve identity, CRS, review, and producer provenance. Attributes ---------- inventory_id : str Stable inventory identifier from the metadata. inventory_sha256 : str SHA-256 digest of the inventory CSV. metadata_sha256 : str SHA-256 digest of the metadata JSON. inventory_path : str Inventory CSV path, relative to the scenario when possible. metadata_path : str Metadata JSON path, relative to the scenario when possible. crs : str Inventory coordinate reference system. review_status : str ``not_reviewed``, ``review_pending``, ``reviewed``, or ``reviewed_with_conditions``. reviewed_by : str | None Reviewer identifier when reviewed. source_id : str Producer source identifier. producer : str Producer name. method : str Detection/segmentation method. tree_count : int Number of tree records. block_ids : list[str] Declared block memberships. caveats : list[str] Inventory caveats. """ inventory_id: str = Field(min_length=1) inventory_sha256: str metadata_sha256: str inventory_path: str = Field(min_length=1) metadata_path: str = Field(min_length=1) crs: str = Field(min_length=1) review_status: TreeInventoryReviewStatus = "not_reviewed" reviewed_by: str | None = None source_id: str = Field(min_length=1) producer: str = Field(min_length=1) method: str = Field(min_length=1) tree_count: int = Field(ge=0) block_ids: list[str] = Field(default_factory=list) caveats: list[str] = Field(default_factory=list) @field_validator( "inventory_id", "inventory_path", "metadata_path", "crs", "source_id", "producer", "method" ) @classmethod def _not_blank(cls, value: str) -> str: if not value.strip(): raise ValueError("TreeInventoryRef fields must be non-empty") return value.strip() @field_validator("inventory_sha256", "metadata_sha256") @classmethod def _checksum(cls, value: str) -> str: if not _CHECKSUM.fullmatch(value): raise ValueError("TreeInventoryRef checksums must be SHA-256 digests") return value.lower() @model_validator(mode="after") def _review_and_blocks(self) -> TreeInventoryRef: if self.review_status in {"reviewed", "reviewed_with_conditions"} and not self.reviewed_by: raise ValueError("Reviewed TreeInventoryRef requires reviewed_by") if len(self.block_ids) != len(set(self.block_ids)): raise ValueError("TreeInventoryRef block_ids must be unique") return self