gtopt Planning Guide

A step-by-step guide to building, running, and understanding gtopt optimization cases. This guide walks through progressively more complex examples: from a simple single-bus dispatch to a multi-bus DC power-flow case with batteries and external time-series data.


Table of Contents

  1. Concepts
    • Time structure: Blocks, Stages, Scenarios
    • Phases and Scenes
    • System elements
  2. Anatomy of a gtopt JSON file
  3. Example 1 – Single-bus dispatch (one block)
  4. Example 2 – Multi-bus DC power flow (IEEE 9-bus)
  5. Example 3 – Multi-stage capacity expansion
  6. Example 4 – Battery storage (4-bus, 4 blocks)
  7. Example 5 – Simple hydro cascade (2-bus, 2 stages)
  8. Numerical Scaling for Large Systems
    • `scale_objective` and `scale_theta`
    • `energy_scale` for Large Reservoirs
    • Battery `energy_scale`
  9. Working with time-series schedules
    • Inline schedules in JSON
    • External CSV files
    • External Parquet files
    • Directory layout and file-field naming convention
  10. Complete JSON element reference
    • Options
    • Simulation (time structure)
    • System – Electrical network
    • System – Profiles
    • System – Energy storage
    • System – Reserves
    • System – Hydro cascade
  11. Field reference and auto-generated docs
  12. Output files
  13. Working with Stochastic Scenarios
    • Scenario-dependent data
    • Example: Solar plant with stochastic production
    • Pasada hydro mode
    • SDDP apertures
  14. Using the Cascade Solver
    • When to use cascade vs plain SDDP
    • Example: 2-level cascade (uninodal warm-start)
    • Example: 3-level progressive refinement
    • Monitoring cascade progress

1. Concepts

1.1 Time structure: Blocks, Stages, Scenarios

The planning data model defines how time and uncertainty are represented:

Image

💾 Regenerate: python3 scripts/gtopt_diagram.py --diagram-type planning -o docs/diagrams/planning_structure.svg

ElementRole
BlockSmallest time unit. energy [MWh] = power [MW] × duration [h].
StageInvestment period. Capacity built in a stage is available in all later stages. Costs are multiplied by discount_factor for present-value accounting.
ScenarioOne realization of uncertain inputs (e.g. dry/wet hydrology). All scenarios are solved simultaneously; their costs are weighted by probability_factor.
PhaseGroups consecutive stages into a higher-level period (e.g. seasons, construction vs. operation). Default: single phase covering all stages. See Section 1.2.
SceneCombines a subset of scenarios for LP solving. Default: single scene covering all scenarios. See Section 1.2.

A single-snapshot operational study uses one block, one stage, one scenario (the defaults if you omit simulation entirely):

{
  "simulation": {
    "block_array":    [{"uid": 1, "duration": 1}],
    "stage_array":    [{"uid": 1, "first_block": 0, "count_block": 1}],
    "scenario_array": [{"uid": 1, "probability_factor": 1}]
  }
}

A 24-hour operational study uses 24 blocks, one stage, one scenario:

{
  "simulation": {
    "block_array": [
      {"uid":  1, "duration": 1},
      {"uid":  2, "duration": 1},
      ...
      {"uid": 24, "duration": 1}
    ],
    "stage_array":    [{"uid": 1, "first_block": 0, "count_block": 24}],
    "scenario_array": [{"uid": 1, "probability_factor": 1}]
  }
}

A 5-year investment study with annual stages uses five 1-block stages (or more blocks per stage for seasonal detail):

{
  "simulation": {
    "block_array": [
      {"uid": 1, "duration": 8760},
      {"uid": 2, "duration": 8760},
      {"uid": 3, "duration": 8760},
      {"uid": 4, "duration": 8760},
      {"uid": 5, "duration": 8760}
    ],
    "stage_array": [
      {"uid": 1, "first_block": 0, "count_block": 1, "discount_factor": 1.0},
      {"uid": 2, "first_block": 1, "count_block": 1, "discount_factor": 0.909},
      {"uid": 3, "first_block": 2, "count_block": 1, "discount_factor": 0.826},
      {"uid": 4, "first_block": 3, "count_block": 1, "discount_factor": 0.751},
      {"uid": 5, "first_block": 4, "count_block": 1, "discount_factor": 0.683}
    ],
    "scenario_array": [{"uid": 1, "probability_factor": 1}]
  }
}

Tip: set annual_discount_rate in simulation and let gtopt compute discount factors automatically instead of providing them explicitly. For backward compatibility, options is also accepted.

1.2 Phases and Scenes

Phase – Grouping stages into higher-level periods

A Phase groups consecutive stages into a higher-level planning period. Common use cases:

Use casePhasesStages per phase
Seasonal analysis4 phases (summer, autumn, winter, spring)3 monthly stages each
Construction vs. operation2 phases (build, operate)Variable
Single-period study1 phase (default)All stages

When no phase_array is provided in the JSON, gtopt automatically creates a single default phase that covers all stages.

JSON example – 4 seasonal phases (12 monthly stages):

{
  "simulation": {
    "phase_array": [
      {"uid": 1, "name": "summer",  "first_stage": 0, "count_stage": 3},
      {"uid": 2, "name": "autumn",  "first_stage": 3, "count_stage": 3},
      {"uid": 3, "name": "winter",  "first_stage": 6, "count_stage": 3},
      {"uid": 4, "name": "spring",  "first_stage": 9, "count_stage": 3}
    ],
    "stage_array": [
      {"uid": 1,  "first_block": 0,  "count_block": 3},
      {"uid": 2,  "first_block": 3,  "count_block": 3},
      {"uid": 3,  "first_block": 6,  "count_block": 3},
      {"uid": 4,  "first_block": 9,  "count_block": 3},
      {"uid": 5,  "first_block": 12, "count_block": 3},
      {"uid": 6,  "first_block": 15, "count_block": 3},
      {"uid": 7,  "first_block": 18, "count_block": 3},
      {"uid": 8,  "first_block": 21, "count_block": 3},
      {"uid": 9,  "first_block": 24, "count_block": 3},
      {"uid": 10, "first_block": 27, "count_block": 3},
      {"uid": 11, "first_block": 30, "count_block": 3},
      {"uid": 12, "first_block": 33, "count_block": 3}
    ],
    "block_array": [
      {"uid": 1,  "duration": 217, "name": "night"},
      {"uid": 2,  "duration": 372, "name": "solar"},
      {"uid": 3,  "duration": 155, "name": "evening"}
    ]
  }
}

Phase fields:

FieldTypeRequiredDescription
uidintegerYesUnique identifier
namestringNoHuman-readable label (e.g. "summer")
activebooleanNoActivation status (default: true)
first_stageintegerNo0-based index of the first stage (default: 0)
count_stageintegerNoNumber of stages (default: all remaining)

Scene – Cross-product of scenarios and phases

A Scene combines a set of scenarios with a phase. In the LP formulation, each scene defines which scenarios are solved together within which phase. This is an advanced feature used for complex multi-scenario, multi-phase studies.

For most cases the default single scene (covering all scenarios across one phase) is sufficient. You only need explicit scene_array when combining multiple scenarios with multiple phases to control which scenario groups apply to which phase.

JSON example – default (implicit):

{
  "simulation": {
    "scene_array": [{"uid": 1, "first_scenario": 0, "count_scenario": 1}]
  }
}

Scene fields:

FieldTypeRequiredDescription
uidintegerYesUnique identifier
namestringNoHuman-readable label
activebooleanNoActivation status (default: true)
first_scenariointegerNo0-based index of the first scenario (default: 0)
count_scenariointegerNoNumber of scenarios (default: all remaining)

Time hierarchy diagram

The complete time hierarchy in gtopt is:

Planning
 ├─ Scene (independent LP trajectory, one per scene)
 │    └─ Phase (Benders decomposition level; state variables propagate via cuts)
 │         └─ Stage (investment period within a single LP formulation)
 │              └─ Block (smallest operating time unit, duration in hours)
 └─ Scenario (stochastic realization, probability-weighted in objective)

Each **(scene, phase)** pair produces a separate LP subproblem. Phases within the same scene are linked by state variables (reservoir volumes, installed capacity, etc.). In the monolithic solver all LPs are solved independently. In the SDDP solver, Benders optimality and feasibility cuts link consecutive phases, and cuts can optionally be shared across scenes.

PLP equivalence

When there is one stage per phase and one scenario per scene, the gtopt formulation is equivalent to the PLP formulation:

gtopt conceptPLP equivalentRole
BlockPLP blockSmallest operating time unit
Stage— (within LP)Generalized horizon time analysis within a single LP
PhasePLP stageBenders decomposition level; state variables propagate via cuts
Scenario— (weighted in objective)Stochastic realization with probability weight
ScenePLP scenarioIndependent LP trajectory

For a typical seasonal study with 2 hydrological trajectories:

Scene 1 ("dry") → groups Scenario "dry year" (prob=0.3)
   Phase 1 ("summer") → LP₁: Stages 1-3 (Jan, Feb, Mar)
       ↓ state variables (reservoir vol, capacity)
   Phase 2 ("autumn") → LP₂: Stages 4-6 (Apr, May, Jun)
       ↓ state variables
   Phase 3 ("winter") → LP₃: Stages 7-9 (Jul, Aug, Sep)
       ↓ state variables
   Phase 4 ("spring") → LP₄: Stages 10-12 (Oct, Nov, Dec)

Scene 2 ("wet") → groups Scenario "wet year" (prob=0.7)
   Phase 1 ("summer") → LP₅: Stages 1-3 (Jan, Feb, Mar)
       ↓ state variables
   Phase 2 ("autumn") → LP₆: Stages 4-6 (Apr, May, Jun)
       ↓ state variables
   Phase 3 ("winter") → LP₇: Stages 7-9 (Jul, Aug, Sep)
       ↓ state variables
   Phase 4 ("spring") → LP₈: Stages 10-12 (Oct, Nov, Dec)

In SDDP mode, optimality cuts generated in one scene can be shared with other scenes (see cut_sharing_mode option). This is analogous to how PLP shares cuts between its scenarios.

1.3 System elements

CategoryElementsDescription
Electrical networkBus, Generator, Demand, LineCore grid model
Time-varying profilesGeneratorProfile, DemandProfileCapacity-factor / load-shape scaling
Energy storageBattery, ConverterBESS modelling
ReserveReserveZone, ReserveProvisionSpinning-reserve requirements
Hydro cascadeJunction, Waterway, Flow, Reservoir, Filtration, TurbineHydrothermal systems

2. Anatomy of a gtopt JSON file

A gtopt case is defined by one or more JSON files passed on the command line. Multiple files are merged in order, so you can split options, simulation, and system across files.

gtopt base_options.json simulation.json system.json

The top-level structure is always:

{
  "options":    { ... },
  "simulation": { ... },
  "system":     { ... }
}

All three sections are optional — omitted sections use defaults.

Options (commonly used fields)

FieldUnitsDescription
input_directoryRoot directory for external time-series files
input_format"parquet" (default) or "csv"
output_directoryDirectory for result files (default: "output")
output_format"parquet" (default) or "csv"
model_options.demand_fail_cost$/MWhPenalty for unserved load (value of lost load)
model_options.use_kirchhoffEnable DC power-flow constraints (true/false)
model_options.use_single_busCollapse network to copper plate (true/false)
model_options.scale_objectiveDivide objective by this value (improves solver numerics)

Simulation (commonly used fields)

FieldUnitsDescription
annual_discount_ratep.u./yearCompute stage discount factors automatically
boundary_cuts_fileCSV file with boundary (future-cost) cuts
boundary_cuts_valuation"end_of_horizon" (default) or "present_value"

3. Example 1 – Single-bus dispatch (one block)

A minimal case: one bus, one cheap generator, one load, one hour.

Network diagram

flowchart LR
    gen_1[/"⚡ g1\n100 MW · $20/MWh"\]
    bus_1["🔌 b1"]
    dem_1[\"📊 d1\n80 MW load"/]

    gen_1 --> bus_1
    bus_1 --> dem_1
    classDef cls_bus fill:#D6EAF8,stroke:#1A5276,color:#1C2833
    classDef cls_gen fill:#FEF9E7,stroke:#E67E22,color:#1C2833
    classDef cls_demand fill:#FADBD8,stroke:#C0392B,color:#1C2833
    class bus_1 cls_bus
    class gen_1 cls_gen
    class dem_1 cls_demand

JSON

{
  "options": {
    "output_format": "csv",
    "model_options": {
      "use_single_bus": true,
      "demand_fail_cost": 500,
      "scale_objective": 1000
    }
  },
  "simulation": {
    "block_array":    [{"uid": 1, "duration": 1}],
    "stage_array":    [{"uid": 1, "first_block": 0, "count_block": 1}],
    "scenario_array": [{"uid": 1, "probability_factor": 1}]
  },
  "system": {
    "name": "example1",
    "bus_array": [
      {"uid": 1, "name": "b1"}
    ],
    "generator_array": [
      {"uid": 1, "name": "g1", "bus": "b1", "pmax": 100, "gcost": 20, "capacity": 100}
    ],
    "demand_array": [
      {"uid": 1, "name": "d1", "bus": "b1", "lmax": 80}
    ]
  }
}

Expected result

  • g1 dispatches 80 MW to serve d1 exactly.
  • Objective = 80 MW × 1 h × $20/MWh / 1000 = $1.60 (scaled).
  • output/solution.csv: status=0 (optimal).
  • output/Generator/generation_sol.csv: uid:1 = 80.

4. Example 2 – Multi-bus DC power flow (IEEE 9-bus)

