Skip to content

Commit 39684fe

Browse files
committed
feat(ramping): hydro ramping
1 parent 5c291e9 commit 39684fe

File tree

10 files changed

+481
-14
lines changed

10 files changed

+481
-14
lines changed

scripts/run_hydrochipps.py

Lines changed: 189 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- HydroCHIPPs (DoE-sponsored HydroWIRES project)
2424
"""
2525
import os
26+
import pandas as pd
2627
import numpy as np
2728
from datetime import datetime
2829
from pownet.core import (
@@ -114,9 +115,10 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
114115
weekly_prices = np.round(np.random.uniform(5, 240, 168), 1)
115116
weekly_contract_costs.append(weekly_prices)
116117

117-
#%%########################################
118-
# Input Processing and Simulation Loop
119-
###########################################
118+
####################################
119+
# Input Processing and Simulation Loop
120+
#####################################
121+
120122
# Optional: re-process input data from scratch
121123
if to_process_inputs:
122124
data_processor = DataProcessor(
@@ -140,6 +142,7 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
140142
line_capacity_factor=1,
141143
line_loss_factor=0,
142144
)
145+
143146
inputs.load_and_check_data()
144147

145148
# Dictionaries to store results and models
@@ -154,27 +157,31 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
154157
update_weekly_hydro_capacity(inputs.weekly_hydro_capacity, start_day, first_values)
155158

156159
# === Apply updated weekly prices for this day ===
157-
update_contract_costs(inputs, 'supplier', weekly_contract_costs[start_day-1], start_day)
158-
159-
# Optional: push updates into the model (uncomment if needed)
160-
# inputs.update_capacity()
160+
update_contract_costs(inputs, 'supplier', weekly_contract_costs[start_day - 1], start_day)
161161

162162
# Initialize model and record-keeping objects
163163
model_builder = ModelBuilder(inputs)
164164
record = SystemRecord(inputs)
165+
165166
build_times = []
166167
opt_times = []
167168
objvals = []
168169

169-
# Get initial condition state (e.g., generator status)
170-
init_conditions = create_init_condition(inputs.thermal_units)
170+
# ========================================================================
171+
# CRITICAL FIX: Initialize with proper hydro initial conditions
172+
# ========================================================================
173+
init_conditions = create_init_condition(
174+
thermal_units=inputs.thermal_units,
175+
storage_units=inputs.storage_units,
176+
ess_max_capacity=inputs.ess_max_capacity,
177+
hydro_units=inputs.hydro_units,
178+
)
171179

172180
# === Inner loop: run multiple steps from this starting day ===
173181
if steps_to_run is None:
174-
steps_to_run = 10 # 365 - (sim_horizon // 24 - 1)
182+
steps_to_run = 10
175183

176-
#todo:select the start date
177-
for step_k in range(start_day, start_day+steps_to_run-1):
184+
for step_k in range(start_day, start_day + steps_to_run - 1):
178185
start_time = datetime.now()
179186

180187
# Build or update the model for the current step
@@ -188,6 +195,7 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
188195
step_k=step_k,
189196
init_conds=init_conditions,
190197
)
198+
191199
build_times.append((datetime.now() - start_time).total_seconds())
192200

193201
# Solve the model using HiGHS solver
@@ -206,9 +214,54 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
206214
runtime=power_system_model[start_day].get_runtime(),
207215
objval=power_system_model[start_day].get_objval(),
208216
solution=power_system_model[start_day].get_solution(),
209-
# lmp=power_system_model[start_day].solve_for_lmp(),
210217
step_k=step_k,
211218
)
219+
220+
# ========================================================================
221+
# CRITICAL FIX: Extract hydro dispatch at t=24 for next iteration
222+
# ========================================================================
223+
if step_k == start_day:
224+
# Get the solution DataFrame
225+
first_solution = power_system_model[start_day].get_solution()
226+
227+
# Initialize dictionary to store hydro dispatch at t=24
228+
initial_phydro = {}
229+
230+
# Iterate through all variables to find phydro at t=24
231+
for idx, row in first_solution.iterrows():
232+
varname = row['varname']
233+
234+
# Check if this is a phydro variable
235+
if varname.startswith('phydro['):
236+
# Extract unit and timestep using string operations
237+
# Format: phydro[unit, timestep]
238+
try:
239+
# Remove 'phydro[' prefix and ']' suffix
240+
content = varname[7:-1] # Skip 'phydro[' and ']'
241+
242+
# Split by comma
243+
parts = content.split(', ')
244+
245+
if len(parts) == 2:
246+
unit = parts[0].strip()
247+
timestep = int(parts[1].strip())
248+
249+
# Only keep values at t=24
250+
if timestep == 24:
251+
initial_phydro[unit] = row['value']
252+
except (ValueError, IndexError) as e:
253+
print(f"⚠️ Warning: Could not parse varname '{varname}': {e}")
254+
continue
255+
256+
# Update init_conditions if we found any hydro dispatch values
257+
if initial_phydro:
258+
init_conditions['initial_phydro'] = initial_phydro
259+
print(f"✓ Initialized hydro ramping for {len(initial_phydro)} units at step {step_k}")
260+
print(f" Sample values: {dict(list(initial_phydro.items())[:3])}")
261+
else:
262+
print(f"⚠️ Warning: No hydro dispatch found at t=24 for step {step_k}")
263+
264+
# Update initial conditions from record for subsequent iterations
212265
init_conditions = record.get_init_conds()
213266

214267
#########################################
@@ -232,5 +285,128 @@ def update_contract_costs(inputs, supplier, new_values, start_day):
232285
os.makedirs(output_folder, exist_ok=True)
233286
export_results[start_day].to_csv(output_folder + f'results_day_{str(start_day)}.csv', index=False)
234287

288+
####################################
289+
##### Quick hydro ramping check ####
290+
####################################
291+
292+
import matplotlib.pyplot as plt
293+
294+
# Extract hydro dispatch from last solved day
295+
final_day_results = export_results[start_day + steps_to_run - 2]
296+
hydro_data = final_day_results[final_day_results['vartype'] == 'phydro']
297+
298+
if not hydro_data.empty:
299+
# ========================================================================
300+
# CRITICAL FIX: Convert 'hour' to numeric and sort before pivoting
301+
# ========================================================================
302+
hydro_data = hydro_data.copy()
303+
hydro_data['hour'] = pd.to_numeric(hydro_data['hour'], errors='coerce')
304+
305+
# Drop any rows where hour couldn't be converted
306+
hydro_data = hydro_data.dropna(subset=['hour'])
307+
308+
# Convert to integer
309+
hydro_data['hour'] = hydro_data['hour'].astype(int)
310+
311+
# Sort by hour before pivoting
312+
hydro_data = hydro_data.sort_values('hour')
313+
314+
# Now pivot
315+
hydro_pivot = hydro_data.pivot(columns='unit', index='hour', values='value')
316+
317+
# ========================================================================
318+
# VERIFY: Check if index is properly sorted
319+
# ========================================================================
320+
print(f"\n📊 Hydro dispatch index check:")
321+
print(f" First 5 hours: {hydro_pivot.index[:5].tolist()}")
322+
print(f" Last 5 hours: {hydro_pivot.index[-5:].tolist()}")
323+
print(f" Is sorted: {hydro_pivot.index.is_monotonic_increasing}")
324+
325+
for unit in hydro_pivot.columns:
326+
dispatch = hydro_pivot[unit]
327+
328+
# ========================================================================
329+
# CRITICAL: Ensure dispatch is sorted by index before diff()
330+
# ========================================================================
331+
dispatch = dispatch.sort_index()
332+
ramp_changes = dispatch.diff()
333+
334+
# ========================================================================
335+
# DIAGNOSTIC: Check for ramping violations
336+
# ========================================================================
337+
ramp_up_limit = inputs.hydro_RU.get(unit, 1e6)
338+
ramp_down_limit = inputs.hydro_RD.get(unit, 1e6)
339+
340+
violations_up = (ramp_changes > ramp_up_limit).sum()
341+
violations_down = (ramp_changes < -ramp_down_limit).sum()
342+
343+
if violations_up > 0 or violations_down > 0:
344+
print(f"\n⚠️ RAMPING VIOLATIONS DETECTED for {unit}:")
345+
print(f" Ramp-up violations: {violations_up}")
346+
print(f" Ramp-down violations: {violations_down}")
347+
print(f" Max ramp change: {ramp_changes.max():.2f} MW/h (limit: {ramp_up_limit:.2f})")
348+
print(f" Min ramp change: {ramp_changes.min():.2f} MW/h (limit: -{ramp_down_limit:.2f})")
349+
350+
# Show the specific violations
351+
violation_hours = ramp_changes[
352+
(ramp_changes > ramp_up_limit) | (ramp_changes < -ramp_down_limit)
353+
]
354+
print(f" Violation details:\n{violation_hours}")
355+
356+
# ========================================================================
357+
# PLOTTING
358+
# ========================================================================
359+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
360+
361+
# Dispatch
362+
ax1.plot(dispatch.index, dispatch.values, 'b-', linewidth=2, marker='o', markersize=3)
363+
ax1.set_ylabel('Power (MW)', fontsize=12)
364+
ax1.set_title(f'{unit} - Dispatch and Ramping', fontsize=14, weight='bold')
365+
ax1.grid(True, alpha=0.3)
366+
367+
# Ramp changes with proper coloring
368+
colors = []
369+
for c in ramp_changes.values[1:]: # Skip first NaN
370+
if abs(c) > ramp_up_limit:
371+
colors.append('red')
372+
else:
373+
colors.append('green')
374+
375+
# Plot bars
376+
ax2.bar(ramp_changes.index[1:], ramp_changes.values[1:],
377+
color=colors, alpha=0.7, edgecolor='black', linewidth=0.5)
378+
379+
# Add limit lines
380+
ax2.axhline(ramp_up_limit, color='darkred', linestyle='--', linewidth=2,
381+
label=f'Ramp-up limit ({ramp_up_limit:.0f} MW/h)')
382+
ax2.axhline(-ramp_down_limit, color='darkred', linestyle='--', linewidth=2,
383+
label=f'Ramp-down limit ({ramp_down_limit:.0f} MW/h)')
384+
ax2.axhline(0, color='black', linestyle='-', linewidth=0.8)
385+
386+
ax2.set_xlabel('Hour', fontsize=12)
387+
ax2.set_ylabel('Ramp (MW/h)', fontsize=12)
388+
ax2.set_title('Hour-to-Hour Changes', fontsize=12)
389+
ax2.legend(loc='upper right')
390+
ax2.grid(True, alpha=0.3, axis='y')
391+
392+
# Add text annotation for violations
393+
if violations_up > 0 or violations_down > 0:
394+
ax2.text(0.02, 0.98,
395+
f'⚠️ Violations: {violations_up + violations_down} ({violations_up} up, {violations_down} down)',
396+
transform=ax2.transAxes, fontsize=11, weight='bold',
397+
verticalalignment='top',
398+
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.8))
399+
else:
400+
ax2.text(0.02, 0.98,
401+
'✓ All ramps within limits',
402+
transform=ax2.transAxes, fontsize=11, weight='bold',
403+
verticalalignment='top',
404+
bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.8))
405+
406+
plt.tight_layout()
407+
plt.savefig(f'{output_folder}/{unit}_ramping_check.png',
408+
dpi=300, bbox_inches='tight')
409+
plt.show()
410+
235411
# for i in range(1,6):
236412
# print(export_results[i][(export_results[i]['vartype'] == 'phydro') & (export_results[i]['unit'] == 'Barkley')]['value'].sum())

src/pownet/builder/hydro.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ def __init__(self, model: gp.Model, inputs: SystemInput):
6464

6565
self.c_link_hydro_pu = gp.tupledict()
6666

67+
self.c_hydro_ramp_up_init = gp.tupledict()
68+
self.c_hydro_ramp_up = gp.tupledict()
69+
self.c_hydro_ramp_down_init = gp.tupledict()
70+
self.c_hydro_ramp_down = gp.tupledict()
71+
6772
def add_variables(self, step_k: int) -> None:
6873
"""Add variables to the model for hydro units.
6974
@@ -129,6 +134,7 @@ def add_variables(self, step_k: int) -> None:
129134
name="uhydro",
130135
)
131136

