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_unitorgreenhouse_1); it is code insystems/, referenced by name from a case. A project is not a model — it configures one. - A study references a project by
project_idand asks a concrete question of it (optimal plan? operational anomaly?). It is the analysis layer, not a new greenhouse — so it does not live inprojects/.
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 Solution — rp/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 atspot_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_deliveriesofquantity_kg_m2everyinterval_daysfromfirst_delivery_day. Targets accumulate, the whole crop is sold atprice_per_kg, and an asymmetric piecewise-linear penalty applies at every date around atolerance_fractiondead band —shortfall_penalty_per_kgbelow it,surplus_penalty_per_kgabove. 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 ongreenlight_led_bleiswijk.studies/anomaly_led_demo/study.json— anomaly detection ongreenlight_led_bleiswijk.studies/mpc_led_demo/study.json— stochastic MPC (receding horizon) ongreenlight_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:
- Add its name to
STUDY_TYPESinstudies/schema.py. - Add a
*StudyParamsdataclass instudies/params.py(typed parse +validate()). - Add a
run_<type>_study(project, params, *, data_root)runner instudies/runners.pythat wraps the relevantanalysis/library, and anelifbranch inrun_study. - Ship a
studies/<id>/study.jsonand 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 |