Skip to content

Studies

A study is a declarative analysis that runs over a project. It is data (studies/<id>/study.json) that names a project, picks an analysis type, and supplies its parameters. Studies are runnable from the CLI (greenflux study run …) and write a result file — no Python editing required.

The layering

physics model (code, systems/)                — HOW a greenhouse is simulated
      ▲ referenced by a simulation case ("greenhouse_unit" | "greenhouse_1")
project = greenhouse + cases (projects/<id>/project.json)   — WHICH greenhouse
      ▲ consumed by
study (studies/<id>/study.json)               — WHICH analysis is run on it
  • A project (greenflux.projects) describes a greenhouse: site, geometry, envelope, equipment, crop, calibration profiles and named simulation cases. It answers what greenhouse are we modelling? See project_structure.md.
  • A model is the physics engine a case selects (greenhouse_unit or greenhouse_1); it is code in systems/, referenced by name from a case. A project is not a model — it configures one.
  • A study references a project by project_id and asks a concrete question of it (optimal plan? operational anomaly?). It is the analysis layer, not a new greenhouse — so it does not live in projects/.

Chance-constrained humidity control

studies/humidity_chance_constraint — the smallest roof opening whose forecast ensemble violates an 85 % RH limit at most epsilon, against a deterministic MPC that treats one forecast as certain. Five times fewer violations for 7 % more gas, at 6.8 standard errors.

This is the only control result here whose sign, magnitude and significance all survived the July 2026 physics corrections — and it grew. It measures constraint satisfaction, where the planning and MPC studies measure expected margin; a margin moves with the plant's calibration and a violated limit barely does. See studies/humidity_chance_constraint/README.md.

Relationship to the experiment framework

GreenFlux already has a programmatic workflow — Project → Problem → Experiment → Benchmark (see experiment_framework.md):

  • greenflux.problems — a step-able, RL-style environment (project + case + objective + actions/observations). The abstraction for control loops, MPC, RL and observability.
  • greenflux.experiments — one reproducible run of a problem, with provenance + metrics.
  • greenflux.benchmarks — many experiments compared against metric thresholds.

Studies overlap with this framework — both are "a reproducible question asked of a project." They differ in shape and intended user:

Experiment framework Studies (studies/)
Form Programmatic (Python objects) Declarative (study.json) + CLI
Granularity A generic single problem-run / step-loop A named analysis pipeline (many runs) behind one type
Best for Control/RL, dataset generation, metric benchmarking "Run this named analysis on a project with these params, give me a result file"
Extends An objective + environment you compose A fixed set of study types (currently three)

A study runner drives a whole self-contained pipeline (e.g. calibrate → plan → validate), not a single environment step, which is why it is a distinct layer rather than a thin wrapper over experiments.

Shared run-record. The two layers are reconciled through one provenance envelope (greenflux.provenance.RunProvenance): an experiment summary carries a provenance block, and a study result is {"provenance": {...}, "result": {...}} with the same block. Both record kind ("experiment" / "study"), run_id, project_id, greenflux_version, created_at_utc and headline metrics. So the two layers differ in granularity but share one run-record shape — a benchmark or report consumer sees the same provenance from either.

The study.json schema

greenflux.studies.schema.StudyDefinition:

Field Type Meaning
study_id str Identity; also the results folder name.
name str Human-readable title.
project_id str References projects/<project_id>/project.json.
type str "stochastic_planning" or "anomaly_detection".
params object Type-specific; parsed + validated by the runner (see below).
metadata object Free-form string map.

validate() rejects an unknown type, an empty project_id, unknown top-level fields, and (via the typed params) a malformed params bag.

Running a study

CLI (console script or module):

greenflux study run studies/plan_led_demo/study.json
greenflux study run studies/anomaly_led_demo/study.json --run-id my_run
# equivalently: uv run python -m greenflux.cli study run studies/<id>/study.json

