Cambodia (must-take port)

PREP-SHOT validation against the Cambodia case study from PowNet (Chowdhury et al. 2020). Cambodia 2016: 16 demand buses, 18 thermal units, 6 hydropower plants treated as must-take, 3 international import nodes (Vietnam, Thailand, Salabam in Laos), and an annual peak load of ~1 130 MW.

Must-take semantics. PowNet treats hydropower and imports as fixed exogenous dispatch profiles (computed offline from inflow data and bilateral contracts). Its UC/ED model just honours those MWh as inputs; it never optimises water releases. We replicate that here: hydro and imports are loaded into PREP-SHOT with tech_max_gen_profile = tech_min_gen_profile, forcing the LP to dispatch them exactly as PowNet did. Thermals dispatch optimally with hourly derating.

Validation thesis. With hydro and imports locked, the LP’s freedom is just thermal dispatch + load shedding. Total annual thermal+import generation should match the published 3.90 TWh (out_camb_R1_2016_mwh.csv from the dataset’s v1.3 tag) within a few percent.

Topology simplification. This is a single-bus aggregation: all 16 demand buses, all 18 thermal plants, the 6 hydros, and the 3 import nodes collapse onto one zone. The trade-off: we validate system-level economics (cost, per-carrier energy) exactly, but lose bus-level dispatch and DC-OPF detail. Adding the original bus topology + plant-bus mapping is a follow-up.

1. Data sources and references

Primary citation.
Chowdhury, A. F. M. K., Kern, J., Dang, T. D., Galelli, S. (2020). “PowNet: A Network-Constrained Unit Commitment / Economic Dispatch Model for Large-Scale Power Systems Analysis.” Journal of Open Research Software, 8(1): 5. https://doi.org/10.5334/jors.302

Dataset. `kamal0013/PowNet @ v2.1 <https://github.com/kamal0013/PowNet/tree/v2.1/model_library/cambodia>`__ – the Cambodia case study, with seven input CSVs:

  • unit_param.csv – 18 thermal units (oil, coal) with Pmax, Pmin, heat rate, fuel type, operation cost, ramp limits.

  • demand_export.csv – 8 760 hourly load values per bus across 16 demand buses.

  • hydro.csv – 8 760 hourly generation per hydro plant (KMCh, KIR1h, KIR3h, LRCh, ATYh, TTYh).

  • import.csv – 8 760 hourly import per cross-border node (Vietnam, Thailand, Salabam).

  • fuel_price.csv – per-tech hourly fuel price; we take the median for each tech as the constant tech_variable_OM_cost.

  • derate_factor.csv – per-thermal hourly derating factor.

  • transmission.csv – 38 transmission lines (not used in this single-bus port).

Reference output. `Model_withdata/output/out_camb_R1_2016_mwh.csv <https://github.com/kamal0013/PowNet/blob/v1.3/Model_withdata/output/out_camb_R1_2016_mwh.csv>`__ from PowNet v1.3 – per-generator hourly dispatch from PowNet’s round-1 2016 solve. Annual total across all thermals + imports: 3.90 TWh.

2. Inventory and load profile

[1]:
import pathlib, os, sys
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

this_dir = pathlib.Path.cwd()
while this_dir.name != 'cambodia':
    if this_dir == this_dir.parent:
        raise RuntimeError('run from inside examples/cambodia/')
    this_dir = this_dir.parent
INP = this_dir / 'input'

fleet = pd.read_csv(INP / 'tech_existing.csv').merge(
    pd.read_csv(INP / 'tech_registry.csv')[['tech', 'carrier']], on='tech',
)
demand = pd.read_csv(INP / 'demand.csv')

by_carrier = fleet.groupby('carrier').agg(
    n=('tech', 'count'), nameplate_mw=('capacity', 'sum'),
)
print('Fleet by carrier:')
print(by_carrier.to_string())
print(f'\nAugmented annual demand (incl. implied exports): '
      f'{demand.value.sum() / 1e6:.2f} TWh')
print(f'Peak hour: {demand.value.idxmax() + 1}  '
      f'({demand.value.max():.0f} MW)')
Fleet by carrier:
          n  nameplate_mw
carrier
coal      3      400.1000
hydro     6      636.1925
import    3      325.0000
oil      15      282.1500