The classic Anderson–Fouad 9-bus benchmark. Three generators, three loads, nine transmission lines. DC power flow (Kirchhoff's voltage law) is enabled.

Network diagram

Image

💾 Regenerate: python3 scripts/gtopt_diagram.py cases/ieee_9b/ieee_9b.json --subsystem electrical -o docs/diagrams/ieee9b_electrical.svg

Run the bundled case

cd cases/ieee_9b_ori
gtopt ieee_9b_ori.json
cat output/solution.csv          # status=0, obj_value=5.0
cat output/Generator/generation_sol.csv

Expected: g1 dispatches ~250 MW (cheapest at $20/MWh), g3 serves the rest, g2 (most expensive at $35/MWh) is at or near minimum.

Key JSON excerpt

{
  "options": {
    "model_options": {
      "use_single_bus": false,
      "use_kirchhoff": true,
      "demand_fail_cost": 1000,
      "scale_objective": 1000
    }
  },
  "system": {
    "line_array": [
      {
        "uid": 1, "name": "l1_4",
        "bus_a": "b1", "bus_b": "b4",
        "reactance": 0.0576,
        "tmax_ab": 250, "tmax_ba": 250
      }
    ]
  }
}

Note on reactance units: line reactance values in the bundled IEEE cases are in per-unit (p.u.) on a common system base (typically 100 MVA). When use_kirchhoff = true, gtopt uses the p.u. reactance to compute the voltage-angle difference: flow [MW] = (θ_a − θ_b) / reactance [p.u.].

</blockquote>

5. Example 3 – Multi-stage capacity expansion

The cases/c0/ case demonstrates demand-side capacity expansion over five years. The demand d1 starts at zero installed capacity and the solver decides how many 20 MW modules to build each year.

Network diagram

flowchart LR
    gen_1[/"⚡ g1\n20 MW · $100/MWh"\]
    bus_1["🔌 b1"]
    dem_1[\"📊 d1\nexpandable 0→200 MW"/]

    gen_1 --> bus_1
    bus_1 --> dem_1
    classDef cls_bus fill:#D6EAF8,stroke:#1A5276,color:#1C2833
    classDef cls_gen fill:#FEF9E7,stroke:#E67E22,color:#1C2833
    classDef cls_demand fill:#FADBD8,stroke:#C0392B,color:#1C2833
    class bus_1 cls_bus
    class gen_1 cls_gen
    class dem_1 cls_demand

Planning time structure

Image

💾 Regenerate: python3 scripts/gtopt_diagram.py cases/c0/system_c0.json --diagram-type planning -o docs/diagrams/c0_planning.svg

Time structure

Five stages × one block each, annual durations (1/2/3/4/5 h in the simplified case; full annual = 8 760 h in production cases).

"stage_array": [
  {"uid": 1, "first_block": 0, "count_block": 1},
  {"uid": 2, "first_block": 1, "count_block": 1},
  {"uid": 3, "first_block": 2, "count_block": 1},
  {"uid": 4, "first_block": 3, "count_block": 1},
  {"uid": 5, "first_block": 4, "count_block": 1}
]

Expandable demand definition

{
  "uid": 1, "name": "d1", "bus": "b1",
  "lmax": "lmax",
  "capacity": 0,
  "expcap": 20,
  "expmod": 10,
  "annual_capcost": 8760
}
  • capacity = 0: no initial capacity
  • expcap = 20 MW: each module adds 20 MW
  • expmod = 10: solver may build at most 10 modules
  • annual_capcost = 8760 $/MW-year: annualised investment cost
  • lmax = "lmax": refers to system_c0/Demand/lmax.parquet

Run

cd cases/c0
gtopt system_c0.json
cat output/Demand/capacost_sol.csv   # expansion cost per stage

6. Example 4 – Battery storage (4-bus, 4 blocks)

The cases/bat_4b/ case adds a battery energy storage system (BESS) to a 4-bus network. The battery charges at low-cost periods and discharges during high-demand periods.

Network diagram

Image

💾 Regenerate: python3 scripts/gtopt_diagram.py cases/bat_4b/bat_4b.json --subsystem electrical -o docs/diagrams/bat4b_electrical.svg

Battery (bat1) uses the unified definition: the bus field connects it to b3 and pmax_charge/pmax_discharge set the charge/discharge power rating. The system auto-generates the discharge generator, charge demand, and converter at LP construction time.

Battery definition (unified)

"battery_array": [
  {
    "uid": 1, "name": "bat1",
    "bus": "b3",
    "input_efficiency":  0.95,
    "output_efficiency": 0.95,
    "emin": 0, "emax": 200,
    "eini": 0,
    "pmax_charge": 60,
    "pmax_discharge": 60,
    "gcost": 0,
    "capacity": 200
  }
]

Note: No converter_array, g_bat_out generator, or d_bat_in demand is needed — all three are auto-generated by expand_batteries().

The BESS charges when solar is cheap (block 3) and discharges during the high-demand block 4 (200 MW load).

Run

cd cases/bat_4b
gtopt bat_4b.json
cat output/Generator/generation_sol.csv
cat output/Battery/storage_sol.csv

7. Example 5 – Simple hydro cascade (2-bus, 2 stages)

This example introduces hydro generation with a reservoir, junctions, a waterway, a turbine, and an exogenous inflow. It models the dispatch of a hydro plant alongside a thermal backup over two stages of four hourly blocks each.

Network diagram

flowchart LR
    subgraph Hydro
        flow["💧 inflow\n10 m³/s"] --> j1["⊕ j1"]
        j1 --> |"ww1"| j2["⊕ j2 (drain)"]
        rsv1["🏞️ rsv1\n0–1000 dam³"] --- j1
        tur1["⚙️ tur1"] --- j1
    end
    gen_hydro[/"⚡ gen_hydro\n100 MW · $5/MWh"\] --> bus1["🔌 bus1"]
    gen_thermal[/"⚡ gen_thermal\n200 MW · $50/MWh"\] --> bus2["🔌 bus2"]
    bus1 --- |"l1 (100 MW)"| bus2
    dem_1[\"📊 d1\n80 MW load"/] --- bus2

    classDef cls_bus fill:#D6EAF8,stroke:#1A5276,color:#1C2833
    classDef cls_gen fill:#FEF9E7,stroke:#E67E22,color:#1C2833
    classDef cls_demand fill:#FADBD8,stroke:#C0392B,color:#1C2833
    classDef cls_hydro fill:#D5F5E3,stroke:#1E8449,color:#1C2833
    class bus1,bus2 cls_bus
    class gen_hydro,gen_thermal cls_gen
    class dem_1 cls_demand
    class j1,j2,rsv1,tur1,flow cls_hydro

JSON

{
  "options": {
    "output_format": "csv",
    "output_compression": "uncompressed",
    "model_options": {
      "use_single_bus": false,
      "use_kirchhoff": true,
      "demand_fail_cost": 1000,
      "scale_objective": 1000
    }
  },
  "simulation": {
    "block_array": [
      {"uid": 1, "duration": 1},
      {"uid": 2, "duration": 1},
      {"uid": 3, "duration": 1},
      {"uid": 4, "duration": 1},
      {"uid": 5, "duration": 1},
      {"uid": 6, "duration": 1},
      {"uid": 7, "duration": 1},
      {"uid": 8, "duration": 1}
    ],
    "stage_array": [
      {"uid": 1, "first_block": 0, "count_block": 4},
      {"uid": 2, "first_block": 4, "count_block": 4}
    ],
    "scenario_array": [
      {"uid": 1, "probability_factor": 1}
    ]
  },
  "system": {
    "name": "hydro_cascade",
    "bus_array": [
      {"uid": 1, "name": "bus1"},
      {"uid": 2, "name": "bus2"}
    ],
    "generator_array": [
      {
        "uid": 1, "name": "gen_hydro", "bus": "bus1",
        "pmax": 100, "gcost": 5, "capacity": 100
      },
      {
        "uid": 2, "name": "gen_thermal", "bus": "bus2",
        "pmax": 200, "gcost": 50, "capacity": 200
      }
    ],
    "demand_array": [
      {"uid": 1, "name": "d1", "bus": "bus2", "lmax": 80}
    ],
    "line_array": [
      {
        "uid": 1, "name": "l1",
        "bus_a": "bus1", "bus_b": "bus2",
        "reactance": 0.02,
        "tmax_ab": 100, "tmax_ba": 100
      }
    ],
    "junction_array": [
      {"uid": 1, "name": "j1"},
      {"uid": 2, "name": "j2", "drain": true}
    ],
    "waterway_array": [
      {
        "uid": 1, "name": "ww1",
        "junction_a": 1, "junction_b": 2,
        "fmin": 0, "fmax": 100
      }
    ],
    "reservoir_array": [
      {
        "uid": 1, "name": "rsv1",
        "junction": 1,
        "emin": 0, "emax": 1000,
        "eini": 500,
        "capacity": 1000
      }
    ],
    "flow_array": [
      {
        "uid": 1, "name": "inflow",
        "direction": 1,
        "junction": 1,
        "discharge": 10
      }
    ],
    "turbine_array": [
      {
        "uid": 1, "name": "tur1",
        "waterway": 1,
        "generator": 1,
        "conversion_rate": 1.0
      }
    ]
  }
}

How the hydro cascade works

The hydro cascade elements interact as follows:

  1. Junctions (j1, j2) are hydraulic nodes where water balance is enforced. Junction j2 has drain: true, meaning water leaving it exits the system (e.g., flows to the sea).
  2. Waterway (ww1) connects j1 to j2. Water flows through it at a rate bounded by [fmin, fmax] in m3/s.
  3. Reservoir (rsv1) is attached to junction j1. It stores water (volume in dam3) between emin and emax. The initial volume eini is 500 dam3. The reservoir volume evolves over blocks according to the water balance: volume[b] = volume[b-1] + (inflows - outflows) * duration.
  4. Flow (inflow) adds 10 m3/s of exogenous water to junction j1 in every block (e.g., river inflow).
  5. Turbine (tur1) converts water flowing through the waterway into electricity via gen_hydro. The conversion_rate (MW per m3/s) determines how much power is generated per unit of water flow.

Expected dispatch

  • Hydro generation (gen_hydro, $5/MWh) is much cheaper than thermal (gen_thermal, $50/MWh), so the optimizer dispatches hydro first.
  • The 80 MW demand at bus2 is served via the transmission line l1 (100 MW capacity), which is sufficient.
  • Thermal generation only activates if hydro capacity or reservoir volume is insufficient to meet demand.
  • The reservoir volume decreases as water is turbined and increases with the 10 m3/s inflow.

Checking results

# Generator dispatch — hydro should serve most/all load
cat output/Generator/generation_sol.csv

# Reservoir volume evolution over blocks
cat output/Reservoir/volume_sol.csv

# Waterway flow (turbine water usage)
cat output/Waterway/flow_sol.csv

# Verify no load shedding
cat output/Demand/fail_sol.csv

8. Numerical Scaling for Large Systems

LP solvers work best when the ratio of the largest to smallest non-zero coefficient in the LP matrix is below $10^7$ (the "LP coefficient ratio"). Poor scaling causes numerical instability, slow convergence, or incorrect solutions.

8.1 <tt>model_options.scale_objective</tt> and <tt>model_options.scale_theta</tt>

The model_options.scale_objective (default 1000) divides all objective coefficients. For a system where generation costs are ~$100/MWh and 24-hour blocks are used, the raw coefficient is $100 × 24 = 2400$. With scale_objective = 1000 this becomes 2.4, which is well-conditioned.

Similarly, model_options.scale_theta (default 1000) normalises voltage-angle variables.

These defaults are adequate for most power systems.

8.2 <tt>energy_scale</tt> for Large Reservoirs

For large hydroelectric reservoirs, the default energy_scale = 1.0 creates LP variable bounds in the tens of millions (dam³), which produces a coefficient ratio far exceeding $10^8$ when combined with generator costs in the range 0.01–1.

Set energy_scale ≈ emax / 1000 to keep LP volume variables in the $[0, 1000]$ range (matching the PLP ScaleVol convention):

{
  "reservoir_array": [
    {"uid": 1, "name": "Laja",   "emax": 6000000,  "energy_scale": 6000},
    {"uid": 2, "name": "Colbun", "emax": 1500000,  "energy_scale": 1500},
    {"uid": 3, "name": "Rapel",  "emax":  200000,  "energy_scale":  200}
  ]
}

Or use a uniform variable_scales entry to apply a default to all reservoirs (and then override individually for very small or very large ones):

{
  "options": {
    "variable_scales": [
      {"class_name": "Reservoir", "variable": "energy", "uid": -1, "scale": 1000.0}
    ]
  }
}

Diagnosing scaling issues: run with --stats and look for the LP coefficient ratio in the log output. A ratio above $10^7$ indicates that scaling should be reviewed.

gtopt my_case.json --stats 2>&1 | grep -i "coeff.*ratio\|coefficient.*ratio"

8.3 Battery <tt>energy_scale</tt>

For batteries, the same principle applies. A battery with emax = 10000 MWh should use energy_scale = 10:

{"uid": 1, "name": "BESS1", "emax": 10000, "energy_scale": 10}

9. Working with time-series schedules

Many fields — pmax, lmax, gcost, profile, discharge — can hold:

Value typeMeaning
100 (scalar)Constant value in every block
[[80, 90, 100]] (inline array)Per-[stage][block] values
[[[70, 80, 90], [60, 70, 80]]]Per-[scenario][stage][block] values
"lmax" (string)Filename in input_directory/<ClassName>/

9.1 Inline schedules in JSON

The array dimensions depend on the field type:

C++ typeDimensionsExample
OptTRealFieldSched[stage] or scalar[100, 90, 80, 70, 60]
OptTBRealFieldSched[stage][block] or scalar[[100, 95], [90, 85]]
STBRealFieldSched[scenario][stage][block][[[1.0, 0.8, 0.5]]]

Example: 24-hour solar generation profile for a generator with 270 MW capacity:

{
  "uid": 1, "name": "gp_solar",
  "generator": "g_solar",
  "profile": [[[0, 0, 0, 0, 0, 0.05,
                0.2, 0.45, 0.7, 0.88, 0.97, 1.0,
                0.98, 0.95, 0.88, 0.72, 0.5, 0.25,
                0.08, 0.01, 0, 0, 0, 0]]]
}

Inline arrays are fine for tens of blocks. For hundreds or thousands of time steps, use external files.

9.2 External CSV files

A CSV schedule file uses these columns:

ColumnDescription
scenarioScenario UID
stageStage UID
blockBlock UID
uid:<N>Value for element with UID N

Multiple elements can appear as additional uid:<N> columns in the same file.

Example: input/Demand/lmax.csv with per-block demand for d1 (uid=1) and d2 (uid=2):

"scenario","stage","block","uid:1","uid:2"
1,1,1,125.0,100.0
1,1,2,130.0,105.0
1,1,3,120.0,95.0

Rows with missing (scenario, stage, block) combinations inherit the last value or zero depending on context.

Using a CSV schedule from JSON:

{
  "uid": 1, "name": "d1", "bus": "b5",
  "lmax": "lmax"
}

When lmax = "lmax", gtopt reads <input_directory>/Demand/lmax.csv (or lmax.parquet) and uses the uid:1 column for this demand.

Convert existing CSV files to Parquet:

# Using the bundled utility
cvs2parquet input/Demand/lmax.csv input/Demand/lmax.parquet

9.3 External Parquet files

Parquet is the preferred format (faster reading, smaller files, typed columns). The schema is identical to CSV: columns scenario, stage, block, and uid:<N> for each element.

Create a Parquet file with Python:

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

# 24-hour demand profile for uid=1 and uid=2
records = [
    {"scenario": 1, "stage": 1, "block": b,
     "uid:1": 125.0 + 20 * (1 if 8 <= b <= 20 else 0),
     "uid:2":  80.0 + 15 * (1 if 7 <= b <= 21 else 0)}
    for b in range(1, 25)
]
df = pd.DataFrame(records)

# Cast index columns to int32, values to float64
for col in ("scenario", "stage", "block"):
    df[col] = df[col].astype("int32")

table = pa.Table.from_pandas(df)
pq.write_table(table, "input/Demand/lmax.parquet")

Read a Parquet file with Python (for validation):

import pyarrow.parquet as pq
table = pq.read_table("input/Demand/lmax.parquet")
print(table.to_pandas().head())

Use cvs2parquet for existing CSV files:

# Single file
cvs2parquet input/Demand/lmax.csv input/Demand/lmax.parquet

# Batch conversion with optional schema enforcement
cvs2parquet --schema input/Generator/pmax.csv input/Generator/pmax.parquet

9.4 Directory layout and file-field naming convention

When a JSON field value is a string it is treated as a filename (without extension). The file is looked up in:

<input_directory>/<ClassName>/<field_name>.<format>

where:

  • <ClassName> is the element's class name (e.g. Generator, Demand, Battery, GeneratorProfile)
  • <field_name> is the string value from JSON (e.g. "lmax", "pmax", "profile")
  • <format> is parquet or csv depending on input_format option

Full directory example:

my_case/
├── my_case.json              # Main planning file
└── input/                    # input_directory = "input"
    ├── Demand/
    │   └── lmax.parquet      # lmax schedule for all demands
    ├── Generator/
    │   ├── pmax.parquet      # pmax schedule for all generators
    │   └── gcost.parquet     # time-varying generation cost
    ├── GeneratorProfile/
    │   └── profile.parquet   # capacity-factor profiles
    ├── Battery/
    │   ├── emin.parquet      # minimum SoC schedule
    │   └── emax.parquet      # maximum SoC schedule
    └── Reservoir/
        └── emax.parquet      # seasonal reservoir limits

JSON linking a demand to an external file:

{
  "uid": 1, "name": "d1", "bus": "b5",
  "lmax": "lmax"
}

This tells gtopt: read input/Demand/lmax.parquet, column uid:1.

JSON linking a generator to multiple external fields:

{
  "uid": 2, "name": "g_wind", "bus": "b3",
  "capacity": 500,
  "pmax": "pmax",
  "gcost": "gcost"
}
  • pmax → column uid:2 in input/Generator/pmax.parquet
  • gcost → column uid:2 in input/Generator/gcost.parquet

Profile files use the GeneratorProfile class name:

{
  "uid": 1, "name": "wind_profile",
  "generator": "g_wind",
  "profile": "profile"
}

input/GeneratorProfile/profile.parquet, column uid:1.

Tip: When input_format = "parquet", gtopt first looks for the .parquet file. If it is absent it falls back to the .csv file.

</blockquote>

10. Complete JSON element reference

Full reference: See Input Data Reference for the complete field-by-field documentation of every JSON element. This section provides a concise summary of the most commonly used fields.

Values can be specified as:

JSON representationDescription
100 (number)Constant scalar in every block/stage
[80, 90]Per-stage values
[[80, 90], [70, 85]]Per-stage, per-block values
"filename" (string)External Parquet/CSV file in input_directory/<Class>/

In summary tables below, ✱ marks required fields.

10.1 Options (key fields)

C++ class: PlanningOptions (header: planning_options.hpp). The JSON key remains "options". See Planning OptionsReference" for the full option hierarchy. </blockquote> <table class="markdownTable"> <tr class="markdownTableHead"> <th class="markdownTableHeadNone"> Field

Default

Description

input_directory

"input"

Root directory for external schedule files

input_format

"parquet"

Preferred input format ("parquet" or "csv")

output_directory

"output"

Root directory for result files

output_format

"parquet"

Output file format ("parquet" or "csv")

output_compression

"zstd"

Parquet/CSV compression codec

model_options.demand_fail_cost

Penalty $/MWh for unserved load (value of lost load)

model_options.reserve_fail_cost

Penalty $/MWh for unserved spinning reserve

model_options.use_kirchhoff

true

Enable DC power-flow constraints

model_options.use_single_bus

false

Copper-plate mode (no network constraints)

model_options.scale_objective

1000

Divide objective coefficients (improves solver numerics)

Note: annual_discount_rate has moved to the simulation section. For backward compatibility, it is still accepted in options.

10.2 Simulation (time structure)

ElementKey fieldsDescription
Blockuid✱, duration✱ (h)Smallest time unit; energy = power × duration
Stageuid✱, first_block, count_block, discount_factorInvestment period grouping consecutive blocks
Scenariouid✱, probability_factorOne realization of uncertain inputs
Phaseuid✱, first_stage, count_stageGroups consecutive stages (advanced)
Sceneuid✱, first_scenario, count_scenarioCross-products scenarios with phases

10.3 System – Electrical network

ElementKey fieldsDescription
Busuid✱, name✱, voltage (kV), reference_thetaElectrical node
Generatoruid✱, name✱, bus✱, pmax, gcost ($/MWh), capacity, expcap, expmod, annual_capcostGeneration unit
Demanduid✱, name✱, bus✱, lmax, capacity, expcap, expmod, annual_capcostElectrical load
Lineuid✱, name✱, bus_a✱, bus_b✱, reactance, tmax_ab, tmax_ba, expcap, expmodTransmission branch

10.4 System – Profiles

ElementKey fieldsDescription
GeneratorProfileuid✱, name✱, generator✱, profile✱ (p.u.)Time-varying capacity factor (solar/wind)
DemandProfileuid✱, name✱, demand✱, profile✱ (p.u.)Time-varying load scaling

10.5 System – Energy storage

Battery (unified recommended): set bus to auto-generate discharge Generator, charge Demand, and Converter automatically.

FieldDescription
uid✱, name✱, busIdentity and bus connection (enables unified definition)
input_efficiency, output_efficiencyCharge/discharge efficiencies (p.u.)
emin, emax (MWh)State-of-charge bounds
pmax_charge, pmax_discharge (MW)Power rating (unified definition)
capacity, expcap, expmod, annual_capcostExpansion fields

Converter (traditional definition only): links battery, generator, demand.

10.6 System – Reserves

ElementKey fieldsDescription
ReserveZoneuid✱, name✱, urreq, drreq (MW)Spinning-reserve requirement
ReserveProvisionuid✱, name✱, generator✱, reserve_zones✱, urmax, drmaxLinks generator to reserve zone

10.7 System – Hydro cascade

ElementKey fieldsDescription
Junctionuid✱, name✱, drainHydraulic node
Waterwayuid✱, name✱, junction_a✱, junction_b✱, fmin, fmax (m³/s)Water channel
Flowuid✱, name✱, junction✱, discharge✱ (m³/s)Exogenous inflow/outflow
Reservoiruid✱, name✱, junction✱, emin, emax (dam³), eini, efinWater storage
Filtrationuid✱, name✱, waterway✱, reservoir✱, slope, constantSeepage model
Turbineuid✱, name✱, waterway✱, generator✱, conversion_rate, main_reservoirHydro turbine
ReservoirEfficiencyuid✱, name✱, turbine✱, reservoir✱, mean_efficiency, segmentsVolume-dependent efficiency

See Input Data Reference for full field descriptions, units, and all optional fields.

Volume-dependent turbine efficiency: when a ReservoirEfficiency element is defined, the turbine's conversion rate is updated during SDDP iterations based on the current reservoir volume. The efficiency curve is a concave piecewise-linear function (analogous to PLP's "rendimiento" tables). For monolithic solves, the static conversion_rate is used. See the Mathematical Formulation for the efficiency formula and the SDDP Solver for the update mechanism.


