@@ -144,6 +144,11 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
144144)
145145
146146inputs .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+
147152print ("\n === Hydro Ramping Rates ===" )
148153for 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
452457hydro_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