C2070 Delta E+Delta E+ · SFU

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.

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?
OSeMOSYS Trade is 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 on Trade is 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>01ELC<z>02
every link, both directions TRN<from><to> : ELC<from>02ELC<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.

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

  1. Add a key under a province in ProvincialPolicies.
  2. In build_params.build(), add a branch in the UDC loop writing UDCConstant and the relevant UDCMultiplier* rows.
  3. Rebuild sets (the UDC set is generated from the policy keys), then harmonize.

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