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/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
+ )