11. Field reference and auto-generated docs

The scripts/gtopt_field_extractor.py utility parses the C++ header files and generates documentation tables directly from the source code. This ensures the documentation stays in sync with the implementation.

Generate a Markdown field reference

# All elements (printed to stdout)
python3 scripts/gtopt_field_extractor.py

# Specific elements
python3 scripts/gtopt_field_extractor.py --elements Generator Demand Line Battery

# Write to a file
python3 scripts/gtopt_field_extractor.py --output docs/field_reference.md

Generate an HTML field reference

python3 scripts/gtopt_field_extractor.py --format html --output docs/field_reference.html

The generated HTML includes:

  • A hyperlinked table of contents
  • Per-element tables with Field / C++ Type / JSON Type / Units / Required / Description
  • Internal anchor links (e.g. #generator, #battery)

Example output (Generator)

FieldC++ TypeJSON TypeUnitsRequiredDescription
uidUidintegerYesUnique identifier
nameNamestringYesGenerator name
busSingleIdinteger|stringYesBus ID where the generator is connected
pminOptTBRealFieldSchednumber|array|stringMWNoMinimum active power output
pmaxOptTBRealFieldSchednumber|array|stringMWNoMaximum active power output
gcostOptTRealFieldSchednumber|array|string$/MWhNoVariable generation cost
capacityOptTRealFieldSchednumber|array|stringMWNoInstalled generation capacity
expcapOptTRealFieldSchednumber|array|stringMWNoCapacity added per expansion module
expmodOptTRealFieldSchednumber|array|stringNoMaximum number of expansion modules
annual_capcostOptTRealFieldSchednumber|array|string$/MW-yearNoAnnualized investment cost

12. Output files

After a successful run, gtopt writes result files in output_directory (default: output/) using the same tabular format as input files.

output/
├── solution.csv                    # Objective, status, iterations
├── Bus/
│   ├── balance_dual.csv            # LMP (Locational Marginal Price) [$/MWh]
│   └── theta_sol.csv               # Voltage angle θ [rad]
├── Generator/
│   ├── generation_sol.csv          # Dispatch [MW]
│   └── generation_cost.csv         # Dispatch cost contribution [$/h]
├── Demand/
│   ├── load_sol.csv                # Served load [MW]
│   ├── fail_sol.csv                # Unserved demand [MW]
│   ├── fail_cost.csv               # Curtailment cost [$/h]
│   └── capacity_dual.csv           # Shadow price of capacity constraint [$/MW]
├── Line/
│   ├── flowp_sol.csv               # Active power flow [MW]
│   └── theta_dual.csv              # Dual of Kirchhoff constraint
├── Battery/
│   └── storage_sol.csv             # State of charge [MWh]
└── Reservoir/
    └── volume_sol.csv              # Reservoir volume [dam³]

solution.csv

ColumnDescription
obj_valueScaled total cost (divide by scale_objective for $/h)
kappaSolver iterations
status0 = optimal; 1 = infeasible; 2 = unbounded; 5 = not solved

Interpreting results

# Check status
grep status output/solution.csv

# Locational Marginal Prices
cat output/Bus/balance_dual.csv

# Verify no load shedding
grep -v "^scenario" output/Demand/fail_sol.csv | awk -F, '{print $NF}' | sort -n | tail -5

Quickstart: Your First Solve

Run the simplest bundled case (ieee_4b_ori) end-to-end in three steps.

1. Get the binary

Build from source (see Building Guide) or download a CI artifact:

# Option A: build from source (requires dependencies)
cmake -S all -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
export PATH=$PWD/build/standalone:$PATH

# Option B: download CI artifact via helper script
export GTOPT_BIN=$(python tools/get_gtopt_binary.py)

2. Run

cd cases/ieee_4b_ori
gtopt ieee_4b_ori.json

Expected log output:

[info] starting gtopt ...
[info] parsing input file ieee_4b_ori.json
[info] creating lp ...
[info] planning ...
[info] writing output ...

3. Check and interpret results

# Solver status: status=0 means optimal
cat output/solution.csv

# Generator dispatch (MW) — g1 ($20) serves most load
cat output/Generator/generation_sol.csv

# Locational Marginal Prices ($/MWh) at each bus
cat output/Bus/balance_dual.csv

# Verify no load shedding (all values should be zero)
cat output/Demand/fail_sol.csv

What to expect: generator g1 ($20/MWh, 300 MW) is cheaper than g2 ($35/MWh, 200 MW), so g1 dispatches most of the 250 MW total load. The LMP at each bus reflects the marginal cost of serving one additional MW there, accounting for transmission constraints.


Quick-start checklist

  1. ☐ Define options (at minimum: model_options.demand_fail_cost, output_format)
  2. ☐ Define simulation (blocks, stages, scenarios)
  3. ☐ Define system (buses, generators, demands)
  4. ☐ Set input_directory and create input files if using external schedules
  5. ☐ Run: gtopt my_case.json
  6. ☐ Check output/solution.csv for status=0
  7. ☐ Inspect output/Generator/generation_sol.csv and output/Demand/fail_sol.csv

13. Working with Stochastic Scenarios

gtopt supports stochastic optimization where uncertain inputs vary across scenarios. Each scenario represents one realization of the uncertainty (e.g., a hydrological year, a weather pattern, or a demand forecast). The solver minimizes expected cost across all scenarios, weighted by their probability_factor.

13.1 Scenario-dependent data

Three element types support scenario-dependent schedules:

ElementFieldTypical use case
FlowdischargeRiver inflows that differ per hydrology year
GeneratorProfileprofileSolar/wind capacity factors per weather scenario
DemandProfileprofileLoad forecast uncertainty per scenario

When a field references an external file (Parquet or CSV), the file contains rows for every (scenario, stage, block) combination. The column layout is:

ColumnTypeDescription
scenarioint32Scenario UID
stageint32Stage UID
blockint32Block UID
uid:Nfloat64Value for element with UID = N

When a field uses an inline JSON array, the outermost dimension is the scenario index:

"profile": [
  [[0.0, 0.85, 0.40]],
  [[0.0, 0.55, 0.20]],
  [[0.0, 0.25, 0.10]]
]

Here, the three outer arrays correspond to scenarios 1, 2, and 3 respectively. Each inner [[...]] contains the [stage][block] values.

Tip: For large cases (hundreds of blocks, many scenarios), always use external Parquet files instead of inline arrays. See Section 9 for file format details.

13.2 Example: Solar plant with stochastic production

This example models a 100 MW solar generator with three weather scenarios over 24 hourly blocks. The scenarios represent:

ScenarioDescriptionProbabilityPeak capacity factor
1Clear sky0.400.85
2Partly cloudy0.350.55
3Overcast0.250.25

Step 1 – Define the simulation with three scenarios:

{
  "simulation": {
    "block_array": [
      {"uid":  1, "duration": 1}, {"uid":  2, "duration": 1},
      {"uid":  3, "duration": 1}, {"uid":  4, "duration": 1},
      {"uid":  5, "duration": 1}, {"uid":  6, "duration": 1},
      {"uid":  7, "duration": 1}, {"uid":  8, "duration": 1},
      {"uid":  9, "duration": 1}, {"uid": 10, "duration": 1},
      {"uid": 11, "duration": 1}, {"uid": 12, "duration": 1},
      {"uid": 13, "duration": 1}, {"uid": 14, "duration": 1},
      {"uid": 15, "duration": 1}, {"uid": 16, "duration": 1},
      {"uid": 17, "duration": 1}, {"uid": 18, "duration": 1},
      {"uid": 19, "duration": 1}, {"uid": 20, "duration": 1},
      {"uid": 21, "duration": 1}, {"uid": 22, "duration": 1},
      {"uid": 23, "duration": 1}, {"uid": 24, "duration": 1}
    ],
    "stage_array":    [{"uid": 1, "first_block": 0, "count_block": 24}],
    "scenario_array": [
      {"uid": 1, "probability_factor": 0.40},
      {"uid": 2, "probability_factor": 0.35},
      {"uid": 3, "probability_factor": 0.25}
    ]
  }
}

Step 2 – Define the generator and its profile:

{
  "system": {
    "generator_array": [
      {
        "uid": 1, "name": "g_solar", "bus": "b1",
        "pmax": 100, "gcost": 0, "capacity": 100
      }
    ],
    "generator_profile_array": [
      {
        "uid": 1, "name": "gp_solar",
        "generator": "g_solar",
        "profile": "solar_profile"
      }
    ]
  }
}

The "solar_profile" string tells gtopt to read input/GeneratorProfile/solar_profile.parquet (or .csv).

Step 3 – Create the profile data file:

The Parquet file input/GeneratorProfile/solar_profile.parquet has the following structure (72 rows = 3 scenarios x 1 stage x 24 blocks):

scenariostageblockuid:1
1110.00
1120.00
1130.00
1140.00
1150.00
1160.00
1170.10
1180.35
1190.60
11100.78
11110.83
11120.85
11130.83
11140.78
11150.65
11160.45
11170.20
11180.05
11190.00
............
2170.05
2180.20
2190.38
21100.48
21110.53
21120.55
21130.52
21140.45
21150.35
21160.22
21170.10
21180.02
............
3170.02
3180.08
3190.15
31100.20
31110.23
31120.25
31130.23
31140.20
31150.15
31160.08
31170.03
31180.00
............

Night hours (blocks 1–6 and 19–24) are 0.00 for all three scenarios.

Create the file with Python:

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

# Hourly capacity factors [blocks 1..24] for each scenario
clear_sky = [
    0.00, 0.00, 0.00, 0.00, 0.00, 0.00,  # blocks 1-6 (night)
    0.10, 0.35, 0.60, 0.78, 0.83, 0.85,  # blocks 7-12 (morning)
    0.83, 0.78, 0.65, 0.45, 0.20, 0.05,  # blocks 13-18 (afternoon)
    0.00, 0.00, 0.00, 0.00, 0.00, 0.00,  # blocks 19-24 (night)
]
partly_cloudy = [
    0.00, 0.00, 0.00, 0.00, 0.00, 0.00,
    0.05, 0.20, 0.38, 0.48, 0.53, 0.55,
    0.52, 0.45, 0.35, 0.22, 0.10, 0.02,
    0.00, 0.00, 0.00, 0.00, 0.00, 0.00,
]
overcast = [
    0.00, 0.00, 0.00, 0.00, 0.00, 0.00,
    0.02, 0.08, 0.15, 0.20, 0.23, 0.25,
    0.23, 0.20, 0.15, 0.08, 0.03, 0.00,
    0.00, 0.00, 0.00, 0.00, 0.00, 0.00,
]

rows = []
for scen_uid, factors in [(1, clear_sky), (2, partly_cloudy), (3, overcast)]:
    for blk in range(1, 25):
        rows.append({
            "scenario": scen_uid, "stage": 1, "block": blk,
            "uid:1": factors[blk - 1],
        })

df = pd.DataFrame(rows)
for col in ("scenario", "stage", "block"):
    df[col] = df[col].astype("int32")

pq.write_table(pa.Table.from_pandas(df),
               "input/GeneratorProfile/solar_profile.parquet")

Step 4 – Understand the dispatch:

The effective power output of the solar generator in each scenario and block is:

power_output[scenario][block] = pmax * profile[scenario][block]

For example, at block 12 (solar noon):

  • Scenario 1 (clear sky): 100 MW * 0.85 = 85 MW
  • Scenario 2 (partly cloudy): 100 MW * 0.55 = 55 MW
  • Scenario 3 (overcast): 100 MW * 0.25 = 25 MW

The optimizer dispatches thermal backup (if present) to cover the remaining demand in each scenario. The total objective is the probability-weighted sum of all scenario costs.

13.3 Pasada hydro mode

Run-of-river (pasada) hydro plants can be modelled in two ways:

  1. Generator profiles – Use a GeneratorProfile with normalized capacity factors (0.0 to 1.0), just like a solar or wind plant. The effective output is pmax * profile[scenario][block]. This is the simpler approach and works well when the hydro plant has no upstream reservoir or complex water balance.
  2. Full hydro topology – Use the hydro cascade elements (Junction, Waterway, Flow, Turbine) with raw discharge values in m3/s. The plp2gtopt converter supports --pasada-hydro mode, which creates the full hydraulic topology from PLP pasada central definitions. This approach models physical water balance and is required when the plant participates in a cascade with reservoirs upstream or downstream.

See Example 5 for the hydro cascade data model. See Scripts Guide for plp2gtopt usage.

13.4 SDDP apertures

In SDDP (Stochastic Dual Dynamic Programming) mode, gtopt uses apertures to sample additional hydrology and scenario combinations during the backward pass. Apertures provide a richer sampling of uncertainty when building Benders optimality cuts, improving the quality of the value function approximation.

The aperture_array in the simulation block maps each aperture UID to a source scenario:

{
  "simulation": {
    "scenario_array": [
      {"uid": 1, "probability_factor": 0.5},
      {"uid": 2, "probability_factor": 0.5}
    ],
    "aperture_array": [
      {"uid": 1, "scenario": 1},
      {"uid": 2, "scenario": 2},
      {"uid": 3, "scenario": 1},
      {"uid": 4, "scenario": 2}
    ]
  }
}

When the SDDP solver switches to an aperture during the backward pass, all scenario-dependent data – Flow.discharge, GeneratorProfile.profile, and DemandProfile.profile – is updated to use the values from the aperture's source scenario. This allows the backward pass to evaluate costs under different realizations of uncertainty without increasing the number of forward scenarios.

Key distinction: Scenarios define the forward-pass trajectories and contribute to the expected-cost objective. Apertures are used only in the backward pass to improve cut quality. A typical configuration uses 2–5 forward scenarios with 10–50 apertures sampling from those scenarios.

</blockquote>

14. Using the Cascade Solver

The cascade solver (method = "cascade") runs multiple SDDP levels in sequence, each with its own LP formulation and solver parameters. It accelerates convergence by starting from a simplified model and progressively refining towards the full network.

Full reference: Cascade Method — configuration fields, transfer mechanisms, implementation details.

14.1 When to use cascade vs plain SDDP

CriterionPlain SDDPCascade
Network complexitySingle formulationGraduated: uninodal → transport → full
Convergence speedAll iterations on full LPFaster via warm-start from simpler models
LP rebuild costOne LP buildMultiple LP builds (amortized by fewer iterations)
Configuration effortMinimalPer-level settings required

Use the cascade solver when:

  • The full network model (Kirchhoff + line losses) takes many iterations to converge.
  • A simplified model (single-bus) converges quickly and provides a good starting point.
  • The problem has many phases (stages), increasing the cut convergence time.

14.2 Example: 2-level cascade (uninodal warm-start)

This example uses the sddp_hydro_3phase test case — a single-bus system with a hydro generator, a thermal generator, and a reservoir, decomposed into 3 phases (one stage each with 24 blocks).

Level 0 (uninodal Benders): solves a copper-plate model quickly, producing a rough solution trajectory for reservoir volumes.

Level 1 (full SDDP with targets): builds the full LP and inherits elastic target constraints from the Level 0 reservoir volumes, guiding the forward pass.

{
  "options": {
    "method": "cascade",
    "model_options": {
      "use_single_bus": true,
      "scale_objective": 1.0,
      "demand_fail_cost": 1000
    },
    "sddp_options": {
      "max_iterations": 30,
      "convergence_tol": 0.001
    },
    "cascade_options": {
      "level_array": [
        {
          "name": "uninodal_benders",
          "model_options": {
            "use_single_bus": true
          },
          "sddp_options": {
            "max_iterations": 10,
            "apertures": []
          }
        },
        {
          "name": "full_sddp",
          "model_options": {
            "use_single_bus": true
          },
          "sddp_options": {
            "max_iterations": 20,
            "convergence_tol": 0.001
          },
          "transition": {
            "inherit_targets": true,
            "target_rtol": 0.05,
            "target_min_atol": 1.0,
            "target_penalty": 500
          }
        }
      ]
    }
  }
}

How it works:

┌─────────────────────────────────────────────────────┐
│  Level 0: uninodal_benders                          │
│  ┌──────────┐  ┌──────────┐       ┌──────────┐     │
│  │ Phase 1   │→ │ Phase 2   │→ … → │ Phase T   │    │
│  │ (forward) │  │ (forward) │      │ (forward) │    │
│  └────┬─────┘  └────┬─────┘       └────┬─────┘    │
│       ↑ cuts        ↑ cuts              ↑ cuts     │
│  ┌────┴─────┐  ┌────┴─────┐       ┌────┴─────┐    │
│  │ Phase 1   │← │ Phase 2   │← … ← │ Phase T   │   │
│  │ (backward)│  │ (backward)│      │ (backward)│    │
│  └──────────┘  └──────────┘       └──────────┘     │
│                                                     │
│  → Converges quickly (single bus, no Kirchhoff)     │
│  → Extracts reservoir/battery state trajectories    │
└──────────────────────┬──────────────────────────────┘
                       │ targets (elastic constraints)
                       ↓
┌─────────────────────────────────────────────────────┐
│  Level 1: full_sddp                                 │
│  ┌──────────┐  ┌──────────┐       ┌──────────┐     │
│  │ Phase 1   │→ │ Phase 2   │→ … → │ Phase T   │    │
│  │ + targets │  │ + targets │      │ + targets │    │
│  └──────────┘  └──────────┘       └──────────┘     │
│                                                     │
│  → Guided by Level 0 state trajectory               │
│  → Converges in fewer iterations than from scratch  │
└─────────────────────────────────────────────────────┘

Running the cascade:

gtopt sddp_hydro_3phase.json

The solver logs per-level statistics (including gap_change when the stationary-gap criterion is enabled):

[info] ═══ Cascade level 0: uninodal_benders ═══
[info] SDDP iter 1: gap=0.782000 gap_change=1.000000 cuts=3
[info] SDDP iter 2: gap=0.321000 gap_change=1.000000 cuts=6
...
[info] SDDP iter 8: gap=0.002000 gap_change=0.800000 cuts=24 [CONVERGED]
[info] ═══ Cascade level 1: full_sddp ═══
[info] Injecting 9 elastic targets from previous level
[info] SDDP iter 1: gap=0.016000 gap_change=1.000000 cuts=3
...
[info] SDDP iter 5: gap=0.000300 gap_change=0.005000 cuts=15 [CONVERGED]

Tip: When the gap plateaus at a non-zero value (common in stochastic problems with many scenarios), enable the stationary-gap criterion by adding "stationary_tol": 0.01 to sddp_options. The solver will declare convergence when gap_change falls below stationary_tol, logging "stationary gap convergence" instead of the standard "[CONVERGED]" message.

14.3 Example: 3-level progressive refinement

A more advanced cascade that progressively refines both the LP formulation and the solver strategy:

{
  "options": {
    "method": "cascade",
    "sddp_options": {
      "max_iterations": 50,
      "convergence_tol": 0.001
    },
    "cascade_options": {
      "level_array": [
        {
          "name": "benders_uninodal",
          "model_options": {
            "use_single_bus": true
          },
          "sddp_options": {
            "max_iterations": 15,
            "apertures": []
          }
        },
        {
          "name": "guided_full_network",
          "model_options": {
            "use_single_bus": false,
            "use_kirchhoff": true
          },
          "sddp_options": {
            "max_iterations": 20,
            "apertures": []
          },
          "transition": {
            "inherit_targets": true,
            "target_rtol": 0.05,
            "target_min_atol": 1.0,
            "target_penalty": 500
          }
        },
        {
          "name": "refined_with_cuts",
          "sddp_options": {
            "max_iterations": 20
          },
          "transition": {
            "inherit_optimality_cuts": true,
            "inherit_feasibility_cuts": true
          }
        }
      ]
    }
  }
}

How the 3 levels work:

Level 0 (benders_uninodal)           Level 1 (guided_full_network)     Level 2 (refined_with_cuts)
┌──────────────────────┐             ┌──────────────────────┐          ┌──────────────────────┐
│ Single-bus Benders    │   targets   │ Full network SDDP    │  cuts    │ Same LP, inherits    │
│ • Fast convergence    │───────────→ │ • Kirchhoff enabled  │────────→ │ • Warm-started LB    │
│ • Rough trajectory    │             │ • Guided by L0 state │          │ • Fewer iterations   │
│ • Generates base cuts │             │ • Better cuts        │          │ • Final convergence  │
└──────────────────────┘             └──────────────────────┘          └──────────────────────┘

Note that Level 2 omits model_options, so it reuses the Level 1 LP. Only the solver parameters change (cuts inherited, possibly different apertures).

14.4 Monitoring cascade progress

Use sddp_monitor to watch convergence in real time:

# In one terminal: run the solver
gtopt case.json

# In another terminal: monitor convergence
sddp_monitor --status-file output/sddp_status.json

The monitoring dashboard shows per-scene upper/lower bounds and gap across all cascade levels. Level transitions appear as step changes in the bound trajectories.

Alternatively, use gtopt --stats to get a summary of per-level statistics after the solve completes.

See also: SDDP Method — convergence theory, cut sharing, elastic filter, and monitoring API. Cascade Method — full configuration reference.

</blockquote>

See also

  • Mathematical Formulation — Full LP/MIP optimization formulation with LaTeX notation, JSON-to-symbol mapping, and academic references
  • Input Data Reference — Complete field reference for all JSON elements
  • Usage Guide — Command-line options and advanced usage
  • Scripts Guideplp2gtopt, igtopt, cvs2parquet conversion utilities
  • Building Guide — Build and installation instructions
  • Diagram Toolgtopt_diagram network and planning diagram tool: aggregation, voltage reduction, large-case workflows
  • SDDP Method — SDDP solver: convergence theory, cut sharing, elastic filter, monitoring API
  • Cascade Method — Cascade solver: multi-level hybrid SDDP with cut and target inheritance
  • Monolithic Method — Default monolithic solver, boundary cuts, and sequential mode
  • scripts/gtopt_field_extractor.py — Auto-generate field-reference tables from C++ headers