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.
Files changed (89) hide show
  1. ws4/__init__.py +4 -0
  2. ws4/build/__init__.py +1 -0
  3. ws4/build/compiler.py +553 -0
  4. ws4/build/expansion.py +330 -0
  5. ws4/build/legacy.py +151 -0
  6. ws4/carbon/__init__.py +98 -0
  7. ws4/carbon/gcbm.py +326 -0
  8. ws4/carbon/ledger.py +218 -0
  9. ws4/carbon/libcbm.py +691 -0
  10. ws4/carbon/package.py +1347 -0
  11. ws4/carbon/parity.py +296 -0
  12. ws4/carbon/sit.py +832 -0
  13. ws4/cli.py +1491 -0
  14. ws4/disturbance.py +341 -0
  15. ws4/io/__init__.py +86 -0
  16. ws4/io/expressions.py +520 -0
  17. ws4/io/forest_model_xml.py +402 -0
  18. ws4/io/fragments.py +76 -0
  19. ws4/io/hydrology.py +2524 -0
  20. ws4/io/lidar.py +1594 -0
  21. ws4/model/__init__.py +1 -0
  22. ws4/model/api.py +134 -0
  23. ws4/model/checks.py +111 -0
  24. ws4/model/schema.py +468 -0
  25. ws4/model/types.py +218 -0
  26. ws4/opt/__init__.py +1 -0
  27. ws4/opt/archive.py +350 -0
  28. ws4/opt/calibrate.py +189 -0
  29. ws4/opt/constraints.py +525 -0
  30. ws4/opt/control.py +43 -0
  31. ws4/opt/cooling.py +175 -0
  32. ws4/opt/core.py +42 -0
  33. ws4/opt/engine.py +1998 -0
  34. ws4/opt/exact.py +161 -0
  35. ws4/opt/flood.py +250 -0
  36. ws4/opt/incremental.py +357 -0
  37. ws4/opt/max_flow.py +531 -0
  38. ws4/opt/mip.py +816 -0
  39. ws4/opt/objective.py +765 -0
  40. ws4/opt/operators.py +915 -0
  41. ws4/opt/patch.py +410 -0
  42. ws4/opt/patch_kernels.py +420 -0
  43. ws4/opt/patch_state.py +806 -0
  44. ws4/opt/portfolio.py +615 -0
  45. ws4/opt/prescreen.py +144 -0
  46. ws4/opt/repro.py +451 -0
  47. ws4/opt/scenarios.py +448 -0
  48. ws4/opt/seeding.py +306 -0
  49. ws4/opt/solvers.py +699 -0
  50. ws4/opt/spatial.py +293 -0
  51. ws4/opt/telemetry.py +231 -0
  52. ws4/opt/tune.py +407 -0
  53. ws4/opt/watch.py +991 -0
  54. ws4/patch/__init__.py +1 -0
  55. ws4/patch/accounts.py +634 -0
  56. ws4/patch/constraints.py +330 -0
  57. ws4/patch/edge.py +104 -0
  58. ws4/patch/metrics.py +228 -0
  59. ws4/patch/topology.py +24 -0
  60. ws4/report.py +267 -0
  61. ws4/roads/__init__.py +1 -0
  62. ws4/roads/accounts.py +1335 -0
  63. ws4/roads/network.py +603 -0
  64. ws4/schedule.py +60 -0
  65. ws4/sim/__init__.py +1 -0
  66. ws4/sim/curve.py +186 -0
  67. ws4/sim/engine.py +1480 -0
  68. ws4/sim/kernels.py +274 -0
  69. ws4/spread/__init__.py +6 -0
  70. ws4/spread/accounts.py +376 -0
  71. ws4/web/__init__.py +5 -0
  72. ws4/web/app.py +2710 -0
  73. ws4/web/skins/model-builder.json +21 -0
  74. ws4/web/skins/reviewer.json +19 -0
  75. ws4/web/skins/scenario-analyst.json +24 -0
  76. ws4/web/skins/scenario-browser.json +21 -0
  77. ws4/web/skins.py +119 -0
  78. ws4/web/static/app.js +3611 -0
  79. ws4/web/static/index.html +231 -0
  80. ws4/web/static/style.css +245 -0
  81. ws4/web/static/vendor/maplibre-gl.LICENSE.txt +116 -0
  82. ws4/web/static/vendor/maplibre-gl.css +1 -0
  83. ws4/web/static/vendor/maplibre-gl.js +59 -0
  84. ws4-0.2.0.dist-info/METADATA +98 -0
  85. ws4-0.2.0.dist-info/RECORD +89 -0
  86. ws4-0.2.0.dist-info/WHEEL +5 -0
  87. ws4-0.2.0.dist-info/entry_points.txt +2 -0
  88. ws4-0.2.0.dist-info/licenses/LICENSE +201 -0
  89. ws4-0.2.0.dist-info/top_level.txt +1 -0
ws4/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """WS4: Wood Supply Simulation System (spatially explicit)."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.2.0"
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