"""The FVS-BC keyword-set validator.
``docs/reference/keywords.rst`` is the contract. This module holds FVS's keyword table as it is
declared in ``base/keywds.f``, validates a keyword set against it, and gives the set a hash.
What the table is
-----------------
``TABLE`` is a ``CHARACTER*8`` array of 150 slots, declared in ``base/keywds.f`` for the base model;
BC adds nothing to it. Six of the slots are blank, and a blank slot is not a keyword, so the table
holds 144 named keywords.
``STOP`` is not in the table, and is still a keyword
----------------------------------------------------
``base/keyrdr.f`` reads each record, upcases its first eight characters, and compares them with
``STOP`` *before* it consults the table; a match ends the file with code 3. So ``STOP`` is a keyword
that ``keywds.f`` does not hold, and it is in :data:`SPECIAL_KEYWORDS` rather than in the table.
The run orchestration found this the first time it wrote a whole key file: every real key file ends
with ``STOP``, and a validator that refused it refused all of them.
Matching is FVS's own. ``base/fndkey.f`` compares the caller's word to the table with FORTRAN's
``.EQ.`` on character strings, which is an exact, case-sensitive, blank-padded comparison. A
validator that accepted ``database`` would therefore accept something FVS does not, so this one does
not either; it rejects the name and points at the table's spelling when there is one close.
The misspelling
---------------
Slot 30 holds ``xSALVAGE``, with a leading ``x``, which is a spelling defect in FVS itself. FVS
matches what the table holds, so ``xSALVAGE`` is the *correct* spelling and ``SALVAGE`` is not a
keyword at all. The validator warns when a set contains ``xSALVAGE``, because a caller who reached
for that name is working around a defect they should know about, and it names ``xSALVAGE`` when a
caller writes ``SALVAGE`` instead.
What this module does not do
----------------------------
Contract rule 4, that a set never silently gains a keyword the caller did not write, is not enforced
here. The one exception the contract allows is the tree-record format, which is a property of how a
run is set up rather than of validation, and it is handled where the run is.
"""
from __future__ import annotations
import hashlib
from collections.abc import Sequence
from dataclasses import dataclass, field
from fresh_fvsbc.bec import BecCode, BecCodeError
__all__ = [
"BLANK_SLOTS",
"BLOCK_BODIES",
"BLOCK_KEYWORDS",
"BODY_KEYWORDS",
"ESTAB_KEYWORDS",
"FIELD_WIDTH",
"FIRE_MODEL_ORDER_WARNING",
"FMIN_KEYWORDS",
"KEYWORDS",
"KEYWORD_BLOCK_KEYWORDS",
"KEYWORD_TABLE",
"LINES_KEYWORDS",
"MISSPELLED_KEYWORD",
"MISTOE_KEYWORDS",
"NESTED_KEYWORDS",
"RDIN_KEYWORDS",
"RECORD_KEYWORDS",
"SPECIAL_KEYWORDS",
"STAND_KEYWORDS",
"SUPPLEMENTAL_KEYWORDS",
"Keyword",
"KeywordOrderError",
"KeywordSet",
"KeywordWidthError",
"UnknownKeywordError",
"UnsupportedKeywordError",
"keyword_set_hash",
"render_keyword",
"render_keywords",
"validate_keyword",
"validate_keywords",
]
#: FVS's keyword table, ``TABLE`` in ``base/keywds.f``, in slot order. ``None`` is a blank slot, and
#: a blank slot is not a keyword. Names are stored as ``CHARACTER*8`` compares them, without the
#: trailing blanks that pads them.
KEYWORD_TABLE: tuple[str | None, ...] = (
# slots 1 to 20
"PROCESS",
"TIMEINT",
"FIXCW",
"TREEDATA",
"TREEFMT",
"MPB",
"DFTM",
"WSBW",
"CWEQN",
"DESIGN",
"NUMCYCLE",
"TFIXAREA",
"GROWTH",
"STDINFO",
"STDIDENT",
"INVYEAR",
"TREELIST",
"REWIND",
"NOSUM",
"DEBUG",
# slots 21 to 40
"ECHOSUM",
"ADDFILE",
"THINAUTO",
"THINBTA",
"THINATA",
"THINBBA",
"THINABA",
"THINPRSC",
"THINDBH",
"xSALVAGE",
"SPLABEL",
"AGPLABEL",
"COMPUTE",
"FERTILIZ",
"THINHT",
"STATS",
"TOPKILL",
"HTGSTOP",
"MCFDLN",
"BFFDLN",
# slots 41 to 60
"MCDEFECT",
"BFDEFECT",
"VOLUME",
"BFVOLUME",
"REGDMULT",
"COVER",
"ESTAB",
"MINHARV",
"SPECPREF",
"SPCODES",
"NODEBUG",
"CUTEFF",
"NOTRIPLE",
"READCORD",
"REUSCORD",
"NOCALIB",
"DGSTDEV",
"BAIMULT",
"MORTMULT",
"NOHTDREG",
# slots 61 to 80
"RANNSEED",
"HTGMULT",
"CHEAPO",
"NUMTRIP",
"ENDFILE",
"BAMAX",
"READCORH",
"REUSCORH",
"MGMTID",
"REGHMULT",
"TCONDMLT",
"NOAUTOES",
"READCORR",
"REUSCORR",
"BRUST",
"IF",
"SCREEN",
"COMPRESS",
"THEN",
"ALSOTRY",
# slots 81 to 100
"ENDIF",
"NOTREES",
"CALBSTAT",
"OPEN",
"CLOSE",
"NOSCREEN",
"RRIN",
"FIXMORT",
"SDIMAX",
"DELOTAB",
"SERLCORR",
"CUTLIST",
"RESETAGE",
"SITECODE",
"MISTOE",
"CRNMULT",
"CFVOLEQU",
"BFVOLEQU",
"ANIN",
"DFB",
# slots 101 to 120, which hold three blank slots
"RDIN",
"MANAGED",
"YARDLOSS",
"FMIN",
"STRCLASS",
"MODTYPE",
"FVSSTAND",
"PRUNE",
"SVS",
"FIXDG",
"FIXHTG",
"THINSDI",
"LOCATE",
None,
"THINCC",
"ECON",
"DATABASE",
None,
"DEFECT",
None,
# slots 121 to 135
"STANDCN",
"THINMIST",
"TREESZCP",
"THINRDEN",
"SPGROUP",
"BMIN",
"DATASCRN",
"SETPTHIN",
"THINPT",
"VOLEQNUM",
"POINTREF",
"ECHO",
"NOECHO",
"CYCLEAT",
"ATRTLIST",
# slots 136 to 150, which hold three blank slots
"THINRDSL",
"MORTMSB",
"SETSITE",
"CLIMATE",
"SDICALC",
"THINQFA",
"PTGROUP",
"ORGANON",
"SPLEAVE",
"CCADJ",
"PRMFROST",
"FIAVBC",
None,
None,
None,
)
#: The named keywords, as a set for lookup.
KEYWORDS: frozenset[str] = frozenset(name for name in KEYWORD_TABLE if name is not None)
#: The 1-based slot numbers that hold no keyword.
BLANK_SLOTS: tuple[int, ...] = tuple(
slot for slot, name in enumerate(KEYWORD_TABLE, start=1) if name is None
)
#: The keywords FVS matches before it consults the table. ``base/keyrdr.f`` compares the record's
#: first eight characters with ``STOP`` and returns code 3, which ends the keyword file, so ``STOP``
#: is a keyword that ``base/keywds.f`` does not hold. A validator that refused it would refuse every
#: real key file, which is what the run orchestration found the first time it wrote one.
SPECIAL_KEYWORDS: frozenset[str] = frozenset({"STOP", "COMMENT"})
#: The width of a parameter field. ``base/keyrdr.f`` reads ``KARD(I)=RECORD(J:J+9)`` with ``J``
#: stepping by ten from eleven, so every parameter is a ten-column field of the keyword's own record
#: and a longer one cannot be carried.
FIELD_WIDTH: int = 10
#: The keywords whose content is a record of its own rather than a field of the keyword's record.
#: ``canada/bc/initre.f`` reads the record after ``STDIDENT`` as the stand id and title, and
#: ``intree.f`` reads the two after ``TREEFMT`` as the format.
RECORD_KEYWORDS: frozenset[str] = frozenset({"STDIDENT", "TREEFMT"})
#: The keywords whose content is a block of **text lines** ended by ``END``. A caller supplies the
#: lines, and the writer supplies the terminator, because a block without one swallows the rest of
#: the file.
LINES_KEYWORDS: frozenset[str] = frozenset({"COMMENT", "COMPUTE", "DATABASE"})
#: The keywords whose block holds **other keywords** rather than lines: ``IF`` opens a conditional
#: whose body is a sequence of keywords and whose terminator is ``ENDIF``. That is a nested
#: structure this model does not have, so it is refused by name rather than written wrongly.
#: Keywords that read **one extra record** after their own. ``THINQFA`` is the one in this
#: variant: its own record carries the year and six parameters, and the record that follows names
#: the units of its residual-density target. ``cuts.f`` reads ``PRMS(1..6)`` from the six post-year
#: fields, which leaves ``QFATAR = INT(PRMS(7))`` for the supplemental record's value; the keyword
#: guide says the same.
SUPPLEMENTAL_KEYWORDS: frozenset[str] = frozenset({"THINQFA", "FUELMODL", "DEFULMOD"})
NESTED_KEYWORDS: frozenset[str] = frozenset({"IF"})
#: The fire-model keywords ``FMIN`` reads, from ``metric/fire/base/fmin.f``'s own table. They are
#: **not** top-level keywords: FVS accepts them only between ``FMIN`` and its ``END``, so the
#: validator refuses one that stands alone rather than writing a record FVS would ignore.
FMIN_KEYWORDS: frozenset[str] = frozenset(
{
"BURNREPT",
"CANCALC",
"CANFPROF",
"CARBCALC",
"CARBCUT",
"CARBREPT",
"CFIMCALC",
"DEFULMOD",
"DROUGHT",
"DUFFPROD",
"DWDCVOUT",
"DWDVLOUT",
"FIRECALC",
"FLAMEADJ",
"FMODLIST",
"FMORTMLT",
"FUELDCAY",
"FUELFOTO",
"FUELINIT",
"FUELMODL",
"FUELMOVE",
"FUELMULT",
"FUELOUT",
"FUELPOOL",
"FUELREPT",
"FUELSOFT",
"FUELTRET",
"LANDOUT",
"MOISTURE",
"MOREOUT",
"MORTCLAS",
"MORTREPT",
"PILEBURN",
"POTFIRE",
"POTFMOIS",
"POTFPAB",
"POTFSEAS",
"POTFTEMP",
"POTFWIND",
"SALVAGE",
"SALVSP",
"SIMFIRE",
"SNAGBRK",
"SNAGCLAS",
"SNAGDCAY",
"SNAGFALL",
"SNAGINIT",
"SNAGOUT",
"SNAGPBN",
"SNAGPSFT",
"SNAGSUM",
"SOILHEAT",
"STATFUEL",
"SVIMAGES",
"USECFIM",
}
)
#: The dwarf-mistletoe keywords ``MISTOE`` reads, from ``newmist/misin.f``'s own table.
MISTOE_KEYWORDS: frozenset[str] = frozenset(
{
"DMBCA",
"DMBCI",
"DMCAP",
"DMCLUMP",
"DMCRTHRD",
"DMDEATH",
"DMETUNE",
"DMFLOWER",
"DMITUNE",
"DMKTUNE",
"DMLIGHT",
"DMOPAQ",
"DMOPQMLT",
"DMSED",
"DMSTUNE",
"DMTABLE",
"DMAUTO",
"MISTABLE",
"MISTGMOD",
"MISTHMOD",
"MISTMORT",
"MISTMULT",
"MISTOFF",
"MISTPINF",
"MISTPREF",
"MISTPRT",
"NEWSPRED",
}
)
#: The root-disease keywords ``RDIN`` reads, from ``rd/rdinie.f``'s own table. ``--RDv3--`` is a
#: separator in that table rather than a keyword.
RDIN_KEYWORDS: frozenset[str] = frozenset(
{
"BBOUT",
"BBCLEAR",
"BORATE",
"BBTYPE1",
"BBTYPE2",
"BBTYPE3",
"BBTYPE4",
"CARRY",
"COMMENT",
"DNSCALC",
"INFKILL",
"INFCOLO",
"INFMULT",
"INFSIMS",
"INOCLIFE",
"INOCSPAN",
"OPEN",
"PLOTINF",
"PLREAD",
"PSTUMP",
"RRCOMP",
"RRDOUT",
"RRECHO",
"RRHOSTS",
"RRINIT",
"RRJUMP",
"RRMINK",
"RRTREIN",
"RRTYPE",
"RSEED",
"SAREA",
"SDIRMULT",
"SMCOUT",
"SPORE",
"SPREAD",
"STREAD",
"TDISTN",
"TIMEDEAD",
"TTDMULT",
"WINDTHR",
}
)
#: The regeneration keywords ``ESTAB`` reads, from ``estb/esin.f``'s own table. ``ESTAB`` appears in
#: it because the block can schedule another establishment; it is also a top-level keyword.
ESTAB_KEYWORDS: frozenset[str] = frozenset(
{
"ADDTREES",
"AUTALLY",
"BUDWORM",
"BURNPREP",
"ESTAB",
"ESRANSD",
"EZCRUISE",
"HABGROUP",
"HTADJ",
"INGROW",
"MECHPREP",
"MINPLOTS",
"NATURAL",
"NOAUTALY",
"NOINGROW",
"NOSPROUT",
"OUTPUT",
"PASSALL",
"PLANT",
"PLOTINFO",
"RESETAGE",
"SPECMULT",
"SPROUT",
"STOCKADJ",
"TALLY",
"TALLYONE",
"TALLYTWO",
"THRSHOLD",
}
)
#: Each keyword block, and the keywords its reader accepts. The body is rendered with the same
#: writer as any other keyword, so a parameter lands in FVS's ten-column field rather than being
#: hand-spaced — a hand-spaced field is read as a different number and FVS reports nothing.
BLOCK_BODIES: dict[str, frozenset[str]] = {
"ESTAB": ESTAB_KEYWORDS,
"FMIN": FMIN_KEYWORDS,
"MISTOE": MISTOE_KEYWORDS,
"RDIN": RDIN_KEYWORDS,
}
#: The block keywords whose body holds **other keywords** and is closed by ``END``.
KEYWORD_BLOCK_KEYWORDS: frozenset[str] = frozenset(BLOCK_BODIES)
#: Every keyword that is only valid inside a keyword block.
BODY_KEYWORDS: frozenset[str] = frozenset().union(*BLOCK_BODIES.values())
#: Every keyword that takes a block of some kind.
BLOCK_KEYWORDS: frozenset[str] = LINES_KEYWORDS | NESTED_KEYWORDS | KEYWORD_BLOCK_KEYWORDS
#: The keywords that start a stand's setup. A ``DATABASE`` block must precede the first of them:
#: FVS refuses it later with ``FVS16 ERROR: KEYWORD ENTERED IS USED IN WRONG CONTEXT``, which is
#: what the official case avoids by putting the block first.
STAND_KEYWORDS: frozenset[str] = frozenset({"STDIDENT", "STDINFO", "STANDSQL"})
#: The keyword whose spelling is defective in FVS, at slot 30.
MISSPELLED_KEYWORD: str = "xSALVAGE"
#: The fire-model keywords whose report gate captures the inventory year. ``FUELOUT`` and
#: ``POTFIRE`` set ``IFLALB`` and ``IPFLMB`` to ``IY(1)`` when they are read, and ``fmdout.f`` and
#: ``fmpofl.f`` treat any positive value as "suppress this report". Read after ``INVYEAR``, they
#: therefore switch their own reports off, and the tables are never written.
YEAR_CAPTURING_KEYWORDS: frozenset[str] = frozenset({"FUELOUT", "POTFIRE"})
#: The warning the validator gives for a fire-model block that follows ``INVYEAR``.
FIRE_MODEL_ORDER_WARNING: str = (
"an FMIN block that asks for FUELOUT or POTFIRE must precede INVYEAR: both capture the "
"inventory year when they are read, and FVS treats a positive value as 'suppress this report', "
"so the fuels and potfire tables would be silently not written"
)
#: The warning the validator gives for :data:`MISSPELLED_KEYWORD`.
MISSPELLING_WARNING: str = (
f'The table spells this "{MISSPELLED_KEYWORD}", with a leading "x", because the defect is in '
f'FVS itself. "SALVAGE" is not a keyword, so this is the spelling FVS matches.'
)
[docs]
class UnknownKeywordError(ValueError):
"""A keyword that FVS's table does not hold."""
[docs]
class KeywordWidthError(ValueError):
"""A parameter that FVS's ten-column field cannot carry."""
[docs]
class UnsupportedKeywordError(ValueError):
"""A keyword whose content is a block, which this module does not write."""
[docs]
class KeywordOrderError(ValueError):
"""A keyword FVS refuses in the position the set gives it."""
def _near_miss(name: str) -> str | None:
"""Return the table's spelling of a name that looks like a near miss, if there is one.
Parameters
----------
name : str
The name a caller wrote, which is not in the table.
Returns
-------
str or None
The table's spelling, when the name differs from it only in case or by the leading ``x``
that FVS's own misspelling carries. ``None`` when nothing in the table is close.
"""
for candidate in (name.upper(), f"x{name}", f"x{name.upper()}"):
if candidate in KEYWORDS:
return candidate
return None
[docs]
@dataclass(frozen=True, slots=True)
class Keyword:
"""One keyword and its parameters, in the order FVS reads them.
Parameters
----------
name : str
The keyword, spelled as FVS's table spells it.
parameters : tuple of str, optional
The values that follow the keyword. FVS reads them as separate tokens, so a parameter may
contain spaces.
lines : tuple of str, optional
The body of a block keyword, one record per entry. Only :data:`LINES_KEYWORDS` take lines,
and the writer adds the ``END`` that closes them.
nested : tuple of Keyword, optional
The body of a **conditional**, which holds keywords rather than lines. Only ``IF`` takes
one, and the writer adds the ``ENDIF`` that closes it.
otherwise : tuple of Keyword, optional
The ``ELSE`` branch of a conditional.
supplemental : tuple of str, optional
The record FVS reads **after** this keyword's own. Only :data:`SUPPLEMENTAL_KEYWORDS` take
one, and the writer does not close it: the record stands alone on its line.
"""
name: str
parameters: tuple[str, ...] = ()
lines: tuple[str, ...] = ()
nested: tuple[Keyword, ...] = ()
otherwise: tuple[Keyword, ...] = ()
supplemental: tuple[str, ...] = ()
#: The residual-density units a ``THINQFA`` supplemental record may name, and what each means.
THINQFA_UNITS: dict[str, str] = {
"0": "basal area per acre",
"1": "trees per acre",
"2": "stand density index",
}
def _validate_supplemental(keyword: Keyword) -> None:
"""Check the one extra record a supplemental keyword reads.
Parameters
----------
keyword : Keyword
The keyword, which is in :data:`SUPPLEMENTAL_KEYWORDS`.
Raises
------
UnsupportedKeywordError
If the record is missing, if there is more than one, or if it does not name a unit. A
missing record is refused rather than written, because FVS would read the **next
keyword** as the units record and the set would be silently wrong.
"""
if len(keyword.supplemental) != 1:
raise UnsupportedKeywordError(
f'"{keyword.name}" reads exactly one supplemental record, and this set gives '
f"{len(keyword.supplemental)}: without it FVS reads the next keyword as that record"
)
if keyword.name in FMIN_KEYWORDS:
# ``FUELMODL`` reads two more numbers and ``DEFULMOD`` seven fuel-model names, and FVS
# parses both itself; only ``THINQFA``'s record has to be one of a fixed set of codes.
return
record = keyword.supplemental[0]
if record not in THINQFA_UNITS:
allowed = ", ".join(f"{code} ({meaning})" for code, meaning in THINQFA_UNITS.items())
raise UnsupportedKeywordError(
f'"{keyword.name}" takes a units code as its supplemental record, one of {allowed}; '
f"this set gives {record!r}"
)
[docs]
def validate_keyword(keyword: Keyword) -> list[str]:
"""Validate one keyword against FVS's table.
Parameters
----------
keyword : Keyword
The keyword and its parameters.
Returns
-------
list of str
The warnings this keyword raises. An empty list means there are none.
Raises
------
UnknownKeywordError
If the name is not in the table. The message names the keyword, and names the table's
spelling when the name is a near miss.
"""
if (keyword.nested or keyword.otherwise) and (
keyword.name not in NESTED_KEYWORDS | KEYWORD_BLOCK_KEYWORDS
):
raise UnsupportedKeywordError(
f'"{keyword.name}" does not take nested keywords; only '
f"{', '.join(sorted(NESTED_KEYWORDS | KEYWORD_BLOCK_KEYWORDS))} does"
)
if keyword.lines and keyword.name not in LINES_KEYWORDS | NESTED_KEYWORDS:
raise UnsupportedKeywordError(
f'"{keyword.name}" does not take lines; only '
f"{', '.join(sorted(LINES_KEYWORDS | NESTED_KEYWORDS))} do"
)
if keyword.supplemental and keyword.name not in SUPPLEMENTAL_KEYWORDS:
raise UnsupportedKeywordError(
f'"{keyword.name}" does not take a supplemental record; only '
f"{', '.join(sorted(SUPPLEMENTAL_KEYWORDS))} does"
)
if keyword.name in SUPPLEMENTAL_KEYWORDS:
_validate_supplemental(keyword)
for block, body in BLOCK_BODIES.items():
if keyword.name in body and keyword.name not in KEYWORDS | SPECIAL_KEYWORDS:
raise KeywordOrderError(
f'"{keyword.name}" is read only inside a {block} block, between {block} and its '
"END; put it in that block's nested keywords"
)
if keyword.name not in KEYWORDS and keyword.name not in SPECIAL_KEYWORDS:
near = _near_miss(keyword.name)
hint = f' The table spells it "{near}".' if near is not None else ""
raise UnknownKeywordError(f'"{keyword.name}" is not a keyword.{hint}')
if keyword.name == "STDINFO":
if len(keyword.parameters) < 2:
raise BecCodeError(
"STDINFO needs a BEC in its first two fields, as in "
"'STDINFO=CAR,IDFdk3/01,...'; without one FVS silently uses ICHmw2/01"
)
BecCode.from_fields(keyword.parameters[0], keyword.parameters[1])
if keyword.name not in RECORD_KEYWORDS:
for parameter in keyword.parameters:
_check_width(keyword.name, parameter)
warnings: list[str] = []
if keyword.name in NESTED_KEYWORDS:
warnings.extend(validate_keywords(keyword.nested))
warnings.extend(validate_keywords(keyword.otherwise))
if keyword.name in KEYWORD_BLOCK_KEYWORDS:
warnings.extend(_validate_block_body(keyword.name, keyword.nested))
if keyword.name == MISSPELLED_KEYWORD:
warnings.append(MISSPELLING_WARNING)
return warnings
def _validate_block_body(block: str, entries: Sequence[Keyword]) -> list[str]:
"""Validate the keywords inside a keyword block.
Parameters
----------
block : str
The block's keyword, whose table the body is checked against.
entries : Sequence of Keyword
The body, in the order FVS reads it.
Returns
-------
list of str
Every warning the body raises. These keywords carry none of their own.
Raises
------
UnknownKeywordError
On a name that is not in the block's table. The message names the table's spelling when the
name is a near miss.
UnsupportedKeywordError
On a block keyword given lines or a nested block, which none of them takes.
KeywordWidthError
On a parameter wider than FVS's ten-column field.
"""
body = BLOCK_BODIES[block]
for entry in entries:
if entry.name not in body:
near = _near_miss(entry.name)
hint = f' The table spells it "{near}".' if near is not None else ""
raise UnknownKeywordError(
f'"{entry.name}" is not a {block} keyword.{hint} A {block} block holds only the '
f"keywords its reader accepts, and {len(body)} are known"
)
if entry.lines or entry.nested or entry.otherwise:
raise UnsupportedKeywordError(
f'"{entry.name}" takes one record inside {block}, and none of them takes lines or '
"a nested block"
)
if entry.supplemental and entry.name not in SUPPLEMENTAL_KEYWORDS:
raise UnsupportedKeywordError(
f'"{entry.name}" takes no supplemental record; only '
f"{', '.join(sorted(SUPPLEMENTAL_KEYWORDS & body))} do"
)
if entry.supplemental:
_validate_supplemental(entry)
for parameter in entry.parameters:
_check_width(entry.name, parameter)
return []
[docs]
def validate_keywords(keywords: Sequence[Keyword]) -> list[str]:
"""Validate a whole keyword set, in order.
Parameters
----------
keywords : Sequence[Keyword]
The keywords, in the order FVS will read them.
Returns
-------
list of str
Every warning the set raises, in the order the keywords were checked.
Raises
------
UnknownKeywordError
On the first keyword whose name is not in the table. Validation stops there rather than
reporting the rest, because a set with an unknown keyword cannot be run.
"""
warnings: list[str] = []
seen_stand = False
seen_inventory = False
for keyword in keywords:
if keyword.name == "INVYEAR":
seen_inventory = True
if keyword.name == "FMIN" and seen_inventory:
captures = any(entry.name in YEAR_CAPTURING_KEYWORDS for entry in keyword.nested)
if captures:
warnings.append(FIRE_MODEL_ORDER_WARNING)
if keyword.name == "DATABASE" and seen_stand:
raise KeywordOrderError(
"a DATABASE block must precede the stand setup; FVS refuses it later with "
"'KEYWORD ENTERED IS USED IN WRONG CONTEXT'. The official case puts it first"
)
if keyword.name in STAND_KEYWORDS:
seen_stand = True
for keyword in keywords:
warnings.extend(validate_keyword(keyword))
return warnings
def _check_width(name: str, parameter: str) -> None:
"""Refuse a parameter that FVS's ten-column field cannot carry.
Parameters
----------
name : str
The keyword, for the message.
parameter : str
The parameter.
Raises
------
KeywordWidthError
If the parameter is wider than :data:`FIELD_WIDTH`.
"""
if len(parameter) > FIELD_WIDTH:
raise KeywordWidthError(
f'"{name}" has a parameter of {len(parameter)} characters, and FVS reads '
f"{FIELD_WIDTH}: {parameter!r}"
)
[docs]
def render_keyword(keyword: Keyword) -> str:
"""Render one keyword as the record FVS reads for it.
FVS reads a keyword from the first eight columns of a record and its parameters from ten-column
fields of the same record, ``KARD(I)=RECORD(J:J+9)`` with ``J`` stepping by ten from eleven.
So a parameter is a field, and the field is padded rather than separated by a delimiter. Two
keywords are the exception and carry their content on the records that follow them:
``STDIDENT`` and ``TREEFMT``.
Parameters
----------
keyword : Keyword
The keyword and its parameters.
Returns
-------
str
The keyword's record, ending with a newline, or the keyword and the records that follow it.
Raises
------
KeywordWidthError
If a parameter is wider than the field FVS reads it from.
"""
if keyword.name in RECORD_KEYWORDS:
return "".join(f"{line}\n" for line in (keyword.name, *keyword.parameters))
fields = []
for parameter in keyword.parameters:
_check_width(keyword.name, parameter)
fields.append(f"{parameter:<{FIELD_WIDTH}}")
head = f"{keyword.name:<{FIELD_WIDTH}}{''.join(fields)}".rstrip() + "\n"
if keyword.name in NESTED_KEYWORDS:
body = "".join(render_keyword(entry) for entry in keyword.nested)
otherwise = ""
if keyword.otherwise:
otherwise = "ELSE\n" + "".join(render_keyword(entry) for entry in keyword.otherwise)
lines = "".join(f"{line}\n" for line in keyword.lines)
return head + lines + "THEN\n" + body + otherwise + "ENDIF\n"
if keyword.name in LINES_KEYWORDS:
return head + "".join(f"{line}\n" for line in keyword.lines) + "END\n"
if keyword.name in KEYWORD_BLOCK_KEYWORDS:
return head + "".join(render_keyword(entry) for entry in keyword.nested) + "END\n"
if keyword.supplemental:
return head + "".join(f"{record}\n" for record in keyword.supplemental)
return head
[docs]
def render_keywords(keywords: Sequence[Keyword]) -> str:
"""Render a keyword set as the text that FVS would read.
Parameters
----------
keywords : Sequence[Keyword]
The keywords, in order.
Returns
-------
str
The rendered set.
"""
return "".join(render_keyword(keyword) for keyword in keywords)
[docs]
@dataclass(frozen=True, slots=True)
class KeywordSet:
"""An ordered keyword set, validated, carrying the hash of the text it renders to.
Parameters
----------
keywords : Sequence[Keyword]
The keywords, in the order FVS will read them.
Raises
------
UnknownKeywordError
If any keyword's name is not in FVS's table.
"""
keywords: tuple[Keyword, ...] = ()
warnings: tuple[str, ...] = field(default=(), init=False)
def __post_init__(self) -> None:
"""Validate the set and record its warnings."""
keywords = tuple(self.keywords)
warnings = tuple(validate_keywords(keywords))
object.__setattr__(self, "keywords", keywords)
object.__setattr__(self, "warnings", warnings)
@property
def digest(self) -> str:
"""Return the hash of the text this set renders to, naming the algorithm."""
return keyword_set_hash(self.keywords)
[docs]
def render(self) -> str:
"""Return the text that FVS would read for this set.
Returns
-------
str
The rendered set, with one token per line.
"""
return render_keywords(self.keywords)
[docs]
def keyword_set_hash(keywords: Sequence[Keyword]) -> str:
"""Return the hash of the text a keyword set renders to.
The hash is taken over the rendered text rather than over a structure, so that it identifies the
exact bytes a run was given, which is what makes it useful as provenance. The algorithm is named
in the result, so a reader does not have to know it.
Parameters
----------
keywords : Sequence[Keyword]
The keywords, in order. Order is part of the identity, because FVS processes a set in order.
Returns
-------
str
``"sha256:"`` followed by the hexadecimal digest.
"""
text = render_keywords(keywords)
return f"sha256:{hashlib.sha256(text.encode('utf-8')).hexdigest()}"