The 1dtoy Example
The 1dtoy case ships in examples/1dtoy/ in the Cobre repository. It is the
smallest complete hydrothermal dispatch problem that exercises every stage of the
workflow: input loading, layered validation, stochastic training, and post-training
simulation. The case solves in under a second and produces inspectable output files.
This page is a self-contained annotated reference. For the pedagogical walkthrough that explains each file field by field, see Anatomy of a Case. For the complete schema reference, see Case Format Reference.
System Description
| Element | Count | Details |
|---|---|---|
| Buses | 1 | SIN — single copper-plate node, no transmission constraints |
| Hydro plants | 1 | UHE1 — 1000 hm³ reservoir, 50 MW capacity, constant productivity (1 MW per m³/s) |
| Thermals | 2 | UTE1 at 5 $/MWh (15 MW), UTE2 at 10 $/MWh (15 MW) |
| Lines | 0 | Single-bus model, no transmission lines |
| Stages | 4 | Monthly, January–April 2024, 10 scenarios per stage during training |
| Simulation | 100 | Post-training evaluation over 100 independently sampled scenarios |
The system has 80 MW of total dispatchable capacity (50 MW hydro + 15 MW UTE1 + 15 MW UTE2). The initial reservoir level is 83.222 hm³ — about 8.3% of maximum capacity — creating a low-storage starting condition where the solver must weigh immediate turbine dispatch against the risk of running short in later stages.
The merit order is: hydro (zero fuel cost) first, then UTE1 (5 $/MWh), then UTE2 (10 $/MWh), then deficit (1000 $/MWh as last resort). The solver learns this ordering implicitly through the Benders cuts it generates.
Input Files
config.json
{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/book/src/schemas/config.schema.json",
"training": {
"forward_passes": 1,
"stopping_rules": [
{
"type": "iteration_limit",
"limit": 128
}
],
"scenario_source": {
"seed": 42,
"inflow": { "scheme": "in_sample" },
"load": { "scheme": "in_sample" },
"ncs": { "scheme": "in_sample" }
}
},
"simulation": {
"enabled": true,
"num_scenarios": 100
},
"modeling": {
"inflow_non_negativity": {
"method": "none"
}
}
}
forward_passes: 1 draws one scenario trajectory per training iteration, which
is standard for single-cut SDDP. The only stopping rule is an iteration_limit
of 128, so a run executes all 128 iterations. In a production study
you would add a convergence-based rule such as "type": "bound_stalling", "iterations": 20, "tolerance": 0.01
to stop early when the lower bound improvement stalls.
The scenario_source block configures per-class scenario sampling. Here all three
entity classes (inflow, load, NCS) use in_sample, meaning forward-pass noise is
drawn from the pre-generated opening tree. The seed: 42 controls the forward-pass
RNG (unused for in_sample but included for explicitness).
modeling.inflow_non_negativity.method: "none" allows the PAR(p) noise model to
produce negative inflow samples without truncation. This is appropriate when inflow
values are already log-transformed or when the scenario generation method handles
non-negativity separately.
For the full configuration schema, see Configuration.
stages.json (excerpt — Stage 0)
{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/book/src/schemas/stages.schema.json",
"policy_graph": {
"type": "finite_horizon",
"annual_discount_rate": 0.12
},
"stages": [
{
"id": 0,
"start_date": "2024-01-01",
"end_date": "2024-02-01",
"blocks": [{ "id": 0, "name": "SINGLE", "hours": 744 }],
"num_scenarios": 10
}
]
}
The remaining three stages follow the same pattern, covering February, March, and
April 2024 with hours values matching each calendar month (696 for February 2024,
744 for March, 720 for April).
policy_graph.type: "finite_horizon" produces a linear stage chain — Stage 0 feeds
Stage 1, Stage 1 feeds Stage 2, and Stage 3 has zero terminal value. The
annual_discount_rate: 0.12 applies a 12% annual discount when aggregating costs
across stages, converting monthly LP costs to a comparable present-value basis.
Each stage has one load block named SINGLE. The hours field converts power
(MW) to energy (MWh) in the LP objective: 744 hours × MW = MWh of energy produced
or consumed. A multi-block stage (e.g., peak/off-peak) would list multiple entries
in the blocks array.
system/hydros.json
{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/book/src/schemas/hydros.schema.json",
"hydros": [
{
"id": 0,
"name": "UHE1",
"bus_id": 0,
"downstream_id": null,
"reservoir": {
"min_storage_hm3": 0.0,
"max_storage_hm3": 1000.0
},
"outflow": {
"min_outflow_m3s": 0.0,
"max_outflow_m3s": 50.0
},
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 50.0,
"min_generation_mw": 0.0,
"max_generation_mw": 50.0
}
}
]
}
UHE1 is a standalone tailwater plant (downstream_id: null). The reservoir
can hold 0–1000 hm³. Total outflow (turbined plus spilled) is capped at 50 m³/s,
representing the physical river channel capacity below the dam.
The constant_productivity turbine model converts flow to power linearly:
power (MW) = flow (m³/s) × productivity coefficient from
system/hydro_production_models.json. More accurate production functions use
the FPHA model with a reservoir geometry table, but constant productivity is
sufficient for this tutorial system.
For the hydro field reference, see Case Format Reference.
system/thermals.json (abbreviated)
{
"thermals": [
{
"id": 0,
"name": "UTE1",
"bus_id": 0,
"cost_segments": [{ "capacity_mw": 15.0, "cost_per_mwh": 5.0 }],
"generation": { "min_mw": 0.0, "max_mw": 15.0 }
},
{
"id": 1,
"name": "UTE2",
"bus_id": 0,
"cost_segments": [{ "capacity_mw": 15.0, "cost_per_mwh": 10.0 }],
"generation": { "min_mw": 0.0, "max_mw": 15.0 }
}
]
}
Two single-segment thermals at different costs create a two-step merit order above zero-marginal-cost hydro. In each LP solve the solver dispatches UTE1 before UTE2 because it is cheaper, and it will only reach UTE2 when hydro and UTE1 combined cannot meet demand.
initial_conditions.json
{
"storage": [{ "hydro_id": 0, "value_hm3": 83.222 }],
"filling_storage": []
}
The initial reservoir level is 83.222 hm³, about 8.3% of the 1000 hm³ maximum.
This low starting level is deliberate: it forces the solver to learn a policy
that conserves water in early stages when the reservoir is nearly empty while
still meeting demand. The filling_storage array is empty because there are no
filling reservoirs (non-generating upstream storage) in this case.
Convergence Behavior
A training run writes its results to output/training/. With this configuration
the solver runs all 128 iterations and stops at the iteration limit (no
convergence-based stopping rule is configured in config.json).
Training summary (from output/training/metadata.json):
Iterations completed: 128
Termination reason: iteration_limit
Convergence achieved: false
Cuts generated: 384
Cuts active: 384
To test for convergence, add a bound_stalling rule alongside the iteration limit:
{
"training": {
"forward_passes": 1,
"stopping_rules": [
{ "type": "iteration_limit", "limit": 200 },
{ "type": "bound_stalling", "iterations": 20, "tolerance": 0.01 }
]
}
}
With this configuration, training ends once the lower bound improvement over the configured rolling window falls below the tolerance — the iteration count depends on the seed. Numerical values like gap percentages are stochastic — your run will differ from any pre-recorded reference values.
The convergence.parquet file in the training output records lower bound, upper
bound, and gap at every iteration, so you can plot convergence progress after the
run.
Output Structure
After running cobre run examples/1dtoy, the output directory contains three subdirectories:
output/
training/
metadata.json # Run metadata: status, iterations, convergence, cuts, problem dimensions
convergence.parquet # Per-iteration lower bound, upper bound, gap
timing/ # Per-stage, per-iteration solver timing
dictionaries/ # Variable and entity dictionaries for output parsing
_SUCCESS # Zero-byte sentinel written on clean completion
simulation/
metadata.json # Simulation metadata: total/completed/failed scenarios
buses/ # Bus dispatch results (Hive-partitioned by scenario)
scenario_id=0000/
data.parquet
...
scenario_id=0099/
data.parquet
hydros/ # Hydro dispatch results (storage, turbined, spilled)
thermals/ # Thermal dispatch results (generation by segment)
costs/ # Per-stage costs
inflow_lags/ # Inflow lag state variables used in each scenario
_SUCCESS
policy/
basis/ # LP basis snapshots for warm-starting
cuts/ # FlatBuffers policy checkpoint (Benders cuts)
metadata.json # Policy version and dimensions
Key files
| File | What it contains |
|---|---|
training/metadata.json | Run status, convergence result, iteration count, row pool statistics, problem dimensions |
training/convergence.parquet | Lower bound, upper bound, gap per iteration — use this to plot convergence |
simulation/buses/scenario_id=N/data.parquet | Bus-level demand, generation, deficit per stage for scenario N |
simulation/hydros/scenario_id=N/data.parquet | Storage level, turbined flow, spillage per stage for scenario N |
simulation/costs/scenario_id=N/data.parquet | Total cost per stage for scenario N |
policy/cuts/ | Saved Benders cuts — load this with --policy to warm-start a future run |
Querying results
All Parquet files are readable with any columnar query tool:
import polars as pl
# Convergence plot data
df = pl.read_parquet("output/training/convergence.parquet")
print(df.head())
# Hydro dispatch for scenario 0
df = pl.read_parquet(
"output/simulation/hydros/scenario_id=0000/data.parquet"
)
print(df)
-- DuckDB: average reservoir storage across all 100 simulation scenarios
SELECT stage_id, AVG(storage_hm3) AS mean_storage
FROM read_parquet('output/simulation/hydros/*/data.parquet')
GROUP BY stage_id
ORDER BY stage_id;
For the complete output schema reference, see Output Format.
Running the Example
Generated output is not committed to the repository — produce it by running the case yourself:
# Validate the input files
cobre validate examples/1dtoy
# Run training and simulation (writes to the output directory)
cobre run examples/1dtoy --output output
To scaffold a fresh copy of the 1dtoy case into a new directory:
cobre init --template 1dtoy my_study
cobre validate my_study
cobre run my_study --output my_study/output