Flags: --projects-root (default projects), --data-root (default data/raw), --run-id (default a UTC timestamp). The run-record is written to studies/<study_id>/results/<run_id>/result.json as {"provenance": {...}, "result": {...}} — the provenance block is the shared envelope described above; the result block is the analysis output documented per type below. Result folders are git-ignored.

Programmatic:

from greenflux.studies.runners import run_study
from greenflux.studies.store import load_study

study = load_study("studies/plan_led_demo/study.json")
result = run_study(study, projects_root="projects", data_root="data/raw")

examples/run_study.py is a minimal wrapper around exactly this.

Stochastic planning needs the optional planning extra (cvxpy); both studies need the simulation extra (SciPy IVP). Install with uv pip install -e ".[planning,simulation]".

Study type: stochastic_planning

Plans macro climate targets (temperature/degree-days, supplemental light, CO2) to fulfil fixed delivery contracts at minimum expected cost, under weather × gas-price uncertainty. It is a two-stage stochastic convex program (cvxpy) over a macro crop model: degree-days govern development timing (linear delivery-timing constraints), while daily light integral and CO2 govern production quantity, and heating is charged on a daily deficit that credits lamp heat (max(T − T_out − k·light, 0), with k fitted from the simulator — lamps substitute for the boiler). The nonlinear forward simulator only calibrates the macro coefficients and validates that the plan is realizable — the low-level control follows.

Following the two-time-scale decomposition of greenhouse optimal control, the dynamics and drivers run at daily resolution (integrating day-to-day weather), while committed setpoints are piecewise-constant over weekly regimes (regime_length_days) and contracts are due by day. See studies/plan_led_demo/README.md for the methodology, decision outputs and literature.

params (see greenflux.studies.params.PlanningStudyParams):

Group Keys
Horizon horizon_days, regime_length_days, base_temperature_c
Calibration rated_heat_w_m2, boiler_efficiency, calibration_dt_s, calibration_window_days, calibration_setpoints_c[], calibration_light_w_m2[] (paired, equal length)
Levers/bounds min_temperature_c, max_temperature_c, max_light_w_m2, light_w_to_mol_m2_day, max_co2_ppm
Economics electricity_price_per_kwh, co2_price_per_kg, spot_price_per_kg, light_kwh_per_w, co2_kg_per_ppm
Development cdd_required
Scenarios (Monte Carlo) n_scenarios, temperature_season_sigma_c, temperature_ar1_rho, solar_season_sigma, gas_price_mean, gas_price_sigma_log, seed
Contracts contracts[] = {quantity_kg_m2, due_day, price_per_kg, shortfall_penalty_per_kg}
Risk cvar_weight (0 = risk-neutral, default), cvar_alpha, spot_price_sigma_log (a stochastic sale price — without it, forward contracting can never pay)
Contract sizing optimize_contract_quantity (default false) — when true the committed quantity becomes a first-stage decision bounded by quantity_kg_m2, which is then read as the buyer's cap (a newsvendor: the optimum commits the critical fractile (p−s)/(c+p−s), not the mean)

