From 0693739d70757c35bae8de55078dfaed1f6de138 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sun, 6 Sep 2026 14:58:45 -0500 Subject: [PATCH] ONCOR real-time arbitrage decision tool + economics doc (#826) oncor-arbitrage.sh: LZ_SOUTH RTM price (pluggable source) -> best-action verdict (serve load vs export to Base flat 4c vs RTW counterfactual); JSON out for the KNELPerf batch gate. Verified with override prices. https://projects.knownelement.com/issues/826#note-5 --- docs/oncor-realtime-arbitrage.md | 61 +++++++++++++++++++++ solar-analysis/oncor-arbitrage.sh | 88 +++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 docs/oncor-realtime-arbitrage.md create mode 100755 solar-analysis/oncor-arbitrage.sh diff --git a/docs/oncor-realtime-arbitrage.md b/docs/oncor-realtime-arbitrage.md new file mode 100644 index 0000000..7cae1a2 --- /dev/null +++ b/docs/oncor-realtime-arbitrage.md @@ -0,0 +1,61 @@ +# ONCOR real-time arbitrage: sell to Base Power vs serve load + +Status: v1 (2026-09-06, #826). Location: Pflugerville, Travis County, TX — +ONCOR wires, ERCOT South load zone (`LZ_SOUTH`). Tool: +`solar-analysis/oncor-arbitrage.sh`. + +## The plan facts that shape everything + +Base Power (verified 2026-09-06): +- **Import: $0.085/kWh flat** (contract through Oct 2028) +- **Export: $0.040/kWh flat** solar credit — Base does NOT pay real-time + wholesale for exports + +So on Base Power, **both sides of your meter are flat rates**. ERCOT's +real-time market price (RTM, 15-min settlement point prices) does not change +what you pay or earn today — it is a *counterfactual* and a *planning +signal*: + +## Decision rules (what the tool emits) + +For each kWh of solar surplus, best action = max of: +| option | value | when it wins | +|---|---|---| +| serve own/deferrable load | $0.085 avoided | almost always (8.5¢ > 4¢) | +| export to Base | $0.040 flat | only when NO deferrable load exists | +| export on an RTW plan* | RTM $/kWh | only when RTM > $0.085 (rare spikes) | + +*counterfactual — would require switching export credit to a real-time- +wholesale retailer plan (e.g. Octopus-style RTW). Texas RTW export averages +~2¢/kWh with rare spikes >$5/kWh — as a steady diet it LOSES to Base's flat +4¢; it only wins if you can time exports into spikes (needs battery). + +**Practical rules:** +1. **Every surplus kWh has a home: load first.** HFNOC batch compute, site-2 + deferrable manufacturing, battery charging — all displace 8.5¢ power. + This is why the KNELPerf class-3 batch gate keys on solar surplus. +2. **Only truly surplus-after-load gets exported at 4¢.** +3. **RTM spike alerts** (`rtw_would_beat_self_consume: true`, RTM > + $85/MWh): under a future RTW plan you'd earn more exporting than + self-consuming. Track how often this happens; if frequent and clustered, + an RTW plan + battery beats Base's flat buyback for exports. Until then, + staying on Base and self-consuming is strictly better. +4. **Battery note**: Base's free battery — verify who controls dispatch + (Base dispatches it for grid services; if they own the dispatch, do not + count it in household arbitrage). Open question logged on #826. + +## Price sources + +Official API (api.ercot.com public API) and gridstatus.io both require a +free API key; ercot.com's dashboard JSON is behind an Imperva bot-wall +(tested 2026-09-06). Tool source precedence: `RTM_OVERRIDE` → +`GRIDSTATUS_API_KEY` → `ERCOT_API_KEY` → cache file. Key registration is a +founder action ( NeedsInput on #826). + +## KNELPerf integration + +`oncor-arbitrage.sh` emits `start_deferrable_load` + `rtw_would_beat_self_consume` +as JSON — the batch launcher consumes this plus the HA solar-surplus sensor +once the plant is live. Docs: KNELPerf docs/ARCHITECTURE.md §7-8. + +> **Docs live on Discourse — this repo is the executable source of truth.** diff --git a/solar-analysis/oncor-arbitrage.sh b/solar-analysis/oncor-arbitrage.sh new file mode 100755 index 0000000..f322006 --- /dev/null +++ b/solar-analysis/oncor-arbitrage.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# SITER-Solar / KNELPerf — ONCOR-area real-time solar arbitrage decision. +# +# Question it answers, per interval: with X kW of solar surplus and a menu of +# deferrable loads, is it better to (a) serve your own load, (b) export to +# Base Power at the flat buyback, or (c) hold for later (battery)? +# +# Plan facts (Base Power, verified 2026-09-06): +# import = $0.085/kWh flat (through Oct 2028) +# export = $0.040/kWh flat (Base solar credit — NOT real-time wholesale) +# Therefore: serving your own load ALWAYS beats exporting to Base +# (8.5c avoided > 4c earned) whenever deferrable load exists. The ERCOT +# real-time price (RTM, LZ_SOUTH for Pflugerville/Travis, ONCOR wires) +# matters as a COUNTERFACTUAL: if you switched export to a real-time- +# wholesale plan, when would exporting have beaten self-consumption? +# +# Price sources (first available wins; set via env or /etc/knelerf/ercot.env): +# ERCOT_API_KEY official api.ercot.com public API (developer.ercot.com) +# GRIDSTATUS_API_KEY api.gridstatus.io free tier +# RTM_OVERRIDE manual $/MWh (testing / cache fallback) +# RTM_CACHE_FILE last-known-good price file, refreshed when possible +# Output: JSON verdict on stdout (consumed by the KNELPerf batch gate). +set -euo pipefail + +IMPORT_RATE=${IMPORT_RATE:-0.085} # $/kWh, Base Power flat +EXPORT_RATE=${EXPORT_RATE:-0.040} # $/kWh, Base Power flat buyback +SETTLEMENT_POINT=${SETTLEMENT_POINT:-LZ_SOUTH} # Pflugerville/Travis Co. +CACHE=${RTM_CACHE_FILE:-/tmp/knelerf-rtm-cache.json} + +now=$(date -u +%Y-%m-%dT%H:%M:%SZ) +rtm="" + +fetch_gridstatus() { # needs GRIDSTATUS_API_KEY; returns $/MWh + [ -n "${GRIDSTATUS_API_KEY:-}" ] || return 1 + local url="https://api.gridstatus.io/v1/datasets/ercot_spp_real_time_15_min?limit=200" + curl -sf -m 30 -H "x-api-key: $GRIDSTATUS_API_KEY" "$url" \ + | jq -r --arg sp "$SETTLEMENT_POINT" \ + '[.data[] | select(.settlement_point == $sp)][0].price // empty' +} + +fetch_ercot() { # needs ERCOT_API_KEY (official) + [ -n "${ERCOT_API_KEY:-}" ] || return 1 + # official public API path; shape verified at registration time + curl -sf -m 30 -H "Authorization: Bearer $ERCOT_API_KEY" -H "Accept: application/json" \ + "https://api.ercot.com/api/public-api/np6-905-cd?deliveryDateFrom=$(date +%F)&settlementPoint=$SETTLEMENT_POINT&size=1" \ + | jq -r '.data[-1].settlementPointPrice // empty' +} + +[ -n "${RTM_OVERRIDE:-}" ] && rtm=$RTM_OVERRIDE +[ -z "$rtm" ] && rtm=$(fetch_gridstatus || true) +[ -z "$rtm" ] && rtm=$(fetch_ercot || true) +src=api +[ -n "${RTM_OVERRIDE:-}" ] && src=override +if [ -z "$rtm" ] && [ -r "$CACHE" ]; then + rtm=$(jq -r '.rtm_per_mwh // empty' "$CACHE"); src=cache +fi +if [ -z "$rtm" ]; then + jq -n --arg now "$now" '{ts:$now, ok:false, error:"no price source available (set ERCOT_API_KEY or GRIDSTATUS_API_KEY; see docs/oncor-realtime-arbitrage.md)"}' + exit 3 +fi + +rtm_kwh=$(awk -v r="$rtm" 'BEGIN{printf "%.5f", r/1000}') + +# Decisions (per kWh of surplus): +# serve_load_value = IMPORT_RATE (avoided purchase) +# export_base = EXPORT_RATE (flat) +# export_rtw_plan = rtm_kwh (counterfactual, real-time plan) +verdict=$(awk -v i="$IMPORT_RATE" -v e="$EXPORT_RATE" -v r="$rtm_kwh" 'BEGIN{ + best="serve_load"; v=i; + if (r > v) {best="export_rtw_plan"; v=r} + if (e > v) {best="export_base"; v=e} + print best" "v}') +read -r action value <<< "$verdict" + +# Batch-load gate (KNELPerf class-3): start deferrable compute while +# surplus exists AND grid price would not make serving load later cheaper. +jq -n --arg now "$now" --arg sp "$SETTLEMENT_POINT" --arg src "$src" \ + --argjson rtm "$rtm" --argjson rtm_kwh "$rtm_kwh" \ + --argjson import "$IMPORT_RATE" --argjson export "$EXPORT_RATE" \ + --arg action "$action" --argjson value "$value" \ + '{ts:$now, ok:true, settlement_point:$sp, source:$src, + rtm_per_mwh:$rtm, rtm_per_kwh:$rtm_kwh, + import_rate: $import, export_rate_base: $export, + best_action: $action, best_value_per_kwh: $value, + serve_load_wins: ($import > $export and $import > $rtm_kwh), + rtw_would_beat_self_consume: ($rtm_kwh > $import), + start_deferrable_load: ($import > $export)}' | tee /tmp/oncor-latest.json +cp /tmp/oncor-latest.json "$CACHE" 2>/dev/null || true