137+
#TODO add fixed ramping cost term to the objective
132138
def get_fixed_objective_terms(self) -> gp.LinExpr:
133139
"""Hydropower units have no fixed objective terms."""
134140
return self.total_fixed_objective_expr
@@ -155,6 +161,7 @@ def get_variable_objective_terms(self, step_k: int) -> gp.LinExpr:
155161

156162
return self.total_energy_cost_expr
157163

164+
# TODO check if you can just add the ramp constr here
158165
def add_constraints(self, step_k: int, init_conds: dict, **kwargs) -> None:
159166
# Hourly upper bound
160167
# Limited by contracted capacity
@@ -217,6 +224,39 @@ def add_constraints(self, step_k: int, init_conds: dict, **kwargs) -> None:
217224
contracted_capacity_dict=self.inputs.hydro_contracted_capacity,
218225
)
219226

227+
# Hydro ramping constraints
228+
self.c_hydro_ramp_up_init = nondispatch_constr.add_c_hydro_ramp_up_init(
229+
model=self.model,
230+
phydro=self.phydro,
231+
hydro_units=self.inputs.hydro_units,
232+
initial_phydro=init_conds.get("initial_phydro", {}),
233+
hydro_RU=self.inputs.hydro_RU,
234+
)
235+
236+
self.c_hydro_ramp_up = nondispatch_constr.add_c_hydro_ramp_up(
237+
model=self.model,
238+
phydro=self.phydro,
239+
sim_horizon=self.inputs.sim_horizon,
240+
hydro_units=self.inputs.hydro_units,
241+
hydro_RU=self.inputs.hydro_RU,
242+
)
243+
244+
self.c_hydro_ramp_down_init = nondispatch_constr.add_c_hydro_ramp_down_init(
245+
model=self.model,
246+
phydro=self.phydro,
247+
hydro_units=self.inputs.hydro_units,
248+
initial_phydro=init_conds.get("initial_phydro", {}),
249+
hydro_RD=self.inputs.hydro_RD,
250+
)
251+
252+
self.c_hydro_ramp_down = nondispatch_constr.add_c_hydro_ramp_down(
253+
model=self.model,
254+
phydro=self.phydro,
255+
sim_horizon=self.inputs.sim_horizon,
256+
hydro_units=self.inputs.hydro_units,
257+
hydro_RD=self.inputs.hydro_RD,
258+
)
259+
220260
def update_variables(self, step_k: int) -> None:
221261
"Some hydropower units have hourly upper bounds."
222262
# Update the time-dependent upper bound of the variable
@@ -226,6 +266,7 @@ def update_variables(self, step_k: int) -> None:
226266
capacity_df=self.inputs.hydro_capacity,
227267
)
228268

