cropforge 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.
- cropforge/__init__.py +31 -0
- cropforge/crop.py +77 -0
- cropforge/environment.py +3 -0
- cropforge/farm.py +725 -0
- cropforge/loaders.py +502 -0
- cropforge/logger.py +351 -0
- cropforge/runtime.py +369 -0
- cropforge/state.py +144 -0
- cropforge/viz/static/index.html +250 -0
- cropforge/viz/static/main.js +644 -0
- cropforge-0.2.0.dist-info/METADATA +58 -0
- cropforge-0.2.0.dist-info/RECORD +14 -0
- cropforge-0.2.0.dist-info/WHEEL +5 -0
- cropforge-0.2.0.dist-info/top_level.txt +1 -0
cropforge/farm.py
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cropforge/farm.py
|
|
3
|
+
=================
|
|
4
|
+
Farm and Field domain classes.
|
|
5
|
+
|
|
6
|
+
``Field`` represents one spatial plot: a rows×cols grid of plant positions and
|
|
7
|
+
soil voxels, with an attached ``Crop``, weather source, and soil profile.
|
|
8
|
+
|
|
9
|
+
``Farm`` is the top-level container that holds multiple ``Field`` objects and
|
|
10
|
+
owns the step-function registry. The ``@farm.step`` decorator (defined here
|
|
11
|
+
for co-location with the registry) registers user model logic; ``Farm.run()``
|
|
12
|
+
drives the time-stepping loop.
|
|
13
|
+
|
|
14
|
+
PRD References:
|
|
15
|
+
Section 4.3 — Directory Structure
|
|
16
|
+
Section 6.1 — Basic Usage Example
|
|
17
|
+
Section 6.2 — @farm.step decorator + phase rules
|
|
18
|
+
Section 6.3 — Event system (skeleton)
|
|
19
|
+
Section 6.4 — Error Handling Contract
|
|
20
|
+
Section 6.5 — farm.visualize() Pre-flight Check (stub)
|
|
21
|
+
|
|
22
|
+
Author : Saswat Sundar Rath, ICAR-IARI Jharkhand
|
|
23
|
+
Licence: MIT
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from cropforge.crop import Crop
|
|
34
|
+
from cropforge.state import (
|
|
35
|
+
EnvironmentState,
|
|
36
|
+
FieldState,
|
|
37
|
+
PlantState,
|
|
38
|
+
SoilVoxelState,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Sentinel elevation profiles (PRD Section 6.1 / 8.3)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
_BUILTIN_ELEVATION_PROFILES: Dict[str, str] = {
|
|
49
|
+
"flat": "flat",
|
|
50
|
+
"slope_1pct_N": "slope_1pct_N",
|
|
51
|
+
"slope_2pct_N": "slope_2pct_N",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_elevation_grid(
|
|
56
|
+
rows: int,
|
|
57
|
+
cols: int,
|
|
58
|
+
profile: Union[str, np.ndarray, None],
|
|
59
|
+
) -> np.ndarray:
|
|
60
|
+
"""Return a (rows, cols) float64 elevation array.
|
|
61
|
+
|
|
62
|
+
Accepts either a string shorthand (e.g. ``"slope_1pct_N"``) or a
|
|
63
|
+
pre-built NumPy array. If *profile* is ``None`` the grid is flat zero.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
rows, cols:
|
|
68
|
+
Field grid dimensions.
|
|
69
|
+
profile:
|
|
70
|
+
``None`` → flat zeros.
|
|
71
|
+
``"flat"`` → flat zeros.
|
|
72
|
+
``"slope_1pct_N"`` → 1 % northward slope (row index × 0.01 m).
|
|
73
|
+
``"slope_2pct_N"`` → 2 % northward slope (row index × 0.02 m).
|
|
74
|
+
``np.ndarray`` → used directly; must have shape ``(rows, cols)``.
|
|
75
|
+
"""
|
|
76
|
+
# Check for ndarray FIRST — before any string equality test,
|
|
77
|
+
# because numpy arrays raise ValueError on `array == "string"` comparisons.
|
|
78
|
+
if isinstance(profile, np.ndarray):
|
|
79
|
+
if profile.shape != (rows, cols):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"elevation_profile array shape {profile.shape} does not match "
|
|
82
|
+
f"field dimensions ({rows}, {cols})."
|
|
83
|
+
)
|
|
84
|
+
return profile.astype(np.float64)
|
|
85
|
+
if profile is None or profile == "flat":
|
|
86
|
+
return np.zeros((rows, cols), dtype=np.float64)
|
|
87
|
+
if profile == "slope_1pct_N":
|
|
88
|
+
elev = np.zeros((rows, cols), dtype=np.float64)
|
|
89
|
+
for r in range(rows):
|
|
90
|
+
elev[r, :] = r * 0.01
|
|
91
|
+
return elev
|
|
92
|
+
if profile == "slope_2pct_N":
|
|
93
|
+
elev = np.zeros((rows, cols), dtype=np.float64)
|
|
94
|
+
for r in range(rows):
|
|
95
|
+
elev[r, :] = r * 0.02
|
|
96
|
+
return elev
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Unknown elevation_profile string {profile!r}. "
|
|
99
|
+
f"Known profiles: {list(_BUILTIN_ELEVATION_PROFILES)}. "
|
|
100
|
+
"Pass a NumPy ndarray for a custom DEM."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Field
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class Field:
|
|
109
|
+
"""One spatial field (plot) within a farm.
|
|
110
|
+
|
|
111
|
+
The field owns the PlantState grid and the SoilVoxelState grid for its
|
|
112
|
+
spatial extent. After construction, the researcher attaches a ``Crop``,
|
|
113
|
+
a weather source, and a soil profile via the ``set_*`` methods.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
name:
|
|
118
|
+
Human-readable field identifier, e.g. ``"Plot A"``. Used as the
|
|
119
|
+
``field_name`` key throughout the Parquet log.
|
|
120
|
+
rows:
|
|
121
|
+
Number of rows in the plant grid (north–south axis by convention).
|
|
122
|
+
cols:
|
|
123
|
+
Number of columns in the plant grid (east–west axis by convention).
|
|
124
|
+
area_ha:
|
|
125
|
+
Physical area of the field in hectares. Used for per-hectare
|
|
126
|
+
quantity calculations (e.g. N application in kg/ha).
|
|
127
|
+
elevation_profile:
|
|
128
|
+
Initial elevation surface. See :func:`_build_elevation_grid` for
|
|
129
|
+
accepted values.
|
|
130
|
+
|
|
131
|
+
Examples
|
|
132
|
+
--------
|
|
133
|
+
>>> from cropforge.farm import Field
|
|
134
|
+
>>> f = Field(name="Plot A", rows=20, cols=30, area_ha=1.0)
|
|
135
|
+
>>> f.rows, f.cols
|
|
136
|
+
(20, 30)
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
name: str,
|
|
142
|
+
rows: int,
|
|
143
|
+
cols: int,
|
|
144
|
+
area_ha: float = 1.0,
|
|
145
|
+
elevation_profile: Union[str, np.ndarray, None] = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
if not name.strip():
|
|
148
|
+
raise ValueError("Field.name must be a non-empty string.")
|
|
149
|
+
if rows < 1 or cols < 1:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Field dimensions must be positive integers, got rows={rows}, cols={cols}."
|
|
152
|
+
)
|
|
153
|
+
if area_ha <= 0:
|
|
154
|
+
raise ValueError(f"Field.area_ha must be positive, got {area_ha}.")
|
|
155
|
+
|
|
156
|
+
self.name: str = name
|
|
157
|
+
self.rows: int = rows
|
|
158
|
+
self.cols: int = cols
|
|
159
|
+
self.area_ha: float = area_ha
|
|
160
|
+
|
|
161
|
+
# Build the elevation grid immediately so it is always available
|
|
162
|
+
self.elevation_grid: np.ndarray = _build_elevation_grid(
|
|
163
|
+
rows, cols, elevation_profile
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Attached data — set via set_* methods before farm.run()
|
|
167
|
+
self.crop: Optional[Crop] = None
|
|
168
|
+
self.weather: Optional[Any] = None # Weather object — set in Phase 1 loaders
|
|
169
|
+
self.soil_profile: Optional[Any] = None # Soil object — set in Phase 1 loaders
|
|
170
|
+
|
|
171
|
+
# Runtime state — populated by the engine at the start of farm.run()
|
|
172
|
+
self._field_state: Optional[FieldState] = None
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Attachment methods (PRD Section 6.1)
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def set_crop(self, crop: Crop) -> None:
|
|
179
|
+
"""Attach a :class:`~cropforge.crop.Crop` to this field."""
|
|
180
|
+
if not isinstance(crop, Crop):
|
|
181
|
+
raise TypeError(f"Expected a Crop instance, got {type(crop).__name__}.")
|
|
182
|
+
self.crop = crop
|
|
183
|
+
|
|
184
|
+
def set_weather(self, weather: Any) -> None:
|
|
185
|
+
"""Attach a Weather data source.
|
|
186
|
+
|
|
187
|
+
The ``Weather`` class is implemented in :mod:`cropforge.loaders`
|
|
188
|
+
(Phase 1). This method accepts any object so that the field class
|
|
189
|
+
does not create a circular import.
|
|
190
|
+
"""
|
|
191
|
+
self.weather = weather
|
|
192
|
+
|
|
193
|
+
def set_soil(self, soil: Any) -> None:
|
|
194
|
+
"""Attach a Soil profile.
|
|
195
|
+
|
|
196
|
+
The ``Soil`` class is implemented in :mod:`cropforge.loaders`
|
|
197
|
+
(Phase 1). This method accepts any object for the same reason as
|
|
198
|
+
:meth:`set_weather`.
|
|
199
|
+
"""
|
|
200
|
+
self.soil_profile = soil
|
|
201
|
+
|
|
202
|
+
def set_elevation(self, dem: np.ndarray) -> None:
|
|
203
|
+
"""Replace the elevation grid with a custom DEM array.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
dem:
|
|
208
|
+
NumPy array of shape ``(rows, cols)`` containing relative
|
|
209
|
+
elevation in metres (PRD Section 8.3).
|
|
210
|
+
"""
|
|
211
|
+
if not isinstance(dem, np.ndarray):
|
|
212
|
+
raise TypeError("dem must be a NumPy ndarray.")
|
|
213
|
+
if dem.shape != (self.rows, self.cols):
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"DEM shape {dem.shape} does not match field "
|
|
216
|
+
f"dimensions ({self.rows}, {self.cols})."
|
|
217
|
+
)
|
|
218
|
+
self.elevation_grid = dem.astype(np.float64)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def elevation_from_csv(path: str) -> np.ndarray:
|
|
222
|
+
"""Load a DEM from a CSV file (PRD Section 8.3).
|
|
223
|
+
|
|
224
|
+
Implemented in Phase 1 loaders; this stub is provided so the
|
|
225
|
+
method name is discoverable from the public API.
|
|
226
|
+
|
|
227
|
+
Raises
|
|
228
|
+
------
|
|
229
|
+
NotImplementedError
|
|
230
|
+
Until the loaders module implements this.
|
|
231
|
+
"""
|
|
232
|
+
raise NotImplementedError(
|
|
233
|
+
"Field.elevation_from_csv() will be implemented in Phase 1 loaders."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
# State initialisation (called by the engine at run-time)
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def _init_field_state(self, day: int = 1) -> FieldState:
|
|
241
|
+
"""Build and return the initial ``FieldState`` for this field.
|
|
242
|
+
|
|
243
|
+
Called by :class:`Farm` at the start of ``run()``. If no soil
|
|
244
|
+
profile has been attached, a minimal default (1 layer, all zeros)
|
|
245
|
+
is generated so the field can still be run in skeleton tests.
|
|
246
|
+
"""
|
|
247
|
+
plants: List[PlantState] = [
|
|
248
|
+
PlantState(
|
|
249
|
+
plant_id=f"r{r:02d}c{c:02d}",
|
|
250
|
+
row=r,
|
|
251
|
+
col=c,
|
|
252
|
+
)
|
|
253
|
+
for r in range(self.rows)
|
|
254
|
+
for c in range(self.cols)
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
# Build soil grid: [row][col][layer]
|
|
258
|
+
# Real soil data is injected by the loaders (Phase 1).
|
|
259
|
+
# When no soil is attached, create 1 default layer per cell.
|
|
260
|
+
if self.soil_profile is not None and hasattr(self.soil_profile, "build_grid"):
|
|
261
|
+
soil: List[List[List[SoilVoxelState]]] = self.soil_profile.build_grid(
|
|
262
|
+
self.rows, self.cols
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
soil = [
|
|
266
|
+
[
|
|
267
|
+
[
|
|
268
|
+
SoilVoxelState(
|
|
269
|
+
row=r,
|
|
270
|
+
col=c,
|
|
271
|
+
layer=0,
|
|
272
|
+
depth_top_cm=0.0,
|
|
273
|
+
depth_bottom_cm=20.0,
|
|
274
|
+
moisture_pct=0.0,
|
|
275
|
+
nitrogen_kg_ha=0.0,
|
|
276
|
+
bulk_density=1.3,
|
|
277
|
+
penetration_resistance=0.5,
|
|
278
|
+
)
|
|
279
|
+
]
|
|
280
|
+
for c in range(self.cols)
|
|
281
|
+
]
|
|
282
|
+
for r in range(self.rows)
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
self._field_state = FieldState(
|
|
286
|
+
day=day,
|
|
287
|
+
plants=plants,
|
|
288
|
+
soil=soil,
|
|
289
|
+
elevation_grid=self.elevation_grid.copy(),
|
|
290
|
+
events_fired=[],
|
|
291
|
+
)
|
|
292
|
+
return self._field_state
|
|
293
|
+
|
|
294
|
+
def __repr__(self) -> str:
|
|
295
|
+
crop_str = repr(self.crop) if self.crop else "None"
|
|
296
|
+
return (
|
|
297
|
+
f"Field(name={self.name!r}, rows={self.rows}, cols={self.cols}, "
|
|
298
|
+
f"area_ha={self.area_ha}, crop={crop_str})"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Farm
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
class Farm:
|
|
307
|
+
"""Top-level simulation container.
|
|
308
|
+
|
|
309
|
+
The researcher creates one ``Farm``, attaches ``Field`` objects, registers
|
|
310
|
+
step functions with ``@farm.step``, and then calls ``farm.run(days=N)``.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
name:
|
|
315
|
+
Human-readable farm / trial identifier, e.g. ``"Trial 2026-A"``.
|
|
316
|
+
location:
|
|
317
|
+
(latitude, longitude) tuple in decimal degrees. Stored for metadata
|
|
318
|
+
purposes; not used in v0.1 computations.
|
|
319
|
+
|
|
320
|
+
Examples
|
|
321
|
+
--------
|
|
322
|
+
>>> from cropforge.farm import Farm
|
|
323
|
+
>>> farm = Farm(name="Trial 2026-A", location=(23.4, 85.3))
|
|
324
|
+
>>> farm.name
|
|
325
|
+
'Trial 2026-A'
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def __init__(
|
|
329
|
+
self,
|
|
330
|
+
name: str,
|
|
331
|
+
location: Tuple[float, float] = (0.0, 0.0),
|
|
332
|
+
) -> None:
|
|
333
|
+
if not name.strip():
|
|
334
|
+
raise ValueError("Farm.name must be a non-empty string.")
|
|
335
|
+
|
|
336
|
+
self.name: str = name
|
|
337
|
+
self.location: Tuple[float, float] = location
|
|
338
|
+
|
|
339
|
+
# Ordered list of attached fields
|
|
340
|
+
self._fields: List[Field] = []
|
|
341
|
+
|
|
342
|
+
# Step function registry: list of (phase, fn) tuples, unsorted until run()
|
|
343
|
+
self._step_registry: List[Tuple[int, Callable]] = []
|
|
344
|
+
|
|
345
|
+
# Physics engine registry (v0.2.0) -- built-in hooks at negative phases.
|
|
346
|
+
# Populated only when use_physics() is called. Never contains
|
|
347
|
+
# researcher-registered functions. Kept separate so _sorted_steps()
|
|
348
|
+
# can merge and sort both registries cleanly.
|
|
349
|
+
self._physics_registry: List[Tuple[int, Callable]] = []
|
|
350
|
+
|
|
351
|
+
# Event registry (Phase 1 Event system; stored here as Any for now)
|
|
352
|
+
self._events: List[Any] = []
|
|
353
|
+
|
|
354
|
+
# Path to the most recent Parquet log (set by logger at end of run)
|
|
355
|
+
self._last_log_path: Optional[str] = None
|
|
356
|
+
|
|
357
|
+
# Physics configuration snapshot (for introspection / documentation)
|
|
358
|
+
self._physics_config: Dict[str, Any] = {}
|
|
359
|
+
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
# Field management
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
def add_field(self, field: Field) -> None:
|
|
365
|
+
"""Attach a configured :class:`Field` to this farm.
|
|
366
|
+
|
|
367
|
+
Parameters
|
|
368
|
+
----------
|
|
369
|
+
field:
|
|
370
|
+
A ``Field`` instance. Field names must be unique within a farm.
|
|
371
|
+
|
|
372
|
+
Raises
|
|
373
|
+
------
|
|
374
|
+
TypeError
|
|
375
|
+
If *field* is not a ``Field`` instance.
|
|
376
|
+
ValueError
|
|
377
|
+
If a field with the same name already exists in this farm.
|
|
378
|
+
"""
|
|
379
|
+
if not isinstance(field, Field):
|
|
380
|
+
raise TypeError(f"Expected a Field instance, got {type(field).__name__}.")
|
|
381
|
+
existing_names = [f.name for f in self._fields]
|
|
382
|
+
if field.name in existing_names:
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"A field named {field.name!r} already exists in farm {self.name!r}. "
|
|
385
|
+
"Field names must be unique."
|
|
386
|
+
)
|
|
387
|
+
self._fields.append(field)
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def fields(self) -> List[Field]:
|
|
391
|
+
"""Read-only view of attached fields."""
|
|
392
|
+
return list(self._fields)
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
# Event management (Section 6.3 — skeleton)
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def add_event(self, event: Any) -> None:
|
|
399
|
+
"""Register a management event (irrigation, fertiliser, custom).
|
|
400
|
+
|
|
401
|
+
The full ``Event`` class is implemented in Phase 1. This method
|
|
402
|
+
stores the event object for execution by the time-stepping loop.
|
|
403
|
+
"""
|
|
404
|
+
self._events.append(event)
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
# @farm.step decorator (Section 6.2)
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def step(
|
|
411
|
+
self,
|
|
412
|
+
interval: str = "daily",
|
|
413
|
+
phase: int = 0,
|
|
414
|
+
) -> Callable:
|
|
415
|
+
"""Decorator that registers a step function with the farm engine.
|
|
416
|
+
|
|
417
|
+
Parameters
|
|
418
|
+
----------
|
|
419
|
+
interval:
|
|
420
|
+
Execution frequency. Only ``"daily"`` is supported in v0.1.
|
|
421
|
+
phase:
|
|
422
|
+
Execution order integer. Lower values run first. Must be a
|
|
423
|
+
non-negative integer (PRD Section 6.2 phase rules).
|
|
424
|
+
Defaults to 0 when omitted.
|
|
425
|
+
|
|
426
|
+
Returns
|
|
427
|
+
-------
|
|
428
|
+
Callable
|
|
429
|
+
The original function, unmodified. The decorator is purely
|
|
430
|
+
registrative — it does not wrap the function.
|
|
431
|
+
|
|
432
|
+
Examples
|
|
433
|
+
--------
|
|
434
|
+
>>> @farm.step(interval="daily", phase=1)
|
|
435
|
+
... def soil_evaporation(state, env):
|
|
436
|
+
... return state
|
|
437
|
+
|
|
438
|
+
Phase rules (PRD Section 6.2):
|
|
439
|
+
- ``phase`` must be a non-negative integer.
|
|
440
|
+
- Multiple steps with the same phase → undefined order within
|
|
441
|
+
that phase. A warning is logged at the start of ``run()``.
|
|
442
|
+
- Steps with no ``phase`` argument default to 0; multiple
|
|
443
|
+
unphased steps trigger the same warning.
|
|
444
|
+
"""
|
|
445
|
+
if not isinstance(phase, int) or phase < 0:
|
|
446
|
+
raise ValueError(
|
|
447
|
+
f"@farm.step phase must be a non-negative integer, got {phase!r}."
|
|
448
|
+
)
|
|
449
|
+
if interval != "daily":
|
|
450
|
+
raise ValueError(
|
|
451
|
+
f"@farm.step interval must be 'daily' in v0.1, got {interval!r}."
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def decorator(fn: Callable) -> Callable:
|
|
455
|
+
self._step_registry.append((phase, fn))
|
|
456
|
+
return fn
|
|
457
|
+
|
|
458
|
+
return decorator
|
|
459
|
+
|
|
460
|
+
def _sorted_steps(self) -> List[Tuple[int, Callable]]:
|
|
461
|
+
"""Return all step functions sorted by phase (ascending).
|
|
462
|
+
|
|
463
|
+
Merges researcher-registered steps (_step_registry, phase >= 0)
|
|
464
|
+
with built-in physics hooks (_physics_registry, phase < 0).
|
|
465
|
+
The negative-phase physics hooks are always guaranteed to appear
|
|
466
|
+
before any researcher steps in the sorted output.
|
|
467
|
+
|
|
468
|
+
Also emits phase-conflict warnings per PRD Section 6.2.
|
|
469
|
+
Must be called at the start of ``run()``.
|
|
470
|
+
"""
|
|
471
|
+
from collections import Counter
|
|
472
|
+
|
|
473
|
+
# Researcher steps only -- check for conflicts within these
|
|
474
|
+
phase_counts = Counter(phase for phase, _ in self._step_registry)
|
|
475
|
+
for phase_val, count in phase_counts.items():
|
|
476
|
+
if count > 1:
|
|
477
|
+
fn_names = [
|
|
478
|
+
fn.__name__
|
|
479
|
+
for p, fn in self._step_registry
|
|
480
|
+
if p == phase_val
|
|
481
|
+
]
|
|
482
|
+
logger.warning(
|
|
483
|
+
"CropForge phase conflict: %d step functions share phase=%d "
|
|
484
|
+
"(%s). Their execution order within this phase is "
|
|
485
|
+
"non-deterministic. Assign unique phase values to enforce "
|
|
486
|
+
"deterministic ordering.",
|
|
487
|
+
count,
|
|
488
|
+
phase_val,
|
|
489
|
+
", ".join(fn_names),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Merge physics hooks (negative phases) + researcher steps (non-negative)
|
|
493
|
+
all_steps = self._physics_registry + self._step_registry
|
|
494
|
+
return sorted(all_steps, key=lambda t: t[0])
|
|
495
|
+
|
|
496
|
+
# ------------------------------------------------------------------
|
|
497
|
+
# use_physics() -- Opt-In Physics API (PRD v0.2.0 Section 4 / 5 / 9)
|
|
498
|
+
# ------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
def use_physics(
|
|
501
|
+
self,
|
|
502
|
+
et0: bool = False,
|
|
503
|
+
root_impedance: bool = False,
|
|
504
|
+
elevation_m: float = 0.0,
|
|
505
|
+
anemometer_height_m: float = 2.0,
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Enable opt-in physics engines for this farm (PRD v0.2.0 Section 4/5).
|
|
508
|
+
|
|
509
|
+
Each engine is registered as a built-in step function at a negative
|
|
510
|
+
phase, guaranteeing it runs before all researcher ``@farm.step``
|
|
511
|
+
functions (which are constrained to phase >= 0).
|
|
512
|
+
|
|
513
|
+
Execution order when all engines are enabled (PRD v0.2.0 Section 9):
|
|
514
|
+
phase=-2 ET0 engine (Penman-Monteith)
|
|
515
|
+
phase=-1 Root impedance engine
|
|
516
|
+
phase= 0 Researcher @farm.step (default phase)
|
|
517
|
+
|
|
518
|
+
This method is **idempotent with respect to flag combination** but
|
|
519
|
+
must only be called once per simulation. Calling it twice appends
|
|
520
|
+
duplicate hooks. The typical pattern is::
|
|
521
|
+
|
|
522
|
+
farm = Farm(name="Trial", location=(lat, lon))
|
|
523
|
+
farm.use_physics(et0=True, root_impedance=True)
|
|
524
|
+
|
|
525
|
+
Parameters
|
|
526
|
+
----------
|
|
527
|
+
et0:
|
|
528
|
+
Enable the FAO-56 Penman-Monteith ET0 engine. When ``True``,
|
|
529
|
+
``EnvironmentState.et0_mm`` (and the four intermediate FAO-56
|
|
530
|
+
fields) are computed and written each day before user steps run.
|
|
531
|
+
Default ``False`` -- ``et0_mm`` stays at whatever value the
|
|
532
|
+
weather source provides (or 0.0 from the stub).
|
|
533
|
+
root_impedance:
|
|
534
|
+
Enable the root impedance engine. When ``True``,
|
|
535
|
+
``PlantState.root_growth_multiplier`` is set to the impedance
|
|
536
|
+
multiplier for the soil layer at the plant's current
|
|
537
|
+
``root_depth_cm`` each day before user steps run.
|
|
538
|
+
Default ``False`` -- ``root_growth_multiplier`` stays at 1.0.
|
|
539
|
+
elevation_m:
|
|
540
|
+
Site elevation above mean sea level (m). Used by the ET0 engine
|
|
541
|
+
for psychrometric constant and clear-sky radiation. Defaults to
|
|
542
|
+
0.0 (sea level). Set this to the field's actual elevation.
|
|
543
|
+
anemometer_height_m:
|
|
544
|
+
Height of the wind speed anemometer (m). Used by the ET0 engine
|
|
545
|
+
to apply the logarithmic wind height correction (FAO-56 Eq. 47)
|
|
546
|
+
when the weather station measures wind at a non-standard height.
|
|
547
|
+
Default 2.0 m (no correction applied).
|
|
548
|
+
|
|
549
|
+
Notes
|
|
550
|
+
-----
|
|
551
|
+
The ``latitude_deg`` for the ET0 engine is taken from
|
|
552
|
+
``self.location[0]`` (the Farm's latitude coordinate).
|
|
553
|
+
|
|
554
|
+
PRD v0.2.0 backward compatibility guarantee:
|
|
555
|
+
If ``use_physics()`` is never called, no built-in hooks are
|
|
556
|
+
registered and the simulation behaves identically to v0.1.0.
|
|
557
|
+
"""
|
|
558
|
+
from cropforge.physics.builtin_hooks import (
|
|
559
|
+
PHASE_ET0_ENGINE,
|
|
560
|
+
PHASE_ROOT_ENGINE,
|
|
561
|
+
make_et0_hook,
|
|
562
|
+
make_root_impedance_hook,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Record configuration for introspection
|
|
566
|
+
self._physics_config.update({
|
|
567
|
+
"et0": et0,
|
|
568
|
+
"root_impedance": root_impedance,
|
|
569
|
+
"elevation_m": elevation_m,
|
|
570
|
+
"anemometer_height_m": anemometer_height_m,
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
if et0:
|
|
574
|
+
latitude_deg = self.location[0]
|
|
575
|
+
hook = make_et0_hook(
|
|
576
|
+
latitude_deg=latitude_deg,
|
|
577
|
+
elevation_m=elevation_m,
|
|
578
|
+
anemometer_height_m=anemometer_height_m,
|
|
579
|
+
)
|
|
580
|
+
self._physics_registry.append((PHASE_ET0_ENGINE, hook))
|
|
581
|
+
logger.info(
|
|
582
|
+
"Farm %r: ET0 engine enabled (lat=%.3f, elev=%.1f m, anem=%.1f m).",
|
|
583
|
+
self.name, latitude_deg, elevation_m, anemometer_height_m,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if root_impedance:
|
|
587
|
+
hook = make_root_impedance_hook()
|
|
588
|
+
self._physics_registry.append((PHASE_ROOT_ENGINE, hook))
|
|
589
|
+
logger.info(
|
|
590
|
+
"Farm %r: Root impedance engine enabled.",
|
|
591
|
+
self.name,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
if not et0 and not root_impedance:
|
|
595
|
+
logger.debug(
|
|
596
|
+
"Farm %r: use_physics() called with all engines disabled -- no-op.",
|
|
597
|
+
self.name,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# ------------------------------------------------------------------
|
|
601
|
+
# farm.run() — Time-stepping loop (Section 6.4)
|
|
602
|
+
# ------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
def run(self, days: int) -> None:
|
|
605
|
+
"""Execute the simulation for *days* timesteps.
|
|
606
|
+
|
|
607
|
+
For each day:
|
|
608
|
+
1. Fire any registered events for that day.
|
|
609
|
+
2. Execute each registered step function in ascending phase order.
|
|
610
|
+
3. Pass the (possibly modified) ``FieldState`` to the Parquet
|
|
611
|
+
logger (Phase 1 — logger stub called here).
|
|
612
|
+
|
|
613
|
+
Error handling contract (PRD Section 6.4):
|
|
614
|
+
- If any step function raises an unhandled exception the run halts.
|
|
615
|
+
- All completed timesteps are flushed to the Parquet log.
|
|
616
|
+
- A crash log is written to ``cropforge_crash.log``.
|
|
617
|
+
- ``CropForgeStepError`` is raised in the terminal.
|
|
618
|
+
|
|
619
|
+
Parameters
|
|
620
|
+
----------
|
|
621
|
+
days:
|
|
622
|
+
Number of daily timesteps to simulate (1-indexed days 1…N).
|
|
623
|
+
"""
|
|
624
|
+
from cropforge.runtime import CropForgeStepError, _execute_run
|
|
625
|
+
|
|
626
|
+
_execute_run(self, days)
|
|
627
|
+
|
|
628
|
+
# ------------------------------------------------------------------
|
|
629
|
+
# farm.visualize() (Section 6.5 — pre-flight stub)
|
|
630
|
+
# ------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
def visualize(self, log: Optional[str] = None) -> None:
|
|
633
|
+
"""Launch the visual frontend (Phase 2).
|
|
634
|
+
|
|
635
|
+
Performs the pre-flight check (PRD Section 6.5), then starts the
|
|
636
|
+
FastAPI + Dash server and opens the default browser to
|
|
637
|
+
``http://localhost:7860``.
|
|
638
|
+
|
|
639
|
+
Parameters
|
|
640
|
+
----------
|
|
641
|
+
log:
|
|
642
|
+
Explicit path to a Parquet session directory. If ``None``,
|
|
643
|
+
the log from the most recent ``farm.run()`` call in this session
|
|
644
|
+
is used (stored in ``self._last_log_path``).
|
|
645
|
+
|
|
646
|
+
Raises
|
|
647
|
+
------
|
|
648
|
+
CropForgeVisualizeError
|
|
649
|
+
If no valid Parquet log is found (PRD Section 6.5, rule 2).
|
|
650
|
+
|
|
651
|
+
Notes
|
|
652
|
+
-----
|
|
653
|
+
PRD Section 6.5 pre-flight check:
|
|
654
|
+
1. Locate the log: explicit path > ``_last_log_path``.
|
|
655
|
+
2. If not found or empty → ``CropForgeVisualizeError``.
|
|
656
|
+
3. If version mismatch → warning printed, visualisation proceeds.
|
|
657
|
+
"""
|
|
658
|
+
import json
|
|
659
|
+
from pathlib import Path
|
|
660
|
+
|
|
661
|
+
from cropforge.runtime import CropForgeVisualizeError
|
|
662
|
+
|
|
663
|
+
# ---- Rule 1: Resolve log path ---------------------------------
|
|
664
|
+
resolved_log: Optional[str] = log or self._last_log_path
|
|
665
|
+
|
|
666
|
+
# ---- Rule 2: Validate the path --------------------------------
|
|
667
|
+
_NO_LOG_MSG = (
|
|
668
|
+
"No valid simulation log found. Run farm.run() before calling "
|
|
669
|
+
"farm.visualize(), or pass an explicit log path via "
|
|
670
|
+
"farm.visualize(log=path)."
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if not resolved_log:
|
|
674
|
+
raise CropForgeVisualizeError(_NO_LOG_MSG)
|
|
675
|
+
|
|
676
|
+
log_dir = Path(resolved_log)
|
|
677
|
+
if not log_dir.exists():
|
|
678
|
+
raise CropForgeVisualizeError(
|
|
679
|
+
f"{_NO_LOG_MSG}\n (Path checked: {resolved_log})"
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# Log directory must contain at least one Parquet file
|
|
683
|
+
parquet_files = list(log_dir.rglob("*.parquet"))
|
|
684
|
+
if not parquet_files:
|
|
685
|
+
raise CropForgeVisualizeError(
|
|
686
|
+
f"{_NO_LOG_MSG}\n (Directory exists but contains no Parquet files: "
|
|
687
|
+
f"{resolved_log})"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# ---- Rule 3: Version mismatch check ---------------------------
|
|
691
|
+
from cropforge import __version__
|
|
692
|
+
try:
|
|
693
|
+
import pyarrow.parquet as pq
|
|
694
|
+
meta = pq.read_metadata(parquet_files[0]).metadata
|
|
695
|
+
file_version = meta.get(b"cropforge_version", b"unknown").decode()
|
|
696
|
+
if file_version != __version__ and file_version != "unknown":
|
|
697
|
+
import warnings
|
|
698
|
+
warnings.warn(
|
|
699
|
+
f"[CropForge] Version mismatch: this log was produced by "
|
|
700
|
+
f"CropForge {file_version}, but you are running "
|
|
701
|
+
f"CropForge {__version__}. Visualisation will proceed but "
|
|
702
|
+
"results may differ. Re-run farm.run() to update the log.",
|
|
703
|
+
UserWarning,
|
|
704
|
+
stacklevel=2,
|
|
705
|
+
)
|
|
706
|
+
logger.warning(
|
|
707
|
+
"Parquet log version mismatch: log=%s, runtime=%s. "
|
|
708
|
+
"Proceeding with visualisation.",
|
|
709
|
+
file_version,
|
|
710
|
+
__version__,
|
|
711
|
+
)
|
|
712
|
+
except Exception:
|
|
713
|
+
# Non-fatal: version check is best-effort
|
|
714
|
+
pass
|
|
715
|
+
|
|
716
|
+
# ---- Launch the server ----------------------------------------
|
|
717
|
+
from cropforge.viz.server import boot
|
|
718
|
+
boot(log_path=str(log_dir.resolve()), cropforge_version=__version__)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def __repr__(self) -> str:
|
|
722
|
+
return (
|
|
723
|
+
f"Farm(name={self.name!r}, location={self.location}, "
|
|
724
|
+
f"fields={[f.name for f in self._fields]})"
|
|
725
|
+
)
|