Uncertainty is a Monte Carlo ensemble: n_scenarios draws of the weather (temperature via a seasonal offset + AR(1) daily deviation; solar via a per-scenario seasonal scale — cloudy/sunny seasons — times a per-day lognormal multiplier; day-to-day variability estimated from the project's own dataset) and a lognormal gas price, each weighted 1/n_scenarios. The objective is the risk-neutral expected margin (the Monte Carlo average). Non-anticipativity is enforced by construction (one shared first-stage plan across all scenarios; per-scenario recourse). See studies/plan_led_demo/README.md.

Result: LP status, expected_margin, regime_length_days, the committed plan_temperature_c / plan_light_w_m2 / plan_co2_ppm (one value per weekly regime), the fitted macro coefficients, a validation block comparing the gas-per-degree-day coefficient at realized degree-days against the simulator, and a stochastic_value block quantifying the Value of the Stochastic Solutionrp/eev/ws margins with vss = rp − eev (gain over the mean-forecast plan) and evpi = ws − rp (value of perfect information).

Honest limitations: the macro model treats the temperature decision as the heating setpoint, so solar gain makes realized indoor degree-days exceed the setpoint-basis estimate (the validation block reports this). With CO2 held at ambient during calibration, biomass_kg_per_co2_ppm ≈ 0 (the CO2 lever carries no fitted benefit until calibration varies interior CO2). Production is short-cycle dry-biomass gain, not harvested fruit.

Study type: anomaly_detection

Injects operational anomalies into a forward run and detects/classifies them from model-vs-observed residuals plus controller-saturation signals. Ground truth is known (the fault is injected), so detection is scored: detected?, correct type?, latency.

Three fault types, each a physics discriminator:

  • sensor drift — additive drift on one measured channel of a monitoring sensor; the plant is unaffected, so cross-channel consistency (other channels stay self-consistent) separates it from a real event. The drift is applied to the recorded trajectory, so it never reaches the controller; a drift on an in-loop control sensor is not modelled and would weaken this discriminator, because the controller would chase the lie and move every channel. See studies/anomaly_led_demo/README.md §1.
  • equipment degradation — boiler capacity loss; the plant is affected, so channels shift coherently and the controller saturates far more than expected.
  • ventilation over-exchange — a roof-opening amplification (stuck/leaky vent) whose over-cooling/over-drying residual correlates with the vent state. Mirrors the documented structural over-exchange finding as a self-contained injectable fault. A constant leak is genuinely indistinguishable from under-heating; the discriminator only separates a modulated (e.g. daytime) leak.

params (see greenflux.studies.params.AnomalyStudyParams):

Group Keys
Window window_start_day, window_days, dt_s
Plant rated_heat_w_m2, setpoint_c
Detector cusum_slack, cusum_threshold
Faults faults[]{type: sensor_drift, channel, rate_per_hour, start_hour} / {type: equipment_degradation, capacity_fraction} / {type: ventilation_overexchange, day_opening}

Result: per-scenario injected vs. classified, correct, detected, latency_hours, dominant_channel, and saturation counts, plus a correct/total tally. The shipped anomaly_led_demo scores 4/4 with no false alarm.

Honest limitations: scoring is simulator-vs-simulator (injected, not field, noise); the classifier thresholds are tuned to the shipped scenario, not field-calibrated.

Study type: stochastic_mpc

The closed-loop, receding-horizon counterpart of stochastic_planning: re-plan the weekly setpoints each week from the realized simulator state, execute only the first week in the full-physics plant under the real weather path, advance, repeat. Reuses PlanningStudyParams (the LP gains initial_biomass_kg_m2 / initial_degree_days offsets so a remaining-horizon re-plan is correct mid-season). It runs three modes — open_loop (commit once), stochastic_mpc (re-plan over the ensemble), deterministic_mpc (re-plan on the mean) — and reports each mode's realized margin plus value_of_feedback (stochastic MPC − open-loop) and value_of_stochastic_in_loop (stochastic − deterministic MPC). The pure control-flow driver is greenflux.analysis.receding_horizon. See studies/mpc_led_demo/README.md — including the honest finding that a single realized path cannot fairly judge stochastic value.

Setting evaluation_forcing_source (plus start_day_offset / evaluation_start_day_offset for calendar alignment) executes the three modes on a different measured season than the one the planner calibrated and believes — an out-of-sample seasonal test reported as cross_season.

With n_physics_paths > 0 it additionally runs the three modes over realized paths with the full simulator, one path per process (studies/mpc_physics_sweep.py), reporting physics_monte_carlo (means, tails and paired standard errors) plus surrogate_bias — the macro-minus-physics gap on the same paths, which quantifies how much the linear surrogate flatters each strategy.

With n_realized_paths > 1 the study also runs a Monte Carlo over realized weather paths (the macro surrogate as a fast deterministic plant, paths sampled from the planner's own model) and reports a monte_carlo block alongside the single_physics_path anchor: per-mode expected / std / worst realized margin, value_of_feedback_mean, value_of_stochastic_in_loop_mean, and the stochastic_win_rate — the expectation-over-paths lens that a single path cannot give.

Settlement: incremental (default) vs cumulative_offtake

A study declares its delivery obligation in one of two mutually exclusive forms, and the form picks the settlement used by both the LP and the realized scorer (they must agree, or the loop optimises one contract and is graded on another):

  • contracts: [...]incremental. One quantity due by one date; deliver up to it against the harvest available by then, sell the surplus at spot_price_per_kg, penalize the undelivered remainder. This is the original scheme and every pre-existing study uses it.
  • offtake: {...}cumulative_offtake. An off-take agreement: n_deliveries of quantity_kg_m2 every interval_days from first_delivery_day. Targets accumulate, the whole crop is sold at price_per_kg, and an asymmetric piecewise-linear penalty applies at every date around a tolerance_fraction dead band — shortfall_penalty_per_kg below it, surplus_penalty_per_kg above. Because the target is cumulative, a deficit is charged again at every date it persists, and being ahead at one date is still being ahead at the next. Delivery dates must land on regime boundaries (the position is read off the weekly ledger).

In cumulative_offtake the runner additionally reports the constraint-satisfaction view of the same runs — per_mode_reliability (delivery_shortfall_probability, season_shortfall_probability, mean_absolute_delivery_deviation_kg_m2) and per_mode_economics (revenue / penalty / gas / electricity / CO2). On this repository's evidence the reliability view survives plant changes that move the margin freely, so it is reported next to the margin rather than derived from it.

Bundled studies

  • studies/plan_led_demo/study.json — stochastic planning on greenlight_led_bleiswijk.
  • studies/anomaly_led_demo/study.json — anomaly detection on greenlight_led_bleiswijk.
  • studies/mpc_led_demo/study.json — stochastic MPC (receding horizon) on greenlight_led_bleiswijk.
  • studies/cross_season_led_agc/study.json — cross-season out-of-sample validation: plan on the GreenLight 2009/10 winter, execute on the AGC 2019/20 winter (calendar-aligned).
  • studies/plan_contract_sizing/study.json — contract sizing as a first-stage newsvendor decision (risk-neutral); validated against the closed-form critical fractile.
  • studies/plan_risk_contract/study.json — forward contracting under price risk with a CVaR objective, at defensible prices (~€22/kg dry matter). Shows that a risk-neutral grower refuses a contract priced below expected spot while a risk-averse one accepts it.

Both reproduce exactly what the (now removed) examples/plan_stochastic_setpoints.py and examples/detect_operational_anomalies.py scripts produced.

Each bundled study has a folder README.md explaining its concepts, models, methodology and decision outputs: studies/plan_led_demo/README.md, studies/anomaly_led_demo/README.md.

Adding a study type

Deliberately minimal (no plugin registry). To add a type:

  1. Add its name to STUDY_TYPES in studies/schema.py.
  2. Add a *StudyParams dataclass in studies/params.py (typed parse + validate()).
  3. Add a run_<type>_study(project, params, *, data_root) runner in studies/runners.py that wraps the relevant analysis/ library, and an elif branch in run_study.
  4. Ship a studies/<id>/study.json and unit-test the params + dispatch.

The heavy analysis logic belongs in analysis/; the study layer only makes it declarative, parameterized and CLI-runnable. Layering stays acyclic: studies imports from analysis, projects, systems, weather, control — never the reverse (guarded by tests/test_architecture.py).

Module map

Module Responsibility
studies/schema.py StudyDefinition + validation
studies/params.py PlanningStudyParams / AnomalyStudyParams typed parse
studies/store.py load_study / save_study
studies/paths.py study_results_dir
studies/runners.py run_study dispatch + the two runners
cli.py greenflux study run subcommand