diff --git a/context/README.md b/context/README.md index 6da12bdd..5d3dcd6a 100644 --- a/context/README.md +++ b/context/README.md @@ -79,6 +79,7 @@ How we derive BAT marginal-cost inputs from regulatory studies. | ny_mcos_studies_comparison.md | Cross-utility comparison of the seven NY 2025 MCOS studies: what MCOS numbers mean (undiluted/diluted, grid topology), methodology comparison, annualization mechanics (workbook-verified step-by-step), system-wide diluted/undiluted tables, component taxonomy mapping (physical hierarchy → cost-center labels), bulk vs local TX scrub, reassessment of bulk TX MC gap. | | ny_bulk_transmission_marginal_cost.md | How to construct bulk TX marginal cost for NY BAT: the gap, data sources (OATT ATRR, CLCPA project costs, FERC Order 1920), five options with tradeoffs, recommended approach (OATT as upper-bound proxy), hourly PoP allocation. | | ri_bulk_transmission_marginal_cost.md | How to construct bulk TX marginal cost for RI BAT: data sources (RNS rate, AESC 2024 avoided PTF cost, ISO-NE 2050 study), four options with tradeoffs, recommended approach (AESC primary, RNS upper bound), hourly PoP allocation, comparison with NY. | +| ct_bulk_transmission_marginal_cost.md | How to construct bulk TX marginal cost for CT BAT: same AESC 2024 $69/kW-year and top-100 exceedance as RI, plus `--allocation-load` flag to toggle between NE system aggregate and CT zone load for peak-hour identification; utilities `ct_eversource` and `ct_ui`; implementation files and Just commands. | | capacity_market_comparison_nyiso_isone.md | Comparison of NYISO ICAP (Strip/Monthly/Spot) vs ISO-NE FCM (FCA/ARA/MRA) capacity market structures: structural analogy table, forward horizons, price volatility differences, implications for BAT marginal cost signal (Spot vs FCA vs MRA), current approach in NY vs RI, trade-offs. | | dist_mc_definition_choice.md | Cross-cutting: which LRMC definition the `sub_tx_and_dist` BAT input uses, and why it differs by state. Taxonomy (Turvey / AIC-avoided / FLIC) and why AIC and FLIC differ ~10-50x, source-number table (NY FLIC $1-8, RI AESC $80, BGE Brattle $32, BGE E3 $203-258, OPC $25-34, BAT paper $54.46), what is implemented per state (NY=FLIC, RI/MD=avoided), the platform tension (NY-vs-RI/MD; methodology-doc §3-vs-§7), rationale, and BGE sensitivities. | | md_bge_dist_mc_methodology.md | BGE (MD) sub-TX + distribution MC implementation: adopts Brattle avoided distribution cost $32/kW-yr (2022$), RI-style single-scalar config + CPI handling, why avoided cost is consistent with BGE's own peak-driven COS, the ~82% volumetric over-recovery intervention hook, FLIC/E3 sensitivities, and the Stage-B allocator MD-enablement dependency. | diff --git a/context/methods/marginal_costs/ct_bulk_transmission_marginal_cost.md b/context/methods/marginal_costs/ct_bulk_transmission_marginal_cost.md new file mode 100644 index 00000000..935f327c --- /dev/null +++ b/context/methods/marginal_costs/ct_bulk_transmission_marginal_cost.md @@ -0,0 +1,158 @@ +# CT bulk transmission marginal cost: how to construct it + +How to create a bulk transmission marginal cost signal for the BAT in Connecticut. CT follows the same ISO-NE framework as Rhode Island — the AESC 2024 avoided PTF cost allocated to peak hours via exceedance weighting — with one addition: a `--allocation-load` flag that lets you choose whether peak hours are identified from the NE system aggregate load or from the CT zone load alone. + +--- + +## Summary + +| Feature | Value | +| --------------------------- | --------------------------------------------------- | +| Marginal cost source | AESC 2024 avoided PTF cost | +| Value | $69/kW-year (same for all 6 NE states) | +| Hourly allocation method | Top-100-hour exceedance on load curve | +| Default allocation load | NE system aggregate (all 8 ISO-NE load zones) | +| Alternative allocation load | CT zone load only | +| ISO | ISO-NE | +| Utilities covered | `ct_eversource`, `ct_ui` | +| Output S3 path | `s3://data.sb/switchbox/marginal_costs/ct/bulk_tx/` | + +--- + +## Cost source: AESC 2024 avoided PTF cost + +CT uses the same cost source as RI: the **Avoided Energy Supply Components (AESC) 2024** study by Synapse Energy Economics [@synapse_AvoidedEnergySupply_2024], which publishes an avoided PTF (Pool Transmission Facility) cost in $/kW-year for benefit-cost screening of efficiency and DER programs in New England. + +**Key value:** `$69/kW-year` (AESC 2024, Table: Avoided T&D — PTF) — [DocumentCloud p. 288](https://www.documentcloud.org/documents/28039756-synapse-2024-avoided-energy-supply-components-in-new-england-2024-repor/#document/p288/a2811325). + +This is the LRMC of bulk transmission in ISO-NE territory: the cost deferred or avoided when a CT customer reduces peak demand by 1 kW during peak hours. The same $69/kW-year applies across all six NE states because PTF is a regional (pooled) cost. The value is implemented as `AESC_2024_AVOIDED_PTF_KW_YEAR` in `utils/data_prep/marginal_costs/bulk_tx_isone.py`. + +For full background on the AESC study, the RNS rate, and the reasoning behind choosing AESC over the embedded RNS rate, see `context/methods/marginal_costs/ri_bulk_transmission_marginal_cost.md`. + +--- + +## Hourly allocation + +The $69/kW-year value must be converted to an 8760 hourly signal for CAIRO. The approach is the **top-100-hour exceedance** method, identical to RI and to the sub-TX/dist PoP methodology used elsewhere in the platform. + +**How it works:** + +1. Load the 8760 aggregate load curve (either NE system or CT zone, see below). +2. Identify the top 100 hours by load level. +3. Allocate the total annual cost ($69/kW-year) across those 100 hours proportional to each hour's load magnitude above the threshold (exceedance weighting). +4. All other hours receive $0. + +The result: a customer who runs a kW of load during the top-100 peak hours sees a positive bulk TX marginal cost on those hours; all other hours are zero. The sum over all 8760 hours for a 1-kW flat load equals $69/kW-year (validated automatically at runtime). + +--- + +## Allocation load: system-wide vs. CT zone + +This is the CT-specific addition relative to RI. The `generate_bulk_tx_mc.py` CLI accepts `--allocation-load`: + +| Flag value | Load used to identify peak hours | +| -------------- | ----------------------------------- | +| `ne_system` | All 8 ISO-NE zones summed (default) | +| `utility_zone` | CT zone load only | + +**`ne_system` (default):** Peak hours are identified from the aggregate New England load curve. This is consistent with how RNS costs are allocated — they are triggered by NE-wide coincident peaks, not CT-specific peaks. A CT customer reducing load at a NE system peak avoids regional PTF cost regardless of whether CT individually peaks at that moment. + +**`utility_zone`:** Peak hours are identified from CT zone load only. This would be appropriate if CT's transmission constraints are primarily local and CT's own peak is the relevant driver of PTF investment for CT. In practice, the NE system peak and the CT zone peak are highly correlated but not identical. + +**Which to use:** `ne_system` is the default and the recommended primary value. The `utility_zone` option exists for sensitivity analysis and to explore how much the two signals diverge. A comparison script is available at `dev/compare_ct_bulk_tx_allocation_loads.py`. + +A comparison run (2025 ISO-NE data) found that the two signals share most peak hours, with a small set of hours where one signal is non-zero and the other is zero. The divergence is typically small relative to the total cost level, making the choice low-stakes for the BAT. + +--- + +## Load data source + +CT zone load is pulled from the ISO-NE hourly demand dataset on S3: + +``` +s3://data.sb/isone/hourly_demand/zones/ +``` + +This dataset contains 8760 rows per year per zone, with columns `timestamp` and `load_mw`. CT-specific load is zone label `"CT"`. NE system aggregate is the sum across all 8 ISO-NE load zones: `CT`, `ME`, `NH`, `VT`, `RI`, `SEMASS`, `WCMASS`, `NEMASSBOST`. + +Loading is handled by `load_isone_zone_loads()` in `utils/data_prep/marginal_costs/supply_capacity_isone.py`. + +--- + +## Output + +The pipeline produces an 8760-row Parquet file at: + +``` +s3://data.sb/switchbox/marginal_costs/ct/bulk_tx/utility={ct_eversource|ct_ui}/year={year}/data.parquet +``` + +| Column | Type | Description | +| --------------------- | ------- | --------------------------------------- | +| `timestamp` | String | ISO 8601, hourly, target year | +| `bulk_tx_cost_enduse` | Float64 | $/kW per hour (zero for non-peak hours) | + +The output is validated at runtime: the sum of `bulk_tx_cost_enduse` over all 8760 hours (for a hypothetical 1-kW constant load) must equal `$69.00/kW-year` within 0.01%. + +--- + +## Running the pipeline + +From `rate_design/hp_rates/`: + +```bash +# Single utility, single year (dry run) +just s ct create-bulk-tx-mc-data ct_eversource 2025 + +# Single utility, upload to S3 +just s ct create-bulk-tx-mc-data ct_eversource 2025 --upload +just s ct create-bulk-tx-mc-data ct_ui 2025 --upload + +# All CT utilities, all years (UTILITIES from state.env) +just s ct create-bulk-tx-mc-data-all --upload + +# Use CT zone load for allocation instead of NE system +just s ct create-bulk-tx-mc-data ct_eversource 2025 --allocation-load utility_zone --upload +``` + +Both `ct_eversource` and `ct_ui` are defined in `state.env` as `UTILITIES=ct_eversource,ct_ui` and in `supply_utils.py` as entries in `ISONE_UTILITY_ZONES`. + +--- + +## Implementation files + +| File | Role | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `utils/data_prep/marginal_costs/bulk_tx_isone.py` | Core logic: `compute_isone_bulk_tx_signal`, `prepare_output`, `validate_allocation` | +| `utils/data_prep/marginal_costs/generate_bulk_tx_mc.py` | CLI entrypoint; handles `--allocation-load` flag | +| `utils/data_prep/marginal_costs/supply_utils.py` | `ISONE_UTILITY_ZONES` mapping, `allocate_annual_exceedance_to_hours` | +| `utils/data_prep/marginal_costs/supply_capacity_isone.py` | `load_isone_zone_loads` | +| `rate_design/hp_rates/ct/Justfile` | `create-bulk-tx-mc-data`, `create-bulk-tx-mc-data-all` recipes | +| `rate_design/hp_rates/ct/state.env` | `UTILITIES=ct_eversource,ct_ui`, `REGION=isone`, `YEAR=2025` | +| `dev/compare_ct_bulk_tx_allocation_loads.py` | Compare system vs. CT-zone allocation signals | +| `tests/test_ri_bulk_tx_mc.py` | Tests for exceedance allocation, system vs. zone load modes | + +--- + +## Comparison with RI + +| Feature | RI | CT | +| ----------------------- | --------------------- | -------------------------------- | +| Cost source | AESC 2024 ($69/kW-yr) | AESC 2024 ($69/kW-yr) | +| Allocation method | Top-100 exceedance | Top-100 exceedance | +| Default allocation load | NE system | NE system | +| Zone-load option | Not implemented | `--allocation-load utility_zone` | +| Utilities | `ri_energy` | `ct_eversource`, `ct_ui` | +| ISO-NE zone label | `RI` | `CT` | + +The only material difference is the `--allocation-load` flag, which was added when implementing CT to allow sensitivity analysis. It is available for RI as well (the same CLI flag exists), but RI currently only runs with the NE system default. + +--- + +## Key references + +- **AESC 2024 Report** [@synapse_AvoidedEnergySupply_2024]: https://www.synapse-energy.com/sites/default/files/AESC%202024.pdf +- **AESC avoided PTF ($69/kW-yr)** — [DocumentCloud p. 288](https://www.documentcloud.org/documents/28039756-synapse-2024-avoided-energy-supply-components-in-new-england-2024-repor/#document/p288/a2811325) +- **AESC 2024 Materials** (User Interfaces, appendices): https://www.synapse-energy.com/aesc-2024-materials +- **RI methodology doc** (full AESC vs RNS discussion): `context/methods/marginal_costs/ri_bulk_transmission_marginal_cost.md` +- **RI cost recovery doc** (RNS/LNS mechanics, PTF allocation): `context/domain/marginal_costs/ri_bulk_transmission_cost_recovery.md` diff --git a/rate_design/hp_rates/ct/Justfile b/rate_design/hp_rates/ct/Justfile new file mode 100644 index 00000000..9bbe2e58 --- /dev/null +++ b/rate_design/hp_rates/ct/Justfile @@ -0,0 +1,59 @@ +# CT heat pump rate design tasks. +# Thin wrapper: sets state defaults, imports shared recipes. +# +# Usage (from rate_design/hp_rates/): +# just -f ct/Justfile create-bulk-tx-mc-data ct_eversource 2025 --upload +# just -f ct/Justfile create-bulk-tx-mc-data ct_ui 2025 --upload +# just -f ct/Justfile create-bulk-tx-mc-data-all --upload +# Or via dispatch: +# just s ct create-bulk-tx-mc-data ct_eversource 2025 --upload +# just s ct create-bulk-tx-mc-data-all --upload + +set dotenv-path := "state.env" + +state := env_var_or_default('STATE', 'ct') + +import '../Justfile' + +# ============================================================================= +# CT-only: bulk transmission marginal costs (ISO-NE AESC PTF) +# +# Uses AESC 2024 avoided PTF cost ($69/kW-yr) allocated to the top-100 NE +# system peak hours via exceedance weighting — same methodology as RI. +# Outputs land at s3://data.sb/switchbox/marginal_costs/ct/bulk_tx/. +# ============================================================================= + +ct_bulk_tx_years := "2025" + +# Generate bulk TX MC for one CT utility and one year. +# +# just -f ct/Justfile create-bulk-tx-mc-data ct_eversource 2025 +# just -f ct/Justfile create-bulk-tx-mc-data ct_eversource 2025 --upload +create-bulk-tx-mc-data utility_arg year_arg *extra_args: + cd {{ path_repo }} && uv run python {{ path_repo }}/utils/data_prep/marginal_costs/generate_bulk_tx_mc.py \ + --iso isone \ + --utility {{ utility_arg }} \ + --year {{ year_arg }} \ + --load-year {{ year_arg }} \ + {{ extra_args }} + +# Generate bulk TX MC for all CT utilities across all available years. +# +# just -f ct/Justfile create-bulk-tx-mc-data-all +# just -f ct/Justfile create-bulk-tx-mc-data-all --upload +create-bulk-tx-mc-data-all *extra_args: + #!/usr/bin/env bash + set -euo pipefail + IFS=',' read -ra utils <<< "{{ utilities }}" + for utility in "${utils[@]}"; do + for year in {{ ct_bulk_tx_years }}; do + echo ">> Generating ISO-NE bulk TX MC: utility=${utility} year=${year}" >&2 + cd {{ path_repo }} && uv run python {{ path_repo }}/utils/data_prep/marginal_costs/generate_bulk_tx_mc.py \ + --iso isone \ + --utility "${utility}" \ + --year "${year}" \ + --load-year "${year}" \ + {{ extra_args }} + done + done + echo ">> Done: all CT bulk TX MC (${#utils[@]} utilities × $(echo {{ ct_bulk_tx_years }} | wc -w) years)" >&2 diff --git a/rate_design/hp_rates/ct/state.env b/rate_design/hp_rates/ct/state.env new file mode 100644 index 00000000..0249f79d --- /dev/null +++ b/rate_design/hp_rates/ct/state.env @@ -0,0 +1,4 @@ +STATE=ct +REGION=isone +YEAR=2025 +UTILITIES=ct_eversource,ct_ui diff --git a/tests/test_ri_bulk_tx_mc.py b/tests/test_ri_bulk_tx_mc.py index c5d4e22f..32447797 100644 --- a/tests/test_ri_bulk_tx_mc.py +++ b/tests/test_ri_bulk_tx_mc.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +from typing import cast import polars as pl import pytest @@ -132,20 +133,20 @@ def test_custom_peak_hours(self) -> None: ) assert result.height == 50 - def test_ri_zone_load_informational_only(self) -> None: - """Passing ri_zone_load_df should not change the cost values.""" + def test_utility_zone_load_informational_only(self) -> None: + """Passing utility_zone_load_df should not change the cost values.""" ne_load = _make_ne_load_profile() ri_load = _make_ri_zone_load(ne_load) ptf = 69.0 - result_without_ri = compute_isone_bulk_tx_signal(ne_load, aesc_ptf_kw_year=ptf) - result_with_ri = compute_isone_bulk_tx_signal( - ne_load, aesc_ptf_kw_year=ptf, ri_zone_load_df=ri_load + result_without = compute_isone_bulk_tx_signal(ne_load, aesc_ptf_kw_year=ptf) + result_with = compute_isone_bulk_tx_signal( + ne_load, aesc_ptf_kw_year=ptf, utility_zone_load_df=ri_load ) - # Same cost allocation regardless of whether RI zone load is provided - assert float(result_without_ri["bulk_tx_cost_enduse"].sum()) == pytest.approx( - float(result_with_ri["bulk_tx_cost_enduse"].sum()), rel=1e-6 + # Same cost allocation regardless of whether utility zone load is provided + assert float(result_without["bulk_tx_cost_enduse"].sum()) == pytest.approx( + float(result_with["bulk_tx_cost_enduse"].sum()), rel=1e-6 ) @@ -199,6 +200,183 @@ def test_raises_on_bad_allocation(self) -> None: validate_allocation(output, ptf * 2.0) +# ── allocation-load modes: system-wide vs. single-zone ─────────────────────── + + +def _make_zone_load( + year: int = 2025, + base_mw: float = 5_000.0, + summer_boost_mw: float = 2_000.0, + hour_trend: float = 50.0, + noise_offset: int = 0, +) -> pl.DataFrame: + """Synthetic single-zone load. + + Parameterised so callers can produce a zone whose summer/shoulder peaks + differ from the system-wide aggregate returned by ``_make_ne_load_profile``. + ``noise_offset`` shifts the intra-day trend phase so peak hours can diverge + across zones. + """ + timestamps = pl.datetime_range( + datetime(year, 1, 1, 0, 0, 0), + datetime(year, 12, 31, 23, 0, 0), + interval="1h", + eager=True, + ) + df = pl.DataFrame({"timestamp": timestamps}).head(8760).with_row_index("idx") + summer = {5, 6, 7, 8, 9} + return df.with_columns( + ( + pl.when(pl.col("timestamp").dt.month().is_in(list(summer))) + .then(base_mw + summer_boost_mw) + .otherwise(base_mw) + + ((pl.col("idx") + noise_offset) % 24).cast(pl.Float64) * hour_trend + + (pl.col("idx") * 0.005) + ).alias("load_mw") + ).select("timestamp", "load_mw") + + +def _sum_zone_loads(*zone_dfs: pl.DataFrame) -> pl.DataFrame: + """Sum multiple zone load DataFrames into a single aggregate.""" + agg = zone_dfs[0] + for other in zone_dfs[1:]: + agg = ( + agg.join(other.rename({"load_mw": "_other"}), on="timestamp", how="left") + .with_columns((pl.col("load_mw") + pl.col("_other")).alias("load_mw")) + .select("timestamp", "load_mw") + ) + return agg + + +class TestAllocationLoadModes: + """Verify that system-wide vs. single-zone allocation loads produce + valid, internally-consistent signals with the expected differences.""" + + def test_both_recover_annual_ptf_cost(self) -> None: + """Both allocation-load modes should recover the full AESC PTF cost.""" + ptf = 69.0 + n = 100 + system_load = _make_ne_load_profile() + zone_load = _make_zone_load() + + system_signal = prepare_output( + compute_isone_bulk_tx_signal(system_load, ptf, n_peak_hours=n), 2025 + ) + zone_signal = prepare_output( + compute_isone_bulk_tx_signal(zone_load, ptf, n_peak_hours=n), 2025 + ) + + assert float(system_signal["bulk_tx_cost_enduse"].sum()) == pytest.approx( + ptf, rel=1e-4 + ) + assert float(zone_signal["bulk_tx_cost_enduse"].sum()) == pytest.approx( + ptf, rel=1e-4 + ) + + def test_both_have_exactly_n_peak_hours(self) -> None: + n = 50 + system_load = _make_ne_load_profile() + zone_load = _make_zone_load() + + system_signal = compute_isone_bulk_tx_signal(system_load, 69.0, n_peak_hours=n) + zone_signal = compute_isone_bulk_tx_signal(zone_load, 69.0, n_peak_hours=n) + + assert system_signal.height == n + assert zone_signal.height == n + + def test_peak_thresholds_differ_by_load_magnitude(self) -> None: + """The system aggregate has a higher peak threshold than a single zone + because summing zones produces higher absolute MW values.""" + system_load = _make_ne_load_profile() # ~14–22 GW synthetic aggregate + zone_load = _make_zone_load(base_mw=5_000.0, summer_boost_mw=2_000.0) # ~5–7 GW + + system_top = cast( + float, + system_load.sort("load_mw", descending=True).head(100)["load_mw"].min(), + ) + zone_top = cast( + float, zone_load.sort("load_mw", descending=True).head(100)["load_mw"].min() + ) + assert system_top is not None and zone_top is not None + + assert system_top > zone_top + + def test_zone_load_is_subset_of_system_peak_hours(self) -> None: + """When zone load tracks the system aggregate closely (fixed share), + almost all zone-only peak hours should overlap with system peak hours.""" + system_load = _make_ne_load_profile() + # Zone load is a strict proportional share of the system — peaks are identical + zone_load = _make_ri_zone_load(system_load, share=0.25) + n = 100 + + system_signal = compute_isone_bulk_tx_signal(system_load, 69.0, n_peak_hours=n) + zone_signal = compute_isone_bulk_tx_signal(zone_load, 69.0, n_peak_hours=n) + + system_peak_ts = set(system_signal["timestamp"].to_list()) + zone_peak_ts = set(zone_signal["timestamp"].to_list()) + + # A proportional zone has the same rank ordering → identical peak windows + assert system_peak_ts == zone_peak_ts + + def test_divergent_zone_produces_different_peak_hours(self) -> None: + """A zone whose intra-day peak phase differs from the system aggregate + identifies a different set of peak hours.""" + system_load = _make_ne_load_profile() + # noise_offset=12 shifts the intra-day peak by 12 hours so zone peaks + # at different hours than the system + zone_load = _make_zone_load(noise_offset=12) + n = 100 + + system_signal = compute_isone_bulk_tx_signal(system_load, 69.0, n_peak_hours=n) + zone_signal = compute_isone_bulk_tx_signal(zone_load, 69.0, n_peak_hours=n) + + system_peak_ts = set(system_signal["timestamp"].to_list()) + zone_peak_ts = set(zone_signal["timestamp"].to_list()) + + # With a shifted phase, some peak hours must differ + assert system_peak_ts != zone_peak_ts + + def test_system_aggregate_sums_zones(self) -> None: + """A hand-summed multi-zone aggregate produces the same peak hours + as loading zones individually and summing via _sum_zone_loads.""" + zone_a = _make_zone_load( + base_mw=10_000.0, summer_boost_mw=3_000.0, noise_offset=0 + ) + zone_b = _make_zone_load( + base_mw=5_000.0, summer_boost_mw=1_500.0, noise_offset=3 + ) + zone_c = _make_zone_load(base_mw=3_000.0, summer_boost_mw=800.0, noise_offset=6) + + system = _sum_zone_loads(zone_a, zone_b, zone_c) + n = 100 + + system_signal = compute_isone_bulk_tx_signal(system, 69.0, n_peak_hours=n) + # Peak hours of the sum must be the top-n hours of the combined load + top_n_ts = set( + system.sort("load_mw", descending=True).head(n)["timestamp"].to_list() + ) + assert set(system_signal["timestamp"].to_list()) == top_n_ts + + def test_informational_zone_load_does_not_change_signal(self) -> None: + """Passing utility_zone_load_df changes only what's printed, not the signal.""" + system_load = _make_ne_load_profile() + zone_load = _make_zone_load() + ptf = 69.0 + n = 100 + + without_info = compute_isone_bulk_tx_signal(system_load, ptf, n_peak_hours=n) + with_info = compute_isone_bulk_tx_signal( + system_load, ptf, n_peak_hours=n, utility_zone_load_df=zone_load + ) + + assert set(without_info["timestamp"].to_list()) == set( + with_info["timestamp"].to_list() + ) + assert float(without_info["bulk_tx_cost_enduse"].sum()) == pytest.approx( + float(with_info["bulk_tx_cost_enduse"].sum()), rel=1e-6 + ) + + # ── AESC constant ──────────────────────────────────────────────────────────── diff --git a/utils/data_prep/marginal_costs/bulk_tx_isone.py b/utils/data_prep/marginal_costs/bulk_tx_isone.py index eaf10b45..2733237e 100644 --- a/utils/data_prep/marginal_costs/bulk_tx_isone.py +++ b/utils/data_prep/marginal_costs/bulk_tx_isone.py @@ -62,44 +62,55 @@ def compute_isone_bulk_tx_signal( - ne_load_df: pl.DataFrame, + load_df: pl.DataFrame, aesc_ptf_kw_year: float, n_peak_hours: int = DEFAULT_N_PEAK_HOURS, - ri_zone_load_df: pl.DataFrame | None = None, + utility_zone_load_df: pl.DataFrame | None = None, + utility_label: str = "Utility zone", ) -> pl.DataFrame: - """Compute ISO-NE bulk transmission hourly signal from NE system load. + """Compute ISO-NE bulk transmission hourly signal via exceedance allocation. Args: - ne_load_df: NE-wide aggregate load (8760 rows, columns: timestamp, load_mw). + load_df: Allocation load (8760 rows, columns: timestamp, load_mw). + This is the load used to identify peak hours and drive exceedance + allocation. Typically either the NE system aggregate (all 8 zones) + or the utility's own zone load, depending on the ``--allocation-load`` + CLI flag. aesc_ptf_kw_year: AESC avoided PTF cost in $/kW-year. - n_peak_hours: Number of top NE system-load hours for exceedance allocation. - ri_zone_load_df: Optional RI zone load for informational RNS share display. - Not used in the cost calculation. + n_peak_hours: Number of top load hours for exceedance allocation. + utility_zone_load_df: Optional utility zone load for informational load-share + display at the allocation peak. Not used in the cost calculation. + Only meaningful when ``load_df`` is system-wide (otherwise the utility + zone IS the allocation load and its share is trivially 100%). + utility_label: Human-readable label for the utility zone in the display + (e.g. ``"RI"`` or ``"CT"``). Returns: DataFrame with columns ``timestamp`` and ``bulk_tx_cost_enduse`` ($/kW per hour). Contains *n_peak_hours* non-zero rows. """ - # Informational: show RI's share of NE peak for context - if ri_zone_load_df is not None: - ne_peak_hour = ne_load_df.sort("load_mw", descending=True).head(1) - ne_peak_ts = ne_peak_hour["timestamp"][0] - ne_peak_mw = float(ne_peak_hour["load_mw"][0]) - - ri_at_peak = ri_zone_load_df.filter(pl.col("timestamp") == ne_peak_ts) - if not ri_at_peak.is_empty(): - ri_peak_mw = float(ri_at_peak["load_mw"][0]) - rns_share = ri_peak_mw / ne_peak_mw - print("\n── RNS Share at NE System Peak ──") - print(f" NE peak hour: {ne_peak_ts}") - print(f" NE peak load: {ne_peak_mw:,.1f} MW") - print(f" RI load at peak: {ri_peak_mw:,.1f} MW") - print(f" RI RNS share: {rns_share:.4f} ({rns_share * 100:.2f}%)") - - # Allocate AESC PTF value to top-N NE system peak hours + # Informational: show utility's share of the allocation load peak + if utility_zone_load_df is not None: + peak_hour = load_df.sort("load_mw", descending=True).head(1) + peak_ts = peak_hour["timestamp"][0] + peak_mw = float(peak_hour["load_mw"][0]) + + util_at_peak = utility_zone_load_df.filter(pl.col("timestamp") == peak_ts) + if not util_at_peak.is_empty(): + util_peak_mw = float(util_at_peak["load_mw"][0]) + load_share = util_peak_mw / peak_mw + print("\n── Load Share at Allocation Peak ──") + print(f" Peak hour: {peak_ts}") + print(f" Allocation load at peak: {peak_mw:,.1f} MW") + print(f" {utility_label} load at peak: {util_peak_mw:,.1f} MW") + print( + f" Load share: {load_share:.4f} ({load_share * 100:.2f}%)" + ) + + # Allocate AESC PTF value to top-N peak hours print("\n── Bulk TX Exceedance Allocation ──") peak_hours_df = allocate_annual_exceedance_to_hours( - load_df=ne_load_df, + load_df=load_df, annual_cost_kw_year=aesc_ptf_kw_year, n_peak_hours=n_peak_hours, cost_col="bulk_tx_cost_enduse", diff --git a/utils/data_prep/marginal_costs/generate_bulk_tx_mc.py b/utils/data_prep/marginal_costs/generate_bulk_tx_mc.py index a4168baf..1ad41d67 100644 --- a/utils/data_prep/marginal_costs/generate_bulk_tx_mc.py +++ b/utils/data_prep/marginal_costs/generate_bulk_tx_mc.py @@ -1,6 +1,6 @@ """Generate utility-level bulk transmission marginal costs. -Supports PJM (MD), NYISO (NY), and ISO-NE (RI). +Supports PJM (MD), NYISO (NY), and ISO-NE (RI, CT). Usage ----- @@ -20,6 +20,15 @@ uv run python utils/data_prep/marginal_costs/generate_bulk_tx_mc.py \\ --iso isone --utility rie --year 2025 --load-year 2025 --upload + # ISO-NE / CT using NE system load for peak identification (default) + uv run python utils/data_prep/marginal_costs/generate_bulk_tx_mc.py \\ + --iso isone --utility ct_eversource --year 2025 --load-year 2025 + + # ISO-NE / CT using CT zone load for peak identification + uv run python utils/data_prep/marginal_costs/generate_bulk_tx_mc.py \\ + --iso isone --utility ct_eversource --year 2025 --load-year 2025 \\ + --allocation-load utility_zone + # ISO-NE with custom AESC PTF override uv run python utils/data_prep/marginal_costs/generate_bulk_tx_mc.py \\ --iso isone --utility rie --year 2025 --aesc-ptf-kw-year 184.0 --upload @@ -56,13 +65,13 @@ VALID_PJM_UTILITIES, ) from utils.data_prep.marginal_costs.supply_utils import ( - DEFAULT_ISONE_BULK_TX_OUTPUT_S3_BASE, DEFAULT_ISONE_ZONE_LOADS_S3_BASE, DEFAULT_NYISO_BULK_TX_OUTPUT_S3_BASE, DEFAULT_NYISO_ZONE_LOADS_S3_BASE, DEFAULT_NYISO_ZONE_MAPPING_PATH, DEFAULT_PJM_BULK_TX_OUTPUT_S3_BASE, ISONE_ALL_LOAD_ZONES, + ISONE_UTILITY_ZONES, VALID_ISONE_UTILITIES, VALID_NYISO_UTILITIES, remap_year_if_needed, @@ -190,10 +199,23 @@ def _parse_args() -> argparse.Namespace: type=int, default=DEFAULT_N_PEAK_HOURS, help=( - "[ISO-NE only] Number of top NE system-load hours for exceedance " + "[ISO-NE only] Number of top allocation-load hours for exceedance " f"allocation (default: {DEFAULT_N_PEAK_HOURS})." ), ) + parser.add_argument( + "--allocation-load", + type=str, + default="ne_system", + choices=["ne_system", "utility_zone"], + help=( + "[ISO-NE only] Load series used to identify peak hours for exceedance " + "allocation. 'ne_system' (default) sums all 8 ISO-NE zones — consistent " + "with how bulk TX peaks are driven regionally. 'utility_zone' uses only " + "the utility's own zone load — closer to embedded-cost RNS allocation " + "mechanics but defensible for marginal cost work." + ), + ) # Shared args parser.add_argument( "--output-s3-base", @@ -202,7 +224,7 @@ def _parse_args() -> argparse.Namespace: help=( "S3 base for output. " f"Defaults to {DEFAULT_NYISO_BULK_TX_OUTPUT_S3_BASE!r} (NYISO) " - f"or {DEFAULT_ISONE_BULK_TX_OUTPUT_S3_BASE!r} (ISO-NE)." + "or 's3://data.sb/switchbox/marginal_costs//bulk_tx/' (ISO-NE)." ), ) parser.add_argument( @@ -246,7 +268,11 @@ def main() -> None: f"Error: utility {utility!r} is not valid for ISO-NE. " f"Valid choices: {sorted(VALID_ISONE_UTILITIES)}" ) - output_s3_base = args.output_s3_base or DEFAULT_ISONE_BULK_TX_OUTPUT_S3_BASE + state = ISONE_UTILITY_ZONES[utility].lower() + output_s3_base = ( + args.output_s3_base + or f"s3://data.sb/switchbox/marginal_costs/{state}/bulk_tx/" + ) zone_loads_s3_base = args.zone_loads_s3_base or DEFAULT_ISONE_ZONE_LOADS_S3_BASE print("=" * 60) @@ -468,42 +494,57 @@ def _run_isone( aesc_ptf = args.aesc_ptf_kw_year n_peak = args.n_peak_hours + allocation_load = args.allocation_load + + # Resolve utility-specific zone (e.g. "RI" for rie, "CT" for ct_eversource) + utility_zone = ISONE_UTILITY_ZONES[utility] + # Zones used for allocation load + allocation_zones = ( + ISONE_ALL_LOAD_ZONES if allocation_load == "ne_system" else [utility_zone] + ) print(f" AESC avoided PTF: ${aesc_ptf:.2f}/kW-yr") print(f" Peak hours: {n_peak}") - print(f" NE zones: {ISONE_ALL_LOAD_ZONES}") + print( + f" Allocation load: {allocation_load} ({', '.join(sorted(allocation_zones))})" + ) print("=" * 60) - # Load all New England zone loads (aggregate to NE system load) - print(f"\n── NE System Load (year={load_year}) ──") - ne_load_df = load_isone_zone_loads( + # Load the allocation load (NE system or utility zone) + print(f"\n── Allocation Load (year={load_year}) ──") + alloc_load_df = load_isone_zone_loads( zone_loads_s3_base=zone_loads_s3_base, - zone_names=ISONE_ALL_LOAD_ZONES, + zone_names=allocation_zones, year=load_year, storage_options=storage_options, ) - # Load RI zone load separately for informational RNS share display - print(f"\n── RI Zone Load (year={load_year}) ──") - ri_zone_load_df = load_isone_zone_loads( - zone_loads_s3_base=zone_loads_s3_base, - zone_names=["RI"], - year=load_year, - storage_options=storage_options, - ) + # When using NE system load, also load the utility's own zone for informational + # load-share display. When using utility zone, skip — it's already the alloc load. + utility_zone_load_df = None + if allocation_load == "ne_system": + print(f"\n── {utility_zone} Zone Load (year={load_year}) ──") + utility_zone_load_df = load_isone_zone_loads( + zone_loads_s3_base=zone_loads_s3_base, + zone_names=[utility_zone], + year=load_year, + storage_options=storage_options, + ) # Remap timestamps if load year differs from target year - ne_load_df = remap_year_if_needed(ne_load_df, "timestamp", load_year, year) - ri_zone_load_df = remap_year_if_needed( - ri_zone_load_df, "timestamp", load_year, year - ) + alloc_load_df = remap_year_if_needed(alloc_load_df, "timestamp", load_year, year) + if utility_zone_load_df is not None: + utility_zone_load_df = remap_year_if_needed( + utility_zone_load_df, "timestamp", load_year, year + ) # Compute ISO-NE bulk TX signal bulk_tx_hourly = compute_isone_bulk_tx_signal( - ne_load_df=ne_load_df, + load_df=alloc_load_df, aesc_ptf_kw_year=aesc_ptf, n_peak_hours=n_peak, - ri_zone_load_df=ri_zone_load_df, + utility_zone_load_df=utility_zone_load_df, + utility_label=utility_zone, ) # Expand to full 8760 and validate diff --git a/utils/data_prep/marginal_costs/supply_utils.py b/utils/data_prep/marginal_costs/supply_utils.py index fad58490..ff42cfbc 100644 --- a/utils/data_prep/marginal_costs/supply_utils.py +++ b/utils/data_prep/marginal_costs/supply_utils.py @@ -47,15 +47,16 @@ DEFAULT_ISONE_ZONE_LOADS_S3_BASE = "s3://data.sb/isone/hourly_demand/zones/" DEFAULT_ISONE_FCA_S3_PATH = "s3://data.sb/isone/capacity/fca/data.parquet" DEFAULT_ISONE_OUTPUT_S3_BASE = "s3://data.sb/switchbox/marginal_costs/ri/supply/" -DEFAULT_ISONE_BULK_TX_OUTPUT_S3_BASE = ( - "s3://data.sb/switchbox/marginal_costs/ri/bulk_tx/" -) -VALID_ISONE_UTILITIES = frozenset({"rie"}) +VALID_ISONE_UTILITIES = frozenset({"rie", "ct_eversource", "ct_ui"}) # Maps each ISO-NE utility to its single load zone. ISO-NE utilities are # single-zone, so no load-weighting is needed (unlike NYISO multi-zone). -ISONE_UTILITY_ZONES: dict[str, str] = {"rie": "RI"} +ISONE_UTILITY_ZONES: dict[str, str] = { + "rie": "RI", + "ct_eversource": "CT", + "ct_ui": "CT", +} # ISO-NE FCA (Forward Capacity Auction) capacity zone mappings. # Maps each ISO-NE utility to its capacity zone ID for FCA price resolution.