Source code for fresh_fvsbc.bec

"""The BC Biogeoclimatic Ecosystem Classification (BEC) code, validated.

``canada/bc/initre.f`` reads a BC stand's BEC from the **first two fields of
``STDINFO``**, concatenates them, and passes the result to ``canada/bc/habtyp.f``.
``HABTYP`` looks for a region token, a zone token, a two-letter subzone token, an
optional subzone variant, a slash, and a site-series code. If any part is
missing it calls its error path and leaves the BEC at the documented default,
``ICHmw2/01``.

That default is the hazard this module closes. A caller who writes
``STDINFO=712,CHS131,...`` is not running an IDF or SBS stand; FVS is running
``ICHmw2/01`` with a maximum basal area of 89 m2/ha. Nothing in the run says so.
This module parses the two ``STDINFO`` fields before a run starts, refuses a code
that would not resolve, and names the accepted grammar and the supported zones.

The source is the authority
----------------------------

The accepted regions are ``CAR``, ``KAM``, and ``NEL``; the zones are ``ESSF``,
``ICH``, ``IDF``, ``MS``, ``PP``, ``SBS``, ``SBPS``, ``CWH``, ``CDF``, ``BWBS``,
``CMA``, and ``MH``; and the subzones are the two-letter list in ``HABTYP``. The
zone list is also the list ``sitset.f`` assigns default maximum basal area for.
The five prototype zones are carried by the W2.5 extension; a build without that
extension echoes a different BEC and the run refuses it.

The grammar is the working form the FVS source accepts, made explicit::

    REGION ZONE SUBZONE[VARIANT]/SERIES

``IDFdk3/01`` is a zone ``IDF``, subzone ``dk``, variant ``3``, series ``01``.
``IDFxm/01`` has no variant. The series is mandatory: ``CAR IDFdk3`` alone does
not resolve, because ``HABTYP`` requires the slash.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

__all__ = [
    "REGIONS",
    "SUBZONES",
    "SUPPORTED_ZONES",
    "UNSUPPORTED_ZONES",
    "BecCode",
    "BecCodeError",
]

#: The region tokens ``HABTYP`` accepts, in its order.
REGIONS: tuple[str, ...] = ("CAR", "KAM", "NEL")

#: The BEC zones the BC variant resolves, in ``HABTYP``'s order.
SUPPORTED_ZONES: tuple[str, ...] = (
    "ESSF",
    "ICH",
    "IDF",
    "MS",
    "PP",
    "SBS",
    "SBPS",
    "CWH",
    "CDF",
    "BWBS",
    "CMA",
    "MH",
)

#: Retained for callers that imported the old refusal list. Empty after the W2.5
#: extension added the five prototype zones.
UNSUPPORTED_ZONES: tuple[str, ...] = ()

#: The two-letter subzone tokens ``HABTYP`` accepts, without duplicates. The source
#: lists ``WM`` twice; a set does not need the duplicate.
SUBZONES: tuple[str, ...] = (
    "DC",
    "DK",
    "DM",
    "DW",
    "MC",
    "MH",
    "MK",
    "MM",
    "MW",
    "VK",
    "WC",
    "WK",
    "WM",
    "WW",
    "XC",
    "XH",
    "XK",
    "XM",
    "DH",
    "DV",
    "MV",
    "VC",
    "WH",
    "WV",
    "XV",
    "XW",
    "XX",
    "UN",
    "DS",
    "MS",
    "VH",
    "VM",
    "WS",
)

_ZONE_PATTERN: str = "|".join(SUPPORTED_ZONES)
_SUBZONE_PATTERN: str = "|".join(SUBZONES)
_SERIES_PATTERN: str = r"[A-Z0-9-]{1,5}"
_PATTERN: re.Pattern[str] = re.compile(
    rf"^(?P<region>{'|'.join(REGIONS)})(?P<zone>{_ZONE_PATTERN})"
    rf"(?P<subzone>{_SUBZONE_PATTERN})(?P<variant>[A-Z0-9])?/(?P<series>{_SERIES_PATTERN})$",
    re.IGNORECASE,
)

_SUBZONE_RE: re.Pattern[str] = re.compile(rf"^(?:{_SUBZONE_PATTERN})[A-Z0-9]?$")
_SERIES_RE: re.Pattern[str] = re.compile(rf"^{_SERIES_PATTERN}$")


[docs] class BecCodeError(ValueError): """A BEC code that FVS-BC would not resolve, or a set that does not carry one."""
[docs] @dataclass(frozen=True, slots=True) class BecCode: """A validated BC BEC code. Parameters ---------- region : str The region token: ``CAR``, ``KAM``, or ``NEL``. zone : str The BEC zone: ``ESSF``, ``ICH``, ``IDF``, ``MS``, ``PP``, ``SBS``, or ``SBPS``. subzone : str The lowercase subzone and optional variant, such as ``dk3`` or ``xm``. series : str The lowercase site-series code, such as ``01`` or ``01-yc``. Raises ------ BecCodeError If a field is outside the source's lists. :meth:`parse` is the normal way to build one, because it reads a caller's code and reports what is wrong. """ region: str zone: str subzone: str series: str def __post_init__(self) -> None: """Check that every field is one the source can carry. Raises ------ BecCodeError If the region, zone, subzone, or series is outside the accepted set. """ if self.region not in REGIONS: raise BecCodeError(f"BEC region {self.region!r} is not one of {', '.join(REGIONS)}") if self.zone not in SUPPORTED_ZONES: raise BecCodeError(f"BEC zone {self.zone!r} is not one of {', '.join(SUPPORTED_ZONES)}") if _SUBZONE_RE.fullmatch(self.subzone.upper()) is None: raise BecCodeError( f"BEC subzone {self.subzone!r} is not one of {', '.join(SUBZONES)} " "with an optional one-character variant" ) if _SERIES_RE.fullmatch(self.series.upper()) is None: raise BecCodeError( f"BEC series {self.series!r} is not one to five letters, digits, or hyphens" ) @property def pretty(self) -> str: """Return the zone, subzone, slash, and series, such as ``IDFdk3/01``. Returns ------- str The second ``STDINFO`` field's canonical form. """ return f"{self.zone}{self.subzone}/{self.series}" @property def fields(self) -> tuple[str, str]: """Return the two ``STDINFO`` fields that carry this BEC. Returns ------- tuple of str The region and the ``zone+subzone/series`` string, in that order. """ return self.region, self.pretty def __str__(self) -> str: """Return the code in the working ``REGION ZONE/SUBSERIES`` form.""" return f"{self.region} {self.pretty}"
[docs] @classmethod def from_fields(cls, first: str, second: str) -> BecCode: """Parse the first two ``STDINFO`` fields. ``HABTYP`` concatenates the fields before it parses them, so this method does the same. The working form puts the region in the first field and the rest in the second: ``STDINFO CAR IDFdk3/01``. Parameters ---------- first : str The first ``STDINFO`` field, normally the region. second : str The second ``STDINFO`` field, normally ``ZONE+SUBZONE/SERIES``. Returns ------- BecCode The validated code. Raises ------ BecCodeError If the concatenated fields do not parse, or if the zone is one the variant does not represent. """ return cls.parse(f"{first}{second}")
[docs] @classmethod def parse(cls, text: str) -> BecCode: """Parse a BEC code and report why it would not resolve. The parser mirrors ``HABTYP``'s accepted grammar, but it is anchored: the region, zone, subzone, slash, and series must be the whole code. Whitespace is removed first, so ``CAR IDFdk3/01`` and ``CARIDFdk3/01`` are the same code. Parameters ---------- text : str A code such as ``CAR IDFdk3/01``, ``CARIDFdk3/01``, or the legacy ``712CHS131`` that the package refuses. Returns ------- BecCode The validated code, with the region and zone upper-case and the subzone and series lower-case. Raises ------ BecCodeError If the code is malformed, if its zone is not represented by the BC variant, or if its series is missing. """ compact = "".join(text.split()) match = _PATTERN.fullmatch(compact) if match is not None: variant = match.group("variant") or "" return cls( region=match.group("region").upper(), zone=match.group("zone").upper(), subzone=f"{match.group('subzone')}{variant}".lower(), series=match.group("series").lower(), ) raise BecCodeError( f"{text!r} is not a BC BEC code. Write the region in the first STDINFO field " f"and the zone, subzone, and site series in the second, as in " f"'CAR IDFdk3/01'. The regions are {', '.join(REGIONS)} and the zones are " f"{', '.join(SUPPORTED_ZONES)}. Without a valid code FVS silently uses " "ICHmw2/01" )