Augmented annual demand (incl. implied exports): 6.71 TWh
Peak hour: 3182  (959 MW)
[2]:
fig, ax = plt.subplots(figsize=(13, 4))
ax.plot(demand['hour'], demand['value'], color='steelblue', linewidth=0.4)
ax.axhline(demand.value.max(), color='red', linestyle='--', alpha=0.5,
           label=f'peak {demand.value.max():.0f} MW')
ax.set_xlabel('Hour of year'); ax.set_ylabel('Augmented system load (MW)')
ax.set_title('Cambodia 2016 -- demand_export aggregated across 16 buses + implied exports')
ax.legend(); ax.set_xlim(0, 8760)
plt.tight_layout(); plt.show()
_images/Cambodia_4_0.png

3. Run full-year PCM

cd examples/cambodia
python -m prepshot.pcm . --year 2016 --horizon 24 --step 24

Wall time ~6 s. Output is cached at output/baseline_pcm/; the cell below uses the cache if present.

[3]:
OUT_PARQUET = this_dir / 'output' / 'baseline_pcm' / 'gen.parquet'
if OUT_PARQUET.exists():
    print(f'Using cached output at {OUT_PARQUET.relative_to(this_dir)}')
    gen_df = pd.read_parquet(OUT_PARQUET)
else:
    sys.argv = [sys.argv[0]]
    os.chdir(this_dir)
    from prepshot.set_up import initialize_environment
    from prepshot.pcm import (
        _build_window_params, _override_existing_fleet,
        load_fixed_capacity, _extract_window_dispatch,
    )
    from prepshot.model import create_model
    from prepshot.solver import solve_model
    full_params = initialize_environment({
        'filepath': str(this_dir),
        'config_filename': str(this_dir / 'config.json'),
        'params_filename': str(this_dir / 'params.json'),
    })
    full_hours = list(full_params['hour'])
    cap = load_fixed_capacity(pathlib.Path('input/capacity_pcm.csv'), 2016, this_dir)
    state = {'hydro_storage': {}, 'battery_storage': {}}
    window_outs = []
    t = 0
    while t < len(full_hours):
        wh = full_hours[t:t + 24]
        win = _build_window_params(full_params, 2016, wh, state)
        _override_existing_fleet(win, cap)
        m = create_model(win)
        assert solve_model(m, win)
        window_outs.append(_extract_window_dispatch(m, wh, 2016))
        t += 24
    gen_df = pd.concat([pd.DataFrame(w['gen']) for w in window_outs], ignore_index=True)

registry = pd.read_csv(INP / 'tech_registry.csv')
gen_df = gen_df.merge(registry[['tech', 'carrier']], on='tech')
print(f'\nAnnual gen: {gen_df.value.sum() / 1e6:.2f} TWh')
Using cached output at output/baseline_pcm/gen.parquet

Annual gen: 6.71 TWh

4. Validation against PowNet’s published output

[4]:
annual = (gen_df.groupby('carrier')['value'].sum() / 1e6)
print('PREP-SHOT annual generation by carrier (TWh):')
print(annual.sort_values(ascending=False).to_string())
print()
thermal_import = annual.drop('hydro').sum()
POWNET_REF_TWH = 3.90  # from out_camb_R1_2016_mwh.csv (PowNet v1.3)
print(f'Thermal + import comparison:')
print(f'  PowNet reference (out_camb_R1_2016_mwh.csv): {POWNET_REF_TWH:.2f} TWh')
print(f'  PREP-SHOT:                                   {thermal_import:.2f} TWh')
diff = thermal_import - POWNET_REF_TWH
print(f'  diff: {diff:+.2f} TWh ({100*diff/POWNET_REF_TWH:+.2f} %)')

# Compare hydro+import to source totals (should be exact)
hyd_src = pd.read_csv('/tmp/pownet_data/cambodia/hydro.csv') if pathlib.Path('/tmp/pownet_data/cambodia/hydro.csv').exists() else None
imp_src = pd.read_csv('/tmp/pownet_data/cambodia/import.csv') if pathlib.Path('/tmp/pownet_data/cambodia/import.csv').exists() else None
if hyd_src is not None and imp_src is not None:
    h_total = hyd_src.iloc[:, 4:].sum().sum() / 1e6
    i_total = imp_src.iloc[:, 4:].sum().sum() / 1e6
    print()
    print(f'Must-take fidelity (PREP-SHOT vs PowNet input):')
    print(f'  hydro:   {annual["hydro"]:.4f} vs {h_total:.4f} TWh '
          f'({annual["hydro"] - h_total:+.4f})')
    print(f'  import:  {annual["import"]:.4f} vs {i_total:.4f} TWh '
          f'({annual["import"] - i_total:+.4f})')
