Skip to content

Commit c8fa313

Browse files
committed
feat(pen_zones): turbine penalty zones, operating range now penalized with multiple operating zones
1 parent 68e20a1 commit c8fa313

File tree

3 files changed

+421
-5
lines changed

3 files changed

+421
-5
lines changed

scripts/run_hydrochipps.py

Lines changed: 253 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
144144
)
145145

146146
inputs.load_and_check_data()
147+
148+
# Check if penalty zones were loaded
149+
print(inputs.use_hydro_penalty_zones) # True
150+
print(inputs.hydro_penalty_zones["Barkley"])
151+
147152
print("\n=== Hydro Ramping Rates ===")
148153
for unit in inputs.hydro_units:
149154
print(f"{unit:20s} | RU: {inputs.hydro_RU[unit]:6.1f} MW/h | RD: {inputs.hydro_RD[unit]:6.1f} MW/h")
@@ -451,13 +456,257 @@ def plot_hydro_ramping_check(
451456

452457
hydro_data = final_day_results.loc[final_day_results["vartype"] == "phydro", ["hour", "unit", "value"]]
453458

454-
plot_hydro_ramping_check(
459+
# plot_hydro_ramping_check(
460+
# hydro_data=hydro_data,
461+
# hydro_RU=inputs.hydro_RU,
462+
# hydro_RD=inputs.hydro_RD,
463+
# output_folder=output_folder,
464+
# units_to_plot=None, # or e.g. ["J_Percy_Priest"]
465+
# eps=1e-6,
466+
# )
467+
468+
469+
def plot_hydro_ramping(
470+
hydro_data: pd.DataFrame,
471+
hydro_RU: dict,
472+
hydro_RD: dict,
473+
output_folder: str,
474+
units_to_plot=None,
475+
eps: float = 1e-3,
476+
penalty_zones: dict[str, list[dict]] = None,
477+
hydro_max_capacity: dict[str, float] = None,
478+
) -> None:
479+
"""
480+
hydro_data: long dataframe with columns ['hour', 'unit', 'value'] for vartype='phydro'
481+
hydro_RU / hydro_RD: dict[unit] -> MW/h
482+
penalty_zones: dict[unit] -> list of dicts with keys 'min_pct', 'max_pct', 'penalty'
483+
hydro_max_capacity: dict[unit] -> MW (max capacity for converting percentages)
484+
eps: tolerance for ramping violations
485+
"""
486+
import matplotlib.pyplot as plt
487+
import numpy as np
488+
import pandas as pd
489+
import os
490+
491+
if hydro_data.empty:
492+
print("No hydro dispatch to check.")
493+
return
494+
495+
# --- Clean hour, ensure numeric, sort ---
496+
df = hydro_data.copy()
497+
df["hour"] = pd.to_numeric(df["hour"], errors="coerce")
498+
df = df.dropna(subset=["hour"])
499+
df["hour"] = df["hour"].astype(int)
500+
df = df.sort_values(["unit", "hour"])
501+
502+
# --- Pivot: hour index, unit columns ---
503+
pivot = df.pivot(index="hour", columns="unit", values="value").sort_index()
504+
505+
# Validate index integrity
506+
if pivot.index.has_duplicates:
507+
dupes = pivot.index[pivot.index.duplicated()].unique().tolist()
508+
raise ValueError(f"Duplicate hours in hydro pivot index: {dupes[:20]}...")
509+
510+
# Choose units
511+
units = list(pivot.columns) if units_to_plot is None else [u for u in units_to_plot if u in pivot.columns]
512+
513+
if not units:
514+
print("No matching hydro units to plot.")
515+
return
516+
517+
os.makedirs(output_folder, exist_ok=True)
518+
519+
for unit in units:
520+
dispatch = pivot[unit].dropna().sort_index()
521+
522+
if dispatch.empty or len(dispatch) < 2:
523+
print(f"{unit}: not enough data to compute ramps.")
524+
continue
525+
526+
ru = float(hydro_RU.get(unit, np.inf))
527+
rd = float(hydro_RD.get(unit, np.inf))
528+
529+
ramps = dispatch.diff() # MW/h; first is NaN
530+
ramps_no_na = ramps.dropna()
531+
532+
# --- Violation logic with tolerance ---
533+
viol_up_mask = ramps_no_na > (ru + eps)
534+
viol_dn_mask = ramps_no_na < (-rd - eps)
535+
viol_mask = viol_up_mask | viol_dn_mask
536+
537+
n_up = int(viol_up_mask.sum())
538+
n_dn = int(viol_dn_mask.sum())
539+
540+
# --- Diagnostics ---
541+
max_ramp = float(ramps_no_na.max())
542+
min_ramp = float(ramps_no_na.min())
543+
544+
if (n_up > 0) or (n_dn > 0):
545+
print(f"\n⚠️ RAMPING VIOLATIONS DETECTED for {unit}:")
546+
print(f" Ramp-up violations: {n_up}")
547+
print(f" Ramp-down violations: {n_dn}")
548+
print(f" Max ramp change: {max_ramp:.12f} MW/h (limit: {ru:.12f})")
549+
print(f" Min ramp change: {min_ramp:.12f} MW/h (limit: {-rd:.12f})")
550+
print(" Violation details (full precision):")
551+
viol_series = ramps_no_na[viol_mask].copy()
552+
resid = pd.Series(index=viol_series.index, dtype=float)
553+
resid.loc[viol_up_mask[viol_mask].index] = viol_series.loc[viol_up_mask] - ru
554+
resid.loc[viol_dn_mask[viol_mask].index] = (-rd) - viol_series.loc[viol_dn_mask]
555+
out = pd.DataFrame({"ramp": viol_series, "residual": resid})
556+
pd.set_option("display.precision", 15)
557+
print(out)
558+
else:
559+
print(f"\n{unit}: all ramps within limits (eps={eps}).")
560+
print(f" Max ramp change: {max_ramp:.12f} MW/h (limit: {ru:.12f})")
561+
print(f" Min ramp change: {min_ramp:.12f} MW/h (limit: {-rd:.12f})")
562+
563+
# --- Colors for bars (correct, sign-aware) ---
564+
colors = []
565+
for h, c in ramps_no_na.items():
566+
if c > ru + eps:
567+
colors.append("red")
568+
elif c < -rd - eps:
569+
colors.append("red")
570+
else:
571+
colors.append("green")
572+
573+
# --- Plot ---
574+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
575+
576+
# ============================================================
577+
# TOP PANEL: Dispatch with penalty zones
578+
# ============================================================
579+
580+
# Get x-axis limits (hour range)
581+
x_min = dispatch.index.min()
582+
x_max = dispatch.index.max()
583+
584+
# Plot penalty zones FIRST (so they appear behind the dispatch line)
585+
if penalty_zones and unit in penalty_zones and hydro_max_capacity:
586+
max_cap = hydro_max_capacity.get(unit, dispatch.max())
587+
zones = penalty_zones[unit]
588+
589+
print(f"\n📊 Plotting penalty zones for {unit}:")
590+
print(f" Max capacity: {max_cap:.2f} MW")
591+
print(f" Number of zones: {len(zones)}")
592+
593+
# Sort zones by penalty (ascending) for consistent shading
594+
zones_sorted = sorted(zones, key=lambda z: z["penalty"])
595+
596+
# Create blue gradient: light blue for cheap, dark blue for expensive
597+
n_zones = len(zones_sorted)
598+
599+
# Define color range
600+
light_blue = np.array([227/255, 242/255, 253/255]) # #E3F2FD
601+
dark_blue = np.array([13/255, 71/255, 161/255]) # #0D47A1
602+
603+
legend_added = set()
604+
605+
for idx, zone in enumerate(zones_sorted):
606+
# Calculate color intensity (0 = lightest, 1 = darkest)
607+
intensity = idx / max(1, n_zones - 1)
608+
609+
# Interpolate color
610+
color = light_blue + intensity * (dark_blue - light_blue)
611+
612+
# Convert percentages to MW
613+
min_mw = (zone["min_pct"] / 100.0) * max_cap
614+
max_mw = (zone["max_pct"] / 100.0) * max_cap
615+
616+
print(f" Zone {idx+1}: {zone['min_pct']:.0f}%-{zone['max_pct']:.0f}% "
617+
f"({min_mw:.2f}-{max_mw:.2f} MW), penalty=${zone['penalty']:.0f}/MWh")
618+
619+
# Determine zone label
620+
if zone["penalty"] >= 500:
621+
zone_label = "High Penalty (≥$500/MWh)"
622+
zone_type = "high"
623+
elif zone["penalty"] >= 50:
624+
zone_label = "Medium Penalty ($50-500/MWh)"
625+
zone_type = "medium"
626+
else:
627+
zone_label = "Low/Zero Penalty (<$50/MWh)"
628+
zone_type = "low"
629+
630+
# Fill the entire zone area using axhspan (horizontal span)
631+
ax1.axhspan(
632+
min_mw,
633+
max_mw,
634+
color=color,
635+
alpha=0.4,
636+
label=zone_label if zone_type not in legend_added else None,
637+
zorder=1
638+
)
639+
legend_added.add(zone_type)
640+
641+
# Add penalty annotation on the right side
642+
mid_mw = (min_mw + max_mw) / 2
643+
ax1.text(
644+
1.01,
645+
mid_mw,
646+
f"${zone['penalty']:.0f}/MWh",
647+
transform=ax1.get_yaxis_transform(),
648+
verticalalignment='center',
649+
fontsize=9,
650+
bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.9, edgecolor='gray')
651+
)
652+
653+
# Plot dispatch line (on top of zones)
654+
ax1.plot(dispatch.index, dispatch.values, "-o", linewidth=2, markersize=3,
655+
color="darkblue", label="Dispatch", zorder=10)
656+
ax1.set_ylabel("Power (MW)", fontsize=12)
657+
ax1.set_title(f"{unit} - Dispatch and Ramping Check", fontsize=14, weight='bold')
658+
ax1.grid(True, alpha=0.3, zorder=0)
659+
ax1.legend(loc='upper left', fontsize=10, framealpha=0.95)
660+
661+
# ============================================================
662+
# BOTTOM PANEL: Ramp bars
663+
# ============================================================
664+
ax2.bar(ramps_no_na.index, ramps_no_na.values, color=colors, alpha=0.75,
665+
edgecolor="black", linewidth=0.4)
666+
ax2.axhline(ru, color="darkred", linestyle="--", linewidth=2,
667+
label=f"Ramp-up limit (+{ru:g} MW/h)")
668+
ax2.axhline(-rd, color="darkred", linestyle="--", linewidth=2,
669+
label=f"Ramp-down limit (-{rd:g} MW/h)")
670+
ax2.axhline(0, color="black", linewidth=0.8)
671+
ax2.set_xlabel("Hour", fontsize=12)
672+
ax2.set_ylabel("Ramp (MW/h)", fontsize=12)
673+
ax2.grid(True, alpha=0.3, axis="y")
674+
ax2.legend(loc="upper right", fontsize=10)
675+
676+
# Annotation
677+
if n_up + n_dn > 0:
678+
ax2.text(
679+
0.02, 0.98,
680+
f"Violations: {n_up + n_dn} ({n_up} up, {n_dn} down)",
681+
transform=ax2.transAxes,
682+
va="top",
683+
fontsize=11,
684+
bbox=dict(boxstyle="round", facecolor="yellow", alpha=0.85),
685+
)
686+
else:
687+
ax2.text(
688+
0.02, 0.98,
689+
"All ramps within limits",
690+
transform=ax2.transAxes,
691+
va="top",
692+
fontsize=11,
693+
bbox=dict(boxstyle="round", facecolor="lightgreen", alpha=0.85),
694+
)
695+
696+
plt.tight_layout()
697+
fname = os.path.join(output_folder, f"{unit}_ramping_check.png")
698+
plt.savefig(fname, dpi=300, bbox_inches="tight")
699+
plt.show()
700+
701+
702+
# Call the function
703+
plot_hydro_ramping(
455704
hydro_data=hydro_data,
456705
hydro_RU=inputs.hydro_RU,
457706
hydro_RD=inputs.hydro_RD,
458-
output_folder=output_folder,
459-
units_to_plot=None, # or e.g. ["J_Percy_Priest"]
460-
eps=1e-6,
707+
penalty_zones=inputs.hydro_penalty_zones if inputs.use_hydro_penalty_zones else None,
708+
hydro_max_capacity=inputs.hydro_contracted_capacity,
709+
output_folder="./outputs"
461710
)
462711

463712
# for i in range(1,6):

0 commit comments

Comments
 (0)