269+
229270
def update_constraints(self, step_k: int, init_conds: dict, **kwargs) -> None:
230271
"""Update constraints for hydro units.
231272
Args:
@@ -268,6 +309,27 @@ def update_constraints(self, step_k: int, init_conds: dict, **kwargs) -> None:
268309
hydro_capacity_min=self.inputs.hydro_min_capacity,
269310
)
270311

312+
# Remove old ramping constraints
313+
self.model.remove(self.c_hydro_ramp_up_init)
314+
self.model.remove(self.c_hydro_ramp_down_init)
315+
316+
# Re-add with new initial conditions
317+
self.c_hydro_ramp_up_init = nondispatch_constr.add_c_hydro_ramp_up_init(
318+
model=self.model,
319+
phydro=self.phydro,
320+
hydro_units=self.inputs.hydro_units,
321+
initial_phydro=init_conds.get("initial_phydro", {}),
322+
hydro_RU=self.inputs.hydro_RU,
323+
)
324+
325+
self.c_hydro_ramp_down_init = nondispatch_constr.add_c_hydro_ramp_down_init(
326+
model=self.model,
327+
phydro=self.phydro,
328+
hydro_units=self.inputs.hydro_units,
329+
initial_phydro=init_conds.get("initial_phydro", {}),
330+
hydro_RD=self.inputs.hydro_RD,
331+
)
332+
271333
def update_daily_hydropower_capacity(
272334
self, step_k: int, new_capacity: dict[tuple[str, int], float]
273335
) -> None:

src/pownet/builder/thermal.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def __init__(self, model: gp.Model, inputs: SystemInput) -> None:
8888
self.c_peak_up_bound = gp.tupledict()
8989
self.c_ramp_down_init = gp.tupledict()
9090
self.c_ramp_up_init = gp.tupledict()
91+
#TODO take those and add to hydro builder
9192
self.c_ramp_down = gp.tupledict()
9293
self.c_ramp_up = gp.tupledict()
9394

@@ -401,6 +402,7 @@ def update_variables(self, step_k: int) -> None:
401402
for var_dict in thermal_unit_vars:
402403
update_var_with_variable_ub(var_dict, step_k, self.thermal_derated_capacity)
403404

405+
404406
def update_constraints(self, step_k: int, init_conds: dict, **kwargs) -> None:
405407
"""Update time-dependent constraints:
406408
- c_link_uvw_init: initial_u is from the previous iteration

0 commit comments

Comments
 (0)