OSeCAN — model description
The single reference for what the model is, how the code builds it, and how policy constraints (UDC) work. Supersedes the earlier architecture / model-structure / transmission / spatial-design notes.
Version: v0.3 · single OSeMOSYS REGION, zone-coded geography, transmission as
technologies, provincial policy via UDC.
1. What OSeCAN is
The CLEWs energy + biomass core for CLEWs-C2070 (NRCan, EIP-NES-147), built on the OSeMOSYS framework and the BCNexus architecture.
- Land and water are external. WS3 (forestry/biomass) and FABLE (agriculture) resolve
them; OSeCAN carries biomass as a primary fuel and couples at the feedstock seam.
INCLUDE_LAND_WATER = False; the set builder raises if asked to build land/water. - Horizon 2021–2070. Units PJ (energy), GW (capacity), Mt CO₂, $2020.
2. Design decisions
2.1 Spatial: single REGION, zone-coded geography (Elias & Taco, 2026-07-22)
The OSeMOSYS REGION set stays single (CAN). Provinces and sub-provincial zones live in
the naming convention. The alternative — provinces as OSeMOSYS regions — was prototyped and
rejected.
2.2 Transmission: technologies, not Trade
Every link, intra-provincial and interprovincial alike, is an ordinary technology.
Why?
OSeMOSYSTradeis weak: in the stock formulation it is unbounded, cost-free and lossless, with no investment decision. Technologies already do everything transmission needs — losses, costs, maximum capacities, lifetimes, investment — so rebuilding that onTradeis wasted effort. Later OSeMOSYS versions (MUIO) drop trade for this reason.
2.3 Provincial policy: User Defined Constraints
Because there is only one REGION, per-province policy is not available through
AnnualEmissionLimit / REMinProductionTarget. It is expressed instead with UDC, which
constrains a group of technologies — see §6.
3. Naming convention
Geography is encoded in fixed-width 2-character zone codes (<province letter><index>),
so every code can be parsed by slice. 13 zones over 9 provinces; the four largest are split.
B1 B2 British Columbia O1 O2 Ontario T1 Atlantic (NB+NS+PE)
A1 A2 Alberta Q1 Q2 Quebec L1 Newfoundland & Labrador
S1 Saskatchewan M1 Manitoba N1 North (territories, islanded)
| Pattern | Meaning | Example |
|---|---|---|
DEM<sector:3><fuel:3><zone:2> |
demand technology | DEMRESELCB1 |
<sector:3><fuel:3><zone:2> |
demand fuel | RESELCB1 |
PWR<arch:3><zone:2> |
power plant | PWRHYDB1 |
ELC<zone:2>01 / ELC<zone:2>02 |
generation bus / grid bus | ELCB101 |
PWRTRN<zone:2> |
within-zone distribution | PWRTRNB1 |
TRN<from:2><to:2> |
transmission line | TRNB1A1 |
MIN/IMP/RNW/SUP+<fuel:3> |
supply | SUPBCW |
Fixed width matters
Several modules parse codes positionally (t[3:6] for archetype, t[-2:] for zone).
Keep archetypes 3 characters and zones 2, or add a parsing helper first.
4. How the sets are generated
sets_n_ratios.BuildOSeCAN() walks the declarative dictionaries in model_structure.py and
emits TECHNOLOGY, FUEL, InputActivityRatio, OutputActivityRatio, MODE_OF_OPERATION
and UDC.
| Rule | Produces |
|---|---|
| zone × sector × end-use fuel | demand fuel + DEM… technology |
| zone × power archetype | PWR<arch><z> → ELC<z>01 |
| every zone | PWRTRN<z> : ELC<z>01 → ELC<z>02 |
| every link, both directions | TRN<from><to> : ELC<from>02 → ELC<to>02 |
| import / mining / renewable / feedstock | IMP*, MIN*, RNW*, SUP* |
each ProvincialPolicies entry |
a member of the UDC set |
Current size: 522 technologies, 379 fuels, 13 zones, 1 region.
5. Transmission
One mechanism for everything. link_kind(a, b) only reports whether the endpoint zones sit in
the same province — the model treats both identically.
- Intra-provincial (8 techs):
TRNB1B2,TRNA1A2,TRNO1O2,TRNQ1Q2+ reverses - Interprovincial (16 techs): BC↔AB, AB↔SK, SK↔MB, MB↔ON, ON↔QC, QC↔Atlantic, northern QC↔Labrador (Churchill Falls), Atlantic↔NL + reverses
Each link carries line efficiency (LineOutputActivityRatio = 0.95), residual capacity from
TransmissionLinks, capital cost and a 50-year life — so expansion is endogenous.
The territories (N1) have no links and are islanded.
6. User Defined Constraints (UDC)
The mechanism for provincial policy. Added to the model file by our patch; mirrors the MUIO formulation (see
resources/Hands-on 14 (UI).pdf).
6.1 The formulation
A UDC constrains a group of technologies in standard form:
$$\sum_{t}\Big( m^{act}{t}\cdot Activity} \;+\; m^{cap{t}\cdot TotalCapacity} \;+\; m^{new{t}\cdot NewCapacity \Big)\;\;\lessgtr\;\; UDCConstant$$
UDCTag = 0 → <= · UDCTag = 1 → =
Added to resources/osemosys_fast_osecan.txt:
set UDC;
param UDCConstant{r in REGION, u in UDC, y in YEAR};
param UDCTag{r in REGION, u in UDC};
param UDCMultiplierTotalCapacity{r in REGION, t in TECHNOLOGY, u in UDC, y in YEAR};
param UDCMultiplierNewCapacity {r in REGION, t in TECHNOLOGY, u in UDC, y in YEAR};
param UDCMultiplierActivity {r in REGION, t in TECHNOLOGY, u in UDC, y in YEAR};
s.t. UDC1_UserDefinedConstraintInequality{...: UDCTag[r,u] = 0}: ... <= UDCConstant[r,u,y];
s.t. UDC2_UserDefinedConstraintEquality {...: UDCTag[r,u] = 1}: ... = UDCConstant[r,u,y];
Activity is sum{l, m} RateOfActivity[r,l,t,m,y] * YearSplit[l,y].
6.2 Declaring a policy
Policies are declarative in model_structure.ProvincialPolicies; build_params translates
them into UDC rows automatically.
ProvincialPolicies = {
"BC": {"re_share": {2035: 0.95, 2050: 0.98}}, # min renewable share of the province
"AB": {"emission_cap_mt": {2035: 25.0}}, # max annual CO2 from the province
}
Schedules interpolate linearly between the given years and hold flat after the last.
6.3 How each policy becomes multipliers
Renewable share (province p, share s). Following the MUIO formulation, the distribution technologies proxy total electricity flowing through the province:
$$IAR \cdot s \cdot \sum_{z \in p} Activity_{PWRTRN_z} \;-\; \sum_{z \in p}\sum_{a \in RE} Activity_{PWR_{a,z}} \;\le\; 0$$
| Technology | UDCMultiplierActivity |
|---|---|
PWRTRN<zone in p> |
+ DistributionInputActivityRatio × s |
PWR<renewable arch><zone in p> |
− 1 |
UDCConstant |
0 |
Example: a 50 % Alberta target gives PWRTRNA1 = +0.555 (1.11 × 0.50) and each Alberta
renewable −1.0.
Emission cap (province p, limit L Mt):
| Technology | UDCMultiplierActivity |
|---|---|
each emitting PWR<arch><zone in p> |
its CO₂ emission factor (Mt/PJ) |
UDCConstant |
L |
6.4 Adding a new policy type
- Add a key under a province in
ProvincialPolicies. - In
build_params.build(), add a branch in the UDC loop writingUDCConstantand the relevantUDCMultiplier*rows. - Rebuild sets (the
UDCset is generated from the policy keys), thenharmonize.
6.5 Testing a UDC — the discipline
A constraint that doesn't bind proves nothing
Our first UDC test appeared correct but changed nothing: under placeholder data Alberta is already ~99.7 % renewable, so a 70 % minimum was slack. Slack constraints are indistinguishable from broken ones.
Always run base vs policy and compare. To verify the mechanism itself, apply a deliberately restrictive constraint:
| Test | Result |
|---|---|
PWRHYDA1 activity ≤ 100 PJ |
activity fell ~197–212 PJ → exactly 100.0 PJ every year |
| objective | 424,256 → 424,946 (costlier substitution) |
| constraint count | +1 row per year, as expected |
That is what a working constraint looks like.
7. The code
Every module, and how they connect. runner.py is the orchestrator; everything else is
something it (or the CLI) calls.
model_structure.py ──► sets_n_ratios.py ──► SETs/
│
build_params.py ──────────────┤
clean_demand.py ──────────────┤──► data/params/ ──► harmonize.py (gate)
│
└──► pipeline/assemble.py ──► otoole ──► datafile
└──► pipeline/solve.py ──► results_csv/
└──► diagnostics.py, review.py
└──► vis/report.py, vis/transmission_map.py
Structure layer
| Module | Role |
|---|---|
model_structure.py |
The declarative vocabulary — you edit this. Provinces, zones, end-use fuels, power archetypes, biomass seam, transmission links, supply lists, ProvincialPolicies. |
sets_n_ratios.py |
Generates the sets and activity ratios from the structure. BuildOSeCAN() (in memory) / build() (writes CSVs). Raises on land/water. |
Data layer (staged: external → interim → mapped → params)
| Module | Role | CLI |
|---|---|---|
data/retrieve.py |
Downloads open datasets, records sha256 + licence in manifest.json |
osecan retrieve |
data/profile.py |
Prints a raw dataset's schema before you write a cleaner | osecan profile <csv> |
data/clean_demand.py |
CER end-use demand → SpecifiedAnnualDemand, split across zones |
osecan clean-demand |
data/build_params.py |
Full placeholder parameter set + transmission capacity + UDC rows | osecan build-params |
data/harmonize.py |
Gate: every param references a valid SET code/year; non-zero exit on failure | osecan harmonize |
Pipeline layer
| Module | Role |
|---|---|
pipeline/assemble.py |
Merges SETS + params, creates header-only CSVs for anything otoole expects but we don't produce |
pipeline/solve.py |
Preprocess → GLPK (swiglpk, no glpsol binary) → extracts capacity, generation, emissions, flows, cost |
pipeline/runner.py |
Chains the stages; writes the diagnostics bundle |
pipeline/diagnostics.py |
status.json, runtime_memory_log.txt, solver log, and an infeasibility checklist |
pipeline/review.py |
Appends every run to modelling_review.md |
Visualisation
| Module | Role | CLI |
|---|---|---|
vis/report.py |
Template-driven HTML dashboard — all inputs + outputs, each with an info button | osecan report |
vis/transmission_map.py |
GADM map; line width ∝ capacity or solved flow, direction arrows | osecan map |
vis/templates/report_template.html |
The report shell (light theme, column switcher) | — |
The model file
resources/osemosys_fast_osecan.txt = the stock OSeMOSYS FAST (preprocessed) model
plus the UDC block. It expects a preprocessed datafile (resources/preprocess_data.py
adds the index sets that speed matrix generation).
8. Running it
uv sync # locked environment
uv run osecan build-sets && uv run osecan build-params
uv run osecan retrieve # once, to fetch open data
uv run osecan clean-demand "Current Measures"
uv run osecan harmonize
uv run python -m osecan.pipeline.assemble --out results/base/input
uv run otoole convert csv datafile results/base/input results/base/osecan.txt config/otoole.yaml
uv run osecan solve --run results/base
uv run osecan map --run results/base && uv run osecan report --run results/base
Notebooks: notebooks/model_building.ipynb (structure & modification),
notebooks/osecan_workflow.ipynb (staged run → solve → plots → policy comparison).
9. Status
| v0.3 | |
|---|---|
| REGION | single (CAN) |
| Zones | 13 (fixed-width 2-char codes) |
| Technologies / fuels | 522 / 379 |
| Transmission | 24 technologies (8 intra-, 16 interprovincial), investable |
Trade |
not used |
| Provincial policy | UDC over technology groups — verified binding |
| Demand | real CER Energy Futures 2026 (9,328 PJ national, 2025) |
| Other parameters | placeholders — see data/params/PLACEHOLDERS.md |
| Solve | optimal · 43,681 × 37,584 · objective 424,256 |
| Tests | 6/6 |
See also: Data pipeline · Solve & diagnostics · Roadmap
Delta E+ · SFU