ws4 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ws4/__init__.py +4 -0
- ws4/build/__init__.py +1 -0
- ws4/build/compiler.py +553 -0
- ws4/build/expansion.py +330 -0
- ws4/build/legacy.py +151 -0
- ws4/carbon/__init__.py +98 -0
- ws4/carbon/gcbm.py +326 -0
- ws4/carbon/ledger.py +218 -0
- ws4/carbon/libcbm.py +691 -0
- ws4/carbon/package.py +1347 -0
- ws4/carbon/parity.py +296 -0
- ws4/carbon/sit.py +832 -0
- ws4/cli.py +1491 -0
- ws4/disturbance.py +341 -0
- ws4/io/__init__.py +86 -0
- ws4/io/expressions.py +520 -0
- ws4/io/forest_model_xml.py +402 -0
- ws4/io/fragments.py +76 -0
- ws4/io/hydrology.py +2524 -0
- ws4/io/lidar.py +1594 -0
- ws4/model/__init__.py +1 -0
- ws4/model/api.py +134 -0
- ws4/model/checks.py +111 -0
- ws4/model/schema.py +468 -0
- ws4/model/types.py +218 -0
- ws4/opt/__init__.py +1 -0
- ws4/opt/archive.py +350 -0
- ws4/opt/calibrate.py +189 -0
- ws4/opt/constraints.py +525 -0
- ws4/opt/control.py +43 -0
- ws4/opt/cooling.py +175 -0
- ws4/opt/core.py +42 -0
- ws4/opt/engine.py +1998 -0
- ws4/opt/exact.py +161 -0
- ws4/opt/flood.py +250 -0
- ws4/opt/incremental.py +357 -0
- ws4/opt/max_flow.py +531 -0
- ws4/opt/mip.py +816 -0
- ws4/opt/objective.py +765 -0
- ws4/opt/operators.py +915 -0
- ws4/opt/patch.py +410 -0
- ws4/opt/patch_kernels.py +420 -0
- ws4/opt/patch_state.py +806 -0
- ws4/opt/portfolio.py +615 -0
- ws4/opt/prescreen.py +144 -0
- ws4/opt/repro.py +451 -0
- ws4/opt/scenarios.py +448 -0
- ws4/opt/seeding.py +306 -0
- ws4/opt/solvers.py +699 -0
- ws4/opt/spatial.py +293 -0
- ws4/opt/telemetry.py +231 -0
- ws4/opt/tune.py +407 -0
- ws4/opt/watch.py +991 -0
- ws4/patch/__init__.py +1 -0
- ws4/patch/accounts.py +634 -0
- ws4/patch/constraints.py +330 -0
- ws4/patch/edge.py +104 -0
- ws4/patch/metrics.py +228 -0
- ws4/patch/topology.py +24 -0
- ws4/report.py +267 -0
- ws4/roads/__init__.py +1 -0
- ws4/roads/accounts.py +1335 -0
- ws4/roads/network.py +603 -0
- ws4/schedule.py +60 -0
- ws4/sim/__init__.py +1 -0
- ws4/sim/curve.py +186 -0
- ws4/sim/engine.py +1480 -0
- ws4/sim/kernels.py +274 -0
- ws4/spread/__init__.py +6 -0
- ws4/spread/accounts.py +376 -0
- ws4/web/__init__.py +5 -0
- ws4/web/app.py +2710 -0
- ws4/web/skins/model-builder.json +21 -0
- ws4/web/skins/reviewer.json +19 -0
- ws4/web/skins/scenario-analyst.json +24 -0
- ws4/web/skins/scenario-browser.json +21 -0
- ws4/web/skins.py +119 -0
- ws4/web/static/app.js +3611 -0
- ws4/web/static/index.html +231 -0
- ws4/web/static/style.css +245 -0
- ws4/web/static/vendor/maplibre-gl.LICENSE.txt +116 -0
- ws4/web/static/vendor/maplibre-gl.css +1 -0
- ws4/web/static/vendor/maplibre-gl.js +59 -0
- ws4-0.2.0.dist-info/METADATA +98 -0
- ws4-0.2.0.dist-info/RECORD +89 -0
- ws4-0.2.0.dist-info/WHEEL +5 -0
- ws4-0.2.0.dist-info/entry_points.txt +2 -0
- ws4-0.2.0.dist-info/licenses/LICENSE +201 -0
- ws4-0.2.0.dist-info/top_level.txt +1 -0
ws4/__init__.py
ADDED
ws4/build/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Compilation of a recipe and an inventory into the matrix dataset."""
|
ws4/build/compiler.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Compile a ForestModelXML recipe and a fragment inventory into the matrix dataset.
|
|
2
|
+
|
|
3
|
+
The matrix is a Model II model: the builder enumerates the reachable strata (unique
|
|
4
|
+
state-variable combinations) and expands each into a track, then connects treatments to
|
|
5
|
+
the target stratum's track (see ``notes/design/ws4-matrix-builder-and-interop.md``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TypedDict
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from ..io.expressions import ExpressionError, evaluate_value
|
|
18
|
+
from ..io.forest_model_xml import Attribute, Recipe, Retention, parse_recipe
|
|
19
|
+
from ..io.fragments import read_fragments
|
|
20
|
+
from ..model.checks import unique_product_rows
|
|
21
|
+
from ..model.schema import MANIFEST_NAME, SCHEMA_VERSION, TABLES, coerce_to_schema
|
|
22
|
+
from ..model.types import CanonicalDatasetManifest
|
|
23
|
+
from .expansion import (
|
|
24
|
+
expand_strata,
|
|
25
|
+
matching_selects,
|
|
26
|
+
resolve_value,
|
|
27
|
+
state_of,
|
|
28
|
+
state_variables,
|
|
29
|
+
transition_key,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_FRAGMENT_COLUMNS = ("fragment_id", "block_id", "area_ha", "age", "track_id")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Portion(TypedDict):
|
|
36
|
+
"""An area portion of a fragment, with its stratum key."""
|
|
37
|
+
|
|
38
|
+
block_id: int
|
|
39
|
+
area_ha: float
|
|
40
|
+
age: int
|
|
41
|
+
key: tuple[str, ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_fragments(path: Path) -> pd.DataFrame:
|
|
45
|
+
"""Read a fragment inventory from a shapefile, CSV, or Parquet."""
|
|
46
|
+
return read_fragments(path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sha256(path: Path) -> str:
|
|
50
|
+
"""Return the SHA-256 digest of a file."""
|
|
51
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _curve_ids(recipe: Recipe) -> dict[str, int]:
|
|
55
|
+
"""Assign a stable integer id to every curve, in recipe order."""
|
|
56
|
+
return {name: index for index, name in enumerate(recipe.curves, start=1)}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _strata_expr(variables: Sequence[str], key: tuple[str, ...]) -> str:
|
|
60
|
+
"""Render a stratum as a strata expression such as ``status(C)+oper(Operable)``."""
|
|
61
|
+
return "+".join(f"{name}({value})" for name, value in zip(variables, key, strict=True))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _input_value(
|
|
65
|
+
expression: str, record: Mapping[str, object], constants: dict[str, str]
|
|
66
|
+
) -> object:
|
|
67
|
+
"""Resolve an ``<input>`` expression against a fragment row.
|
|
68
|
+
|
|
69
|
+
A plain column name is read directly; an expression such as ``Int(RES_KEY)`` or
|
|
70
|
+
``Shape_Area/10000`` is evaluated against the row.
|
|
71
|
+
"""
|
|
72
|
+
if expression in record:
|
|
73
|
+
return record[expression]
|
|
74
|
+
try:
|
|
75
|
+
return evaluate_value(expression, record, constants)
|
|
76
|
+
except ExpressionError:
|
|
77
|
+
return record.get(expression)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _managed_value(row: Mapping[str, object], constants: dict[str, str]) -> str:
|
|
81
|
+
"""Return the managed/unmanaged value for a stratum.
|
|
82
|
+
|
|
83
|
+
A model may carry an ``IFM`` field; otherwise the value is derived from the status
|
|
84
|
+
field and the ``managed`` constant.
|
|
85
|
+
"""
|
|
86
|
+
if "IFM" in row:
|
|
87
|
+
return str(row["IFM"])
|
|
88
|
+
managed = constants.get("managed", "C")
|
|
89
|
+
return "managed" if str(row.get("status", "")) == managed else "unmanaged"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _expand_tokens(
|
|
93
|
+
text: str, row: Mapping[str, object], kind: str, constants: dict[str, str]
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Replace a label or expression's ``%f``, ``%m``, ``%au``, and ``%s`` tokens.
|
|
96
|
+
|
|
97
|
+
``%f`` becomes the attribute's kind (``feature`` or ``product``), ``%m`` the
|
|
98
|
+
managed/unmanaged value, and ``%au`` / ``%s`` the analysis unit and species. A text
|
|
99
|
+
with no ``%`` is returned unchanged without scanning it (recipe expressions are huge).
|
|
100
|
+
"""
|
|
101
|
+
if "%" not in text:
|
|
102
|
+
return text
|
|
103
|
+
return (
|
|
104
|
+
text.replace("%f", kind)
|
|
105
|
+
.replace("%m", _managed_value(row, constants))
|
|
106
|
+
.replace("%au", str(row.get("au", "")))
|
|
107
|
+
.replace("%s", str(row.get("species", "")))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _expand_label(
|
|
112
|
+
label: str, row: Mapping[str, object], kind: str, constants: dict[str, str]
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Expand a label's ``%f`` and ``%m`` tokens, and evaluate a label expression.
|
|
115
|
+
|
|
116
|
+
A label that is an expression (for example ``'%f.area.%m.state.'+if(...)`` or
|
|
117
|
+
``'product.area.managed.state.'+statecode``) is evaluated against the row.
|
|
118
|
+
"""
|
|
119
|
+
text = _expand_tokens(label, row, kind, constants)
|
|
120
|
+
if "+" in text or "if(" in text:
|
|
121
|
+
try:
|
|
122
|
+
return str(evaluate_value(text, row, constants))
|
|
123
|
+
except ExpressionError:
|
|
124
|
+
return text
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _factor_value(attribute: object, row: Mapping[str, object], constants: dict[str, str]) -> float:
|
|
129
|
+
"""Evaluate an attribute's factor expression, defaulting to 1.0."""
|
|
130
|
+
factor = getattr(attribute, "factor", None)
|
|
131
|
+
if factor is None:
|
|
132
|
+
return 1.0
|
|
133
|
+
try:
|
|
134
|
+
return float(str(evaluate_value(factor, row, constants)))
|
|
135
|
+
except (ExpressionError, ValueError):
|
|
136
|
+
return 1.0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _expression_curve(
|
|
140
|
+
recipe: Recipe,
|
|
141
|
+
variables: Sequence[str],
|
|
142
|
+
key: tuple[str, ...],
|
|
143
|
+
expression: str,
|
|
144
|
+
kind: str,
|
|
145
|
+
attributes: dict[str, str] | None = None,
|
|
146
|
+
treatment: str = "",
|
|
147
|
+
) -> str | None:
|
|
148
|
+
"""Evaluate an attribute's curve expression to a curve name or a number string.
|
|
149
|
+
|
|
150
|
+
``%f`` / ``%m`` / ``%au`` / ``%s`` tokens are expanded first, and
|
|
151
|
+
``attribute('label')`` resolves against ``attributes`` (the labels already emitted
|
|
152
|
+
for this stratum). The result is a curve name, a number, or None when the
|
|
153
|
+
expression cannot be evaluated.
|
|
154
|
+
"""
|
|
155
|
+
row = dict(zip(variables, key, strict=True))
|
|
156
|
+
if treatment:
|
|
157
|
+
row["treatment"] = treatment
|
|
158
|
+
text = _expand_tokens(expression, row, kind, recipe.constants)
|
|
159
|
+
try:
|
|
160
|
+
return str(evaluate_value(text, row, recipe.constants, attributes))
|
|
161
|
+
except ExpressionError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _retained_key(
|
|
166
|
+
key: tuple[str, ...],
|
|
167
|
+
variables: Sequence[str],
|
|
168
|
+
retention: Retention,
|
|
169
|
+
constants: dict[str, str],
|
|
170
|
+
) -> tuple[str, ...]:
|
|
171
|
+
"""Return the state key a retention rule moves its area to."""
|
|
172
|
+
state = dict(zip(variables, key, strict=True))
|
|
173
|
+
for name, value in retention.assigns.items():
|
|
174
|
+
state[name] = resolve_value(value, state, constants)
|
|
175
|
+
return tuple(state.get(name, "") for name in variables)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _account_group(states: set[str]) -> str:
|
|
179
|
+
"""Return the account group for a label's producing selects.
|
|
180
|
+
|
|
181
|
+
A label produced only by managed selects belongs to ``_MANAGED_``, only by unmanaged
|
|
182
|
+
selects to ``_UNMANAGED_``, and otherwise to ``_ALL_``.
|
|
183
|
+
"""
|
|
184
|
+
if states == {"managed"}:
|
|
185
|
+
return "_MANAGED_"
|
|
186
|
+
if states == {"unmanaged"}:
|
|
187
|
+
return "_UNMANAGED_"
|
|
188
|
+
return "_ALL_"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _as_float(value: object) -> float:
|
|
192
|
+
"""Convert a value to a float, or 0.0 when it is not numeric."""
|
|
193
|
+
try:
|
|
194
|
+
return float(str(value))
|
|
195
|
+
except ValueError:
|
|
196
|
+
return 0.0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _retention_factor(
|
|
200
|
+
expression: str, record: Mapping[str, object], constants: dict[str, str]
|
|
201
|
+
) -> float:
|
|
202
|
+
"""Evaluate a retention factor to a value in ``[0, 1]``.
|
|
203
|
+
|
|
204
|
+
The factor is a literal number (for example ``0.1``), a fragment column, or an
|
|
205
|
+
expression. A factor that cannot be evaluated is treated as zero.
|
|
206
|
+
"""
|
|
207
|
+
value: object
|
|
208
|
+
if expression in record:
|
|
209
|
+
value = record[expression]
|
|
210
|
+
else:
|
|
211
|
+
try:
|
|
212
|
+
value = evaluate_value(expression, record, constants)
|
|
213
|
+
except ExpressionError:
|
|
214
|
+
value = 0.0
|
|
215
|
+
return min(max(_as_float(value), 0.0), 1.0)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _retention_split(
|
|
219
|
+
record: Mapping[str, object], key: tuple[str, ...], variables: Sequence[str], recipe: Recipe
|
|
220
|
+
) -> tuple[float, tuple[str, ...]] | None:
|
|
221
|
+
"""Return the retention factor and the retained state key, or None."""
|
|
222
|
+
for index in matching_selects(key, variables, recipe.selects, recipe.constants):
|
|
223
|
+
retention = recipe.selects[index - 1].retention
|
|
224
|
+
if retention is None:
|
|
225
|
+
continue
|
|
226
|
+
factor = (
|
|
227
|
+
_retention_factor(retention.factor, record, recipe.constants)
|
|
228
|
+
if retention.factor
|
|
229
|
+
else 0.0
|
|
230
|
+
)
|
|
231
|
+
state = dict(zip(variables, key, strict=True))
|
|
232
|
+
for name, value in retention.assigns.items():
|
|
233
|
+
state[name] = resolve_value(value, state, recipe.constants)
|
|
234
|
+
return factor, tuple(state.get(name, "") for name in variables)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _portions(
|
|
239
|
+
record: Mapping[str, object], variables: Sequence[str], recipe: Recipe
|
|
240
|
+
) -> list[_Portion]:
|
|
241
|
+
"""Split a fragment into area portions by its retention rule.
|
|
242
|
+
|
|
243
|
+
The retained fraction moves to the state the retention rule assigns (for
|
|
244
|
+
example, ``IFM = unmanaged``); the rest keeps the fragment's own state.
|
|
245
|
+
"""
|
|
246
|
+
key = state_of(record, variables, recipe)
|
|
247
|
+
block_id = int(_as_float(_input_value(recipe.input_block, record, recipe.constants)))
|
|
248
|
+
area = _as_float(_input_value(recipe.input_area, record, recipe.constants))
|
|
249
|
+
age = int(_as_float(_input_value(recipe.input_age, record, recipe.constants)))
|
|
250
|
+
split = _retention_split(record, key, variables, recipe)
|
|
251
|
+
if split is None:
|
|
252
|
+
return [_Portion(block_id=block_id, area_ha=area, age=age, key=key)]
|
|
253
|
+
factor, retained_key = split
|
|
254
|
+
rows: list[_Portion] = []
|
|
255
|
+
if factor < 1.0:
|
|
256
|
+
rows.append(_Portion(block_id=block_id, area_ha=area * (1.0 - factor), age=age, key=key))
|
|
257
|
+
if factor > 0.0:
|
|
258
|
+
rows.append(_Portion(block_id=block_id, area_ha=area * factor, age=age, key=retained_key))
|
|
259
|
+
return rows
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def build_canonical_dataset(xml_path: Path, fragments_path: Path, out_dir: Path) -> Path:
|
|
263
|
+
"""Compile a ForestModelXML recipe and a fragment inventory into a matrix dataset.
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
xml_path : Path
|
|
268
|
+
Path to the ForestModelXML recipe.
|
|
269
|
+
fragments_path : Path
|
|
270
|
+
Path to the fragment inventory, as CSV or Parquet.
|
|
271
|
+
out_dir : Path
|
|
272
|
+
Directory to write the compiled dataset into.
|
|
273
|
+
|
|
274
|
+
Returns
|
|
275
|
+
-------
|
|
276
|
+
Path
|
|
277
|
+
The dataset directory.
|
|
278
|
+
"""
|
|
279
|
+
xml_path = Path(xml_path)
|
|
280
|
+
fragments_path = Path(fragments_path)
|
|
281
|
+
out_dir = Path(out_dir)
|
|
282
|
+
recipe = parse_recipe(xml_path)
|
|
283
|
+
raw = _read_fragments(fragments_path)
|
|
284
|
+
curve_ids = _curve_ids(recipe)
|
|
285
|
+
variables = state_variables(recipe)
|
|
286
|
+
|
|
287
|
+
records: list[dict[str, object]] = [
|
|
288
|
+
{str(key): value for key, value in record.items()} for record in raw.to_dict("records")
|
|
289
|
+
]
|
|
290
|
+
portions = [portion for record in records for portion in _portions(record, variables, recipe)]
|
|
291
|
+
fragment_keys = [portion["key"] for portion in portions]
|
|
292
|
+
strata = expand_strata(fragment_keys, recipe)
|
|
293
|
+
track_of: dict[tuple[str, ...], int] = {s.key: s.track_id for s in strata}
|
|
294
|
+
|
|
295
|
+
fragments = pd.DataFrame(
|
|
296
|
+
{
|
|
297
|
+
"fragment_id": range(1, len(portions) + 1),
|
|
298
|
+
"block_id": [portion["block_id"] for portion in portions],
|
|
299
|
+
"area_ha": [portion["area_ha"] for portion in portions],
|
|
300
|
+
"age": [portion["age"] for portion in portions],
|
|
301
|
+
"track_id": [track_of.get(portion["key"], 0) for portion in portions],
|
|
302
|
+
},
|
|
303
|
+
columns=list(_FRAGMENT_COLUMNS),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
tracks = pd.DataFrame(
|
|
307
|
+
[
|
|
308
|
+
{"track_id": stratum.track_id, "name": _strata_expr(variables, stratum.key)}
|
|
309
|
+
for stratum in strata
|
|
310
|
+
],
|
|
311
|
+
columns=["track_id", "name"],
|
|
312
|
+
)
|
|
313
|
+
strata_table = pd.DataFrame(
|
|
314
|
+
[
|
|
315
|
+
{"track_id": stratum.track_id, "offset": 0, "next_age": recipe.horizon, "cycle": 0}
|
|
316
|
+
for stratum in strata
|
|
317
|
+
],
|
|
318
|
+
columns=["track_id", "offset", "next_age", "cycle"],
|
|
319
|
+
)
|
|
320
|
+
curve_rows: list[dict[str, object]] = [
|
|
321
|
+
{"curve_id": curve_ids[name], "x": x, "y": y}
|
|
322
|
+
for name, points in recipe.curves.items()
|
|
323
|
+
for x, y in points
|
|
324
|
+
]
|
|
325
|
+
scaled_ids: dict[tuple[str, float], int] = {}
|
|
326
|
+
constant_ids: dict[float, int] = {}
|
|
327
|
+
next_curve = [len(curve_ids) + 1]
|
|
328
|
+
|
|
329
|
+
def curve_for(base_name: str, factor: float) -> int:
|
|
330
|
+
"""Return a base curve's id, or a scaled copy when the factor is not 1."""
|
|
331
|
+
if factor == 1.0:
|
|
332
|
+
return curve_ids[base_name]
|
|
333
|
+
key = (base_name, round(factor, 6))
|
|
334
|
+
if key in scaled_ids:
|
|
335
|
+
return scaled_ids[key]
|
|
336
|
+
curve_id = next_curve[0]
|
|
337
|
+
next_curve[0] += 1
|
|
338
|
+
scaled_ids[key] = curve_id
|
|
339
|
+
for x, y in recipe.curves[base_name]:
|
|
340
|
+
curve_rows.append({"curve_id": curve_id, "x": x, "y": y * factor})
|
|
341
|
+
return curve_id
|
|
342
|
+
|
|
343
|
+
def constant_curve(value: float) -> int:
|
|
344
|
+
"""Return the id of a constant-valued curve, creating it on first use."""
|
|
345
|
+
key = round(value, 9)
|
|
346
|
+
if key in constant_ids:
|
|
347
|
+
return constant_ids[key]
|
|
348
|
+
curve_id = next_curve[0]
|
|
349
|
+
next_curve[0] += 1
|
|
350
|
+
constant_ids[key] = curve_id
|
|
351
|
+
curve_rows.append({"curve_id": curve_id, "x": 0.0, "y": key})
|
|
352
|
+
return curve_id
|
|
353
|
+
|
|
354
|
+
def resolve_curve(
|
|
355
|
+
attribute: Attribute,
|
|
356
|
+
key: tuple[str, ...],
|
|
357
|
+
kind: str,
|
|
358
|
+
attributes: dict[str, str],
|
|
359
|
+
treatment: str = "",
|
|
360
|
+
) -> tuple[int | None, str | None]:
|
|
361
|
+
"""Resolve an attribute to a curve id and, when it names one, its curve name.
|
|
362
|
+
|
|
363
|
+
A ``<curve idref>`` resolves to that curve; an expression resolves to a curve
|
|
364
|
+
name (an existing recipe curve) or, when it evaluates to a number, to a constant
|
|
365
|
+
curve. The name is returned so a later ``attribute('label')`` can resolve to it.
|
|
366
|
+
"""
|
|
367
|
+
row = dict(zip(variables, key, strict=True))
|
|
368
|
+
if treatment:
|
|
369
|
+
row["treatment"] = treatment
|
|
370
|
+
factor = _factor_value(attribute, row, recipe.constants)
|
|
371
|
+
if attribute.curve is not None:
|
|
372
|
+
return curve_for(attribute.curve, factor), attribute.curve
|
|
373
|
+
if attribute.expression is None:
|
|
374
|
+
return None, None
|
|
375
|
+
result = _expression_curve(
|
|
376
|
+
recipe, variables, key, attribute.expression, kind, attributes, treatment
|
|
377
|
+
)
|
|
378
|
+
if result is None:
|
|
379
|
+
return None, None
|
|
380
|
+
if result in curve_ids:
|
|
381
|
+
return curve_for(result, factor), result
|
|
382
|
+
try:
|
|
383
|
+
return constant_curve(float(result) * factor), None
|
|
384
|
+
except ValueError:
|
|
385
|
+
return None, None
|
|
386
|
+
|
|
387
|
+
feature_rows: list[dict[str, object]] = []
|
|
388
|
+
product_rows: list[dict[str, object]] = []
|
|
389
|
+
treatment_rows: list[dict[str, object]] = []
|
|
390
|
+
# A product select with no ``treatment eq`` context applies to every treatment its
|
|
391
|
+
# track offers, so each product is tagged with each of those treatments.
|
|
392
|
+
treatments_of_track: dict[int, list[str]] = {}
|
|
393
|
+
for stratum in strata:
|
|
394
|
+
labels: list[str] = []
|
|
395
|
+
for select_index in stratum.select_indices:
|
|
396
|
+
labels.extend(
|
|
397
|
+
treatment.label for treatment in recipe.selects[select_index - 1].treatments
|
|
398
|
+
)
|
|
399
|
+
treatments_of_track[stratum.track_id] = labels
|
|
400
|
+
# The Matrix Builder writes a simple summary account for every attribute, with the
|
|
401
|
+
# account name identical to the attribute name (see the user guide, "Creating Summary
|
|
402
|
+
# Accounts"). The user copies ``protoaccounts`` to ``accounts`` and edits it; a re-run
|
|
403
|
+
# must not overwrite that edited copy. Each label records the managed state(s) of the
|
|
404
|
+
# selects that produce it, which determines its account group.
|
|
405
|
+
account_labels: dict[str, set[str]] = {}
|
|
406
|
+
for stratum in strata:
|
|
407
|
+
row = dict(zip(variables, stratum.key, strict=True))
|
|
408
|
+
managed = _managed_value(row, recipe.constants)
|
|
409
|
+
# The curve each feature label already resolved to for this stratum, so an
|
|
410
|
+
# expression such as ``operable(attribute('feature.yield.managed.total'))`` can
|
|
411
|
+
# resolve to the same curve the base attribute used.
|
|
412
|
+
label_curves: dict[str, str] = {}
|
|
413
|
+
for select_index in stratum.select_indices:
|
|
414
|
+
select = recipe.selects[select_index - 1]
|
|
415
|
+
for attribute in select.features:
|
|
416
|
+
curve_id, curve_name = resolve_curve(
|
|
417
|
+
attribute, stratum.key, "feature", label_curves
|
|
418
|
+
)
|
|
419
|
+
if curve_id is None:
|
|
420
|
+
continue
|
|
421
|
+
label = _expand_label(attribute.label, row, "feature", recipe.constants)
|
|
422
|
+
if curve_name is not None:
|
|
423
|
+
label_curves[label] = curve_name
|
|
424
|
+
feature_rows.append(
|
|
425
|
+
{"track_id": stratum.track_id, "label": label, "curve_id": curve_id}
|
|
426
|
+
)
|
|
427
|
+
account_labels.setdefault(label, set()).add(managed)
|
|
428
|
+
# A retention rule's features describe the retained fraction, so they attach
|
|
429
|
+
# to the stratum the rule moves the area to, not to the select's own stratum.
|
|
430
|
+
if select.retention is not None:
|
|
431
|
+
retained = _retained_key(stratum.key, variables, select.retention, recipe.constants)
|
|
432
|
+
retained_track = track_of.get(retained)
|
|
433
|
+
retained_row = dict(zip(variables, retained, strict=True))
|
|
434
|
+
for attribute in select.retention_features:
|
|
435
|
+
curve_id, _ = resolve_curve(attribute, retained, "feature", label_curves)
|
|
436
|
+
if curve_id is None or retained_track is None:
|
|
437
|
+
continue
|
|
438
|
+
label = _expand_label(
|
|
439
|
+
attribute.label, retained_row, "feature", recipe.constants
|
|
440
|
+
)
|
|
441
|
+
feature_rows.append(
|
|
442
|
+
{"track_id": retained_track, "label": label, "curve_id": curve_id}
|
|
443
|
+
)
|
|
444
|
+
account_labels.setdefault(label, set()).add(managed)
|
|
445
|
+
for attribute in select.products:
|
|
446
|
+
if select.treatment_context:
|
|
447
|
+
contexts = [select.treatment_context]
|
|
448
|
+
else:
|
|
449
|
+
contexts = treatments_of_track.get(stratum.track_id, [])
|
|
450
|
+
for context in contexts:
|
|
451
|
+
# A product curve may depend on the treatment; try the treatment
|
|
452
|
+
# context first and fall back to the stratum-wide value.
|
|
453
|
+
curve_id, _ = resolve_curve(
|
|
454
|
+
attribute, stratum.key, "product", label_curves, treatment=context
|
|
455
|
+
)
|
|
456
|
+
if curve_id is None and context:
|
|
457
|
+
curve_id, _ = resolve_curve(attribute, stratum.key, "product", label_curves)
|
|
458
|
+
if curve_id is None:
|
|
459
|
+
continue
|
|
460
|
+
label_row = dict(row)
|
|
461
|
+
if context:
|
|
462
|
+
label_row["treatment"] = context
|
|
463
|
+
label = _expand_label(attribute.label, label_row, "product", recipe.constants)
|
|
464
|
+
product_rows.append(
|
|
465
|
+
{
|
|
466
|
+
"track_id": stratum.track_id,
|
|
467
|
+
"treatment": context,
|
|
468
|
+
"label": label,
|
|
469
|
+
"curve_id": curve_id,
|
|
470
|
+
}
|
|
471
|
+
)
|
|
472
|
+
account_labels.setdefault(label, set()).add(managed)
|
|
473
|
+
for treatment in select.treatments:
|
|
474
|
+
treatment_rows.append(
|
|
475
|
+
{
|
|
476
|
+
"track_id": stratum.track_id,
|
|
477
|
+
"begin": treatment.min_age,
|
|
478
|
+
"end": treatment.max_age,
|
|
479
|
+
"treatment": treatment.label,
|
|
480
|
+
"new_track": track_of.get(
|
|
481
|
+
transition_key(stratum.key, variables, treatment, recipe.constants), 0
|
|
482
|
+
),
|
|
483
|
+
"offset": 0,
|
|
484
|
+
"retain": 0,
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
protoaccount_rows = [
|
|
489
|
+
{"group": _account_group(states), "attribute": label, "account": label, "sum": 1.0}
|
|
490
|
+
for label, states in account_labels.items()
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
blocks = (
|
|
494
|
+
fragments.groupby(["block_id", "track_id"], as_index=False)
|
|
495
|
+
.agg(offset=("age", "min"), area_ha=("area_ha", "sum"))
|
|
496
|
+
.loc[:, ["block_id", "track_id", "offset", "area_ha"]]
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
tables: dict[str, pd.DataFrame] = {
|
|
500
|
+
"fragments": fragments,
|
|
501
|
+
"blocks": blocks,
|
|
502
|
+
"tracks": tracks,
|
|
503
|
+
"strata": strata_table,
|
|
504
|
+
"treatments": pd.DataFrame(
|
|
505
|
+
treatment_rows,
|
|
506
|
+
columns=["track_id", "begin", "end", "treatment", "new_track", "offset", "retain"],
|
|
507
|
+
),
|
|
508
|
+
"curves": pd.DataFrame(curve_rows, columns=["curve_id", "x", "y"]),
|
|
509
|
+
"features": pd.DataFrame(feature_rows, columns=["track_id", "label", "curve_id"]),
|
|
510
|
+
# A product row is recorded once per treated fragment, so an exact duplicate multiplies
|
|
511
|
+
# the account. The recipe declares products per select and several selects can resolve to
|
|
512
|
+
# one track; identical rows are redundant, while a repeated key with a different curve is a
|
|
513
|
+
# component to be summed and is kept.
|
|
514
|
+
"products": unique_product_rows(
|
|
515
|
+
pd.DataFrame(product_rows, columns=["track_id", "treatment", "label", "curve_id"])
|
|
516
|
+
),
|
|
517
|
+
"protoaccounts": pd.DataFrame(
|
|
518
|
+
protoaccount_rows, columns=["group", "attribute", "account", "sum"]
|
|
519
|
+
),
|
|
520
|
+
"accounts": pd.DataFrame(
|
|
521
|
+
protoaccount_rows, columns=["group", "attribute", "account", "sum"]
|
|
522
|
+
),
|
|
523
|
+
"groups": pd.DataFrame({"block_id": blocks["block_id"], "group": "forest"}),
|
|
524
|
+
"messages": pd.DataFrame(
|
|
525
|
+
{
|
|
526
|
+
"severity": ["info"],
|
|
527
|
+
"message": [
|
|
528
|
+
f"compiled {len(fragments)} fragments into {len(blocks)} block portions, "
|
|
529
|
+
f"{len(strata)} strata"
|
|
530
|
+
],
|
|
531
|
+
}
|
|
532
|
+
),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
536
|
+
for table in TABLES:
|
|
537
|
+
# Preserve a user-curated accounts file: the builder only writes protoaccounts,
|
|
538
|
+
# and re-running must not overwrite an edited accounts table.
|
|
539
|
+
if table.name == "accounts" and (out_dir / table.file).is_file():
|
|
540
|
+
continue
|
|
541
|
+
coerce_to_schema(tables[table.name], table).to_parquet(out_dir / table.file, index=False)
|
|
542
|
+
|
|
543
|
+
manifest = CanonicalDatasetManifest(
|
|
544
|
+
schema_version=SCHEMA_VERSION,
|
|
545
|
+
tables={table.name: table.file for table in TABLES},
|
|
546
|
+
sources=[
|
|
547
|
+
{"path": xml_path.name, "sha256": _sha256(xml_path)},
|
|
548
|
+
{"path": fragments_path.name, "sha256": _sha256(fragments_path)},
|
|
549
|
+
],
|
|
550
|
+
options={"period_length": 1, "recipe": "ForestModelXML", "model": "II"},
|
|
551
|
+
)
|
|
552
|
+
(out_dir / MANIFEST_NAME).write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
|
553
|
+
return out_dir
|