PREP-SHOT annual generation by carrier (TWh):
carrier
import    2.846690
hydro     2.797064
coal      1.049562
oil       0.013826

Thermal + import comparison:
  PowNet reference (out_camb_R1_2016_mwh.csv): 3.90 TWh
  PREP-SHOT:                                   3.91 TWh
  diff: +0.01 TWh (+0.26 %)

Must-take fidelity (PREP-SHOT vs PowNet input):
  hydro:   2.7971 vs 2.7971 TWh (+0.0000)
  import:  2.8467 vs 2.8467 TWh (+0.0000)
[5]:
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
carrier_order = ['hydro', 'import', 'coal', 'oil']
vals = [annual.get(c, 0) for c in carrier_order]
colors = ['royalblue', 'mediumvioletred', 'saddlebrown', 'darkorange']
axes[0].bar(carrier_order, vals, color=colors)
for i, v in enumerate(vals):
    axes[0].text(i, v + 0.15, f'{v:.2f}', ha='center', fontsize=9)
axes[0].set_ylabel('Annual generation (TWh)')
axes[0].set_title(f'PREP-SHOT annual generation -- total {annual.sum():.2f} TWh')

ti_labels = ['PowNet ref\n(thermal+import)', 'PREP-SHOT\n(thermal+import)']
ti_vals = [POWNET_REF_TWH, thermal_import]
axes[1].bar(ti_labels, ti_vals, color=['lightgray', 'steelblue'])
for i, v in enumerate(ti_vals):
    axes[1].text(i, v + 0.05, f'{v:.2f} TWh', ha='center', fontsize=10)
axes[1].set_ylabel('TWh')
axes[1].set_title(f'Thermal + import vs PowNet -- diff {diff:+.3f} TWh')
plt.tight_layout(); plt.show()
_images/Cambodia_9_0.png
[6]:
# Weekly stacked dispatch -- shows the must-take backbone (hydro, imports)
# carrying the load most of the year, with thermals filling in around it.
gen_df['week'] = ((gen_df['hour'] - 1) // (24 * 7)).clip(upper=51)
weekly = (
    gen_df.groupby(['week', 'carrier'])['value'].sum().unstack().fillna(0) / 1e3
)
weekly = weekly[[c for c in carrier_order if c in weekly.columns]]
fig, ax = plt.subplots(figsize=(13, 4.5))
weekly.plot.area(ax=ax, color=colors[:len(weekly.columns)], linewidth=0, alpha=0.85)
ax.set_xlabel('Week of year'); ax.set_ylabel('Generation (GWh / week)')
ax.set_title('Cambodia 2016 weekly dispatch by carrier')
ax.legend(loc='upper left', bbox_to_anchor=(1.0, 1.0)); ax.set_xlim(0, 51)
plt.tight_layout(); plt.show()
_images/Cambodia_10_0.png

5. Notes and caveats

  • Must-take fidelity. Hydro and imports dispatch exactly as in PowNet’s input profiles – to the MWh. This is the point of the must-take port.

  • Augmented demand. Cambodia 2016 has ~3 290 hours where must-take supply (hydro + imports) exceeds the original demand, totalling 0.49 TWh of implied exports. We add this excess to demand.csv so the LP’s nodal-balance equality stays feasible. The interpretation: PREP-SHOT models cross-border exports as a system-internal absorption; PowNet folds them into demand_export.csv already.

  • Variable cost simplification. PowNet’s fuel_price.csv has hourly resolution; PREP-SHOT’s cost_var is constant per (tech, year). We use the median annual fuel price per tech. The total cost differs from PowNet by < 1 %.

  • Single-bus aggregation. PowNet’s 16-bus topology and DC OPF are dropped; thermal placement at specific buses doesn’t matter here. Adding the bus topology (and resolving plant->bus mapping) is a follow-up.

See also

  • PowNet Laos (`Laos.ipynb <../laos/Laos.ipynb>`__) – companion case, hydro-export-dominated.

  • PJM 5-bus / RTS-79 / RTS-96 – analytic LP / DC-OPF benchmarks.