Skip to content

Commit 1b7940b

Browse files
authored
Merge pull request #470 from switchbox-data/469-implement-pjm-supply-capacity-marginal-cost-supply_capacity_pjmpy-for-bge
Implement PJM supply capacity marginal cost (RPM 5CP) for BGE
2 parents dd40aae + 3edbc1d commit 1b7940b

6 files changed

Lines changed: 801 additions & 35 deletions

File tree

context/domain/marginal_costs/pjm_supply_capacity_marginal_cost.md

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Related docs:
66

77
- `context/methods/marginal_costs/capacity_market_comparison_nyiso_isone.md` — NYISO ICAP vs ISO-NE FCM
88
- `context/code/marginal_costs/ny_supply_marginal_costs.md` — NY implementation (monthly ICAP)
9-
- `utils/pre/marginal_costs/supply_capacity_isone.py` — closest platform template (annual FCA + exceedance)
9+
- `utils/data_prep/marginal_costs/supply_capacity_isone.py` — closest platform template (annual FCA + exceedance)
1010

1111
---
1212

@@ -144,14 +144,16 @@ $$\text{LRC} = \text{Daily UCAP Obligation (MW)} \times \text{Final Zonal Capaci
144144

145145
### Annualizing for BAT
146146

147-
For a **calendar year** analysis (e.g. 2025), blend two overlapping delivery years (same logic as ISO-NE CCP):
147+
For a **calendar year** analysis (e.g. 2025), blend the two overlapping delivery years (same idea as the ISO-NE CCP blend):
148148

149-
- **DY1** (Jun 2024–May 2025): covers JanMay of calendar year**5 months**
150-
- **DY2** (Jun 2025–May 2026): covers JunDec of calendar year**7 months**
149+
- **DY1** (Jun 2024–May 2025): covers Jan 1 – May 31 of the calendar year
150+
- **DY2** (Jun 2025–May 2026): covers Jun 1 – Dec 31 of the calendar year
151151

152-
$$\text{capacity\_cost\_kw\_year} = P_{\text{DY1}} \times 5 + P_{\text{DY2}} \times 7$$
152+
The RPM Final Zonal Capacity Price is natively `$/MW-day`, so we blend by the **actual number of calendar-year days** each DY covers (an exact day-count blend), rather than ISO-NE's `$/kW-month × months` shape:
153153

154-
where $P$ is the annualized `$/kW-year` from Final Zonal Capacity Price (convert from `$/MW-day`).
154+
$$\overline{P}_{\text{kW-yr}} = \frac{P^{\text{day}}_{\text{DY1}} \cdot d_{\text{Jan–May}} + P^{\text{day}}_{\text{DY2}} \cdot d_{\text{Jun–Dec}}}{1000}$$
155+
156+
where $P^{\text{day}}$ is the Final Zonal Capacity Price in `$/MW-day`, $d_{\text{Jan–May}}$ is 151 days (152 in a leap year), $d_{\text{Jun–Dec}}$ is 214 days, and the `/1000` converts MW → kW. (The earlier `× 5 / × 7` month split is the same idea approximated to whole months; the day-count form is exact and dimensionally consistent for a daily price.)
155157

156158
---
157159

@@ -227,7 +229,16 @@ Each decision below is **independent**. Recommended defaults are marked ★.
227229
| **F2 Exceedance weighting** | Threshold-exceedance among the 5 hours (or top-K from summer pool) | Consistent with `allocate_annual_exceedance_to_hours` in `supply_utils.py`; used for RI | **Not** how PLC is defined (average, not exceedance) |
228230
| **F3 Load-proportional** | Weight by zone load in each hour | Simple physical interpretation | Still not identical to PLC reconciliation |
229231

230-
**Recommendation:** F1 if prioritizing PJM fidelity; F2 if prioritizing platform consistency. Document the choice explicitly.
232+
**Decision: F1 (equal 1/K weights).** This is the implemented choice in `supply_capacity_pjm.py`.
233+
234+
**Rationale.** PJM defines a customer's capacity obligation (PLC) as the **simple average** of that customer's reconciled load across the five coincident-peak hours (PJM Manual 19 §4.3; BGE PLC Overview). Averaging across the five hours is mathematically equivalent to giving each hour a weight of exactly **1/5**. Equal weighting is therefore the literal analog of how the capacity obligation is assigned: it is _definitionally_ correct, not merely a convenient default. The exceedance (F2) and load-proportional (F3) alternatives re-introduce a within-peak load signal that the PLC _average_ deliberately removes, so both deviate from the obligation definition. F2 is retained only as a cross-component-consistency sensitivity (it matches the RI dist/bulk-TX convention), not as a capacity-fidelity option.
235+
236+
**Empirical note (BGE).** For BGE the choice is also immaterial in practice: BGE's zonal load at the five summer-2025 5CP hours is nearly flat (≈ 6.1–6.6 GW), so F1 vs. load-weighting changes the within-peak split by only about ±4%. Both options place the same total `$/kW-year` on the same five hours.
237+
238+
**Sources:**
239+
240+
- [PJM Manual 19 §4.3 — Peak Load Allocation (5CP), PLC = average of five hourly loads](https://www.pjm.com/-/media/DotCom/documents/manuals/m19.pdf)
241+
- [BGE Peak Load Contribution (PLC) Overview — "average of … load during those five hours"](https://supplier.bge.com/electric/load/plcs.asp)
231242

232243
---
233244

@@ -278,7 +289,7 @@ For any new PJM utility MC pipeline:
278289
## 7. Zone and LDA
279290

280291
- BGE serves the **BGE zone** in PJM (Maryland territory).
281-
- BGE is **not** a separately constrained LDA; it typically clears at the **RTO** Final Zonal Capacity Price.
292+
- BGE **is frequently a separately constrained LDA**: in the curated RPM dataset it clears at a BGE-specific Final Zonal Capacity Price (above the RTO system price) in 6 of 9 delivery years, including DY2024/25 and DY2025/26 (the DYs that feed calendar-year 2025). The implementation therefore selects the utility's **own zone row** (`zone == "BGE"`) and uses its `final_zonal_capacity_price_per_mw_day`, which already incorporates the locational adder — it does **not** assume the RTO price. (Only DY2018/19 and DY2026/27 had BGE at the RTO price.)
282293
- Pepco/DPL serve other Maryland areas under different zones/LDAs — do not reuse BGE prices for them.
283294

284295
**Sources:**
@@ -376,16 +387,16 @@ Useful for validating order-of-magnitude but **not** a direct hourly MC formula.
376387

377388
## 11. ★ Recommended BGE v1 package
378389

379-
| Parameter | BGE choice |
380-
| ---------------------- | -------------------------------------------------------------------------------------------- |
381-
| LDA / price zone | RTO (BGE zone unconstrained) |
382-
| Price | Final Zonal Capacity Price, 5/7 DY calendar-year blend; sensitivity with IA-trued price |
383-
| K | 5 (PJM 5CP timestamps) |
384-
| Season | Jun 1 – Sep 30 |
385-
| Load | BGE zone load at 5CP timestamps (E1) |
386-
| Weights | Equal 1/5 (F1, PLC-average) |
387-
| Bill side | Schedule R flat SOS; note PSC shoulder smoothing as non-MC |
388-
| Output path (proposed) | `s3://data.sb/switchbox/marginal_costs/md/supply/capacity/utility=bge/year={Y}/data.parquet` |
390+
| Parameter | BGE choice |
391+
| ---------------------- | ------------------------------------------------------------------------------------------------- |
392+
| LDA / price zone | BGE zone (a constrained LDA in most DYs; use the BGE Final Zonal price, which bakes in the adder) |
393+
| Price | Final Zonal Capacity Price, 5/7 DY calendar-year blend; sensitivity with IA-trued price |
394+
| K | 5 (PJM 5CP timestamps) |
395+
| Season | Jun 1 – Sep 30 |
396+
| Load | BGE zone load at 5CP timestamps (E1) |
397+
| Weights | Equal 1/5 (F1, PLC-average) |
398+
| Bill side | Schedule R flat SOS; note PSC shoulder smoothing as non-MC |
399+
| Output path (proposed) | `s3://data.sb/switchbox/marginal_costs/md/supply/capacity/utility=bge/year={Y}/data.parquet` |
389400

390401
---
391402

@@ -412,15 +423,13 @@ Useful for validating order-of-magnitude but **not** a direct hourly MC formula.
412423

413424
Implemented (curated data pipelines, reproducible from committed source intermediates):
414425

415-
- `data/pjm/capacity/rpm/` — RPM BRA + Final Zonal prices, DY 2018/19–2026/27. Per-DY markdown intermediates under `sources/` (`rpm_YYYY_YY.md`, both source URLs in the header) → `just convert` → CSV. Rows carry `source_url` (final zonal) and `bra_source_url` citations.
416-
- `data/pjm/capacity/5cp/` — summer 5CP peaks, **summers 2021–2025** (only those feeding 2025+ runs are retained; earlier summers dropped). Per-summer markdown intermediates under `sources/` (`5cp_YYYY.md`) → `just convert` → CSV.
426+
- `data/pjm/capacity/rpm/` — RPM BRA + Final Zonal prices, DY 2018/19–2026/27. Per-DY markdown intermediates under `sources/` (`rpm_YYYY_YY.md`, both source URLs in the header) → `just convert` → CSV`s3://data.sb/pjm/capacity/rpm/data.parquet`. Rows carry `source_url` (final zonal) and `bra_source_url` citations.
427+
- `data/pjm/capacity/5cp/` — summer 5CP peaks, **summers 2021–2025** (only those feeding 2025+ runs are retained; earlier summers dropped). Per-summer markdown intermediates under `sources/` (`5cp_YYYY.md`) → `just convert` → CSV`s3://data.sb/pjm/capacity/5cp/data.parquet`.
417428
- `data/pjm/zone_mapping/` — utility → zone/LDA crosswalk.
418-
419-
Not yet implemented:
420-
421-
- `data/pjm/hourly_demand/zones/` — zone load pipeline (deferred; see Future work below)
422-
- `utils/pre/marginal_costs/supply_capacity_pjm.py` — computation
423-
- `rate_design/hp_rates/md/` — state config (future)
429+
- `data/pjm/hourly_demand/zones/` — PJM-native zonal hourly loads on S3 (only needed for the E1/F2 load-weighted sensitivity, not the v1 equal-weight pipeline).
430+
- `utils/data_prep/marginal_costs/supply_capacity_pjm.py` — computation (RPM day-count blend + 5CP equal-weight allocation + 1 kW validation).
431+
- `generate_supply_capacity_mc.py --iso pjm` — CLI entrypoint.
432+
- `rate_design/hp_rates/md/Justfile``create-supply-capacity-mc[-all-years|-all|-full-backfill]` recipes.
424433

425434
---
426435

@@ -441,4 +450,4 @@ Not yet implemented:
441450
13. [MD PSC Order 87591 — BGE rate case (PDF)](https://psc.maryland.gov/wp-content/uploads/Order-No.-87591-Case-No.-9406-BGE-Rate-Case.pdf)
442451
14. Platform: `context/methods/marginal_costs/capacity_market_comparison_nyiso_isone.md`
443452
15. Platform: `context/code/marginal_costs/ny_supply_marginal_costs.md`
444-
16. Platform: `utils/pre/marginal_costs/supply_capacity_isone.py`
453+
16. Platform: `utils/data_prep/marginal_costs/supply_capacity_isone.py`

rate_design/hp_rates/md/Justfile

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,77 @@ create-supply-energy-mc-full-backfill *extra_args:
102102
done
103103
done
104104
echo ">> Done: full backfill" >&2
105+
106+
# =============================================================================
107+
# MD-only: supply capacity marginal costs (PJM RPM + 5CP)
108+
# =============================================================================
109+
110+
# Years with both an RPM calendar-year blend and a published summer 5CP set.
111+
# 5CP data covers summers 2021-2025; RPM covers DY 2018/19-2026/27. The binding
112+
# constraint is the 5CP summer, so capacity MC is available for 2021-2025.
113+
pjm_capacity_years := "2021 2022 2023 2024 2025"
114+
115+
# Generate supply capacity MC for one MD utility and one year.
116+
#
117+
# just -f md/Justfile create-supply-capacity-mc bge 2025
118+
# just -f md/Justfile create-supply-capacity-mc bge 2025 --upload
119+
create-supply-capacity-mc utility year *extra_args:
120+
uv run python {{ path_repo }}/utils/data_prep/marginal_costs/generate_supply_capacity_mc.py \
121+
--iso pjm \
122+
--utility {{ utility }} \
123+
--year {{ year }} \
124+
{{ extra_args }}
125+
126+
# Generate supply capacity MC for one utility across all available years.
127+
#
128+
# just -f md/Justfile create-supply-capacity-mc-all-years bge
129+
# just -f md/Justfile create-supply-capacity-mc-all-years bge --upload
130+
create-supply-capacity-mc-all-years utility *extra_args:
131+
#!/usr/bin/env bash
132+
set -euo pipefail
133+
for year in {{ pjm_capacity_years }}; do
134+
echo ">> Generating PJM supply capacity MC: utility={{ utility }} year=${year}" >&2
135+
uv run python {{ path_repo }}/utils/data_prep/marginal_costs/generate_supply_capacity_mc.py \
136+
--iso pjm \
137+
--utility {{ utility }} \
138+
--year "${year}" \
139+
{{ extra_args }}
140+
done
141+
echo ">> Done: {{ utility }} all years" >&2
142+
143+
# Generate supply capacity MC for all MD utilities for one year.
144+
#
145+
# just -f md/Justfile create-supply-capacity-mc-all 2025
146+
# just -f md/Justfile create-supply-capacity-mc-all 2025 --upload
147+
create-supply-capacity-mc-all year *extra_args:
148+
#!/usr/bin/env bash
149+
set -euo pipefail
150+
for utility in {{ pjm_utilities }}; do
151+
echo ">> Generating PJM supply capacity MC: utility=${utility} year={{ year }}" >&2
152+
uv run python {{ path_repo }}/utils/data_prep/marginal_costs/generate_supply_capacity_mc.py \
153+
--iso pjm \
154+
--utility "${utility}" \
155+
--year {{ year }} \
156+
{{ extra_args }}
157+
done
158+
echo ">> Done: all utilities year={{ year }}" >&2
159+
160+
# Generate supply capacity MC for ALL utilities across ALL available years.
161+
# This is the full backfill. Runs sequentially utility-by-utility, year-by-year.
162+
#
163+
# just -f md/Justfile create-supply-capacity-mc-full-backfill
164+
# just -f md/Justfile create-supply-capacity-mc-full-backfill --upload
165+
create-supply-capacity-mc-full-backfill *extra_args:
166+
#!/usr/bin/env bash
167+
set -euo pipefail
168+
for utility in {{ pjm_utilities }}; do
169+
for year in {{ pjm_capacity_years }}; do
170+
echo ">> Generating PJM supply capacity MC: utility=${utility} year=${year}" >&2
171+
uv run python {{ path_repo }}/utils/data_prep/marginal_costs/generate_supply_capacity_mc.py \
172+
--iso pjm \
173+
--utility "${utility}" \
174+
--year "${year}" \
175+
{{ extra_args }}
176+
done
177+
done
178+
echo ">> Done: full backfill" >&2

0 commit comments

Comments
 (0)