11import numpy as np
22from attrs import field , define
33
4- from h2integrate .core .utilities import BaseConfig , merge_shared_inputs
5- from h2integrate .core .validators import range_val , range_val_or_none
6- from h2integrate .core .model_baseclasses import PerformanceModelBaseClass
4+ from h2integrate .core .utilities import merge_shared_inputs
5+ from h2integrate .core .validators import range_val_or_none
6+ from h2integrate .storage .storage_baseclass import (
7+ StoragePerformanceBase ,
8+ StoragePerformanceBaseConfig ,
9+ )
710
811
912@define (kw_only = True )
10- class StorageSizingModelConfig (BaseConfig ):
13+ class StorageSizingModelConfig (StoragePerformanceBaseConfig ):
1114 """Configuration class for the StorageAutoSizingModel.
1215
1316 Attributes:
@@ -38,8 +41,8 @@ class StorageSizingModelConfig(BaseConfig):
3841 commodity : str = field (converter = (str .strip , str .lower ))
3942 commodity_rate_units : str = field (converter = str .strip )
4043
41- min_soc_fraction : float = field (validator = range_val (0 , 1 ))
42- max_soc_fraction : float = field (validator = range_val (0 , 1 ))
44+ # min_soc_fraction: float = field(validator=range_val(0, 1))
45+ # max_soc_fraction: float = field(validator=range_val(0, 1))
4346
4447 # TODO: add in logic for having different discharge rate
4548 # charge_equals_discharge: bool = field(default=True)
@@ -80,7 +83,7 @@ def __attrs_post_init__(self):
8083 self .commodity_amount_units = f"({ self .commodity_rate_units } )*h"
8184
8285
83- class StorageAutoSizingModel (PerformanceModelBaseClass ):
86+ class StorageAutoSizingModel (StoragePerformanceBase ):
8487 """Performance model that calculates the storage charge rate and capacity needed
8588 to either:
8689
@@ -136,23 +139,6 @@ def setup(self):
136139
137140 super ().setup ()
138141
139- # Inputs
140- self .add_input (
141- f"{ self .commodity } _demand" ,
142- val = self .config .demand_profile ,
143- shape = self .n_timesteps ,
144- units = self .commodity_rate_units ,
145- desc = f"{ self .commodity } demand profile timeseries" ,
146- )
147-
148- self .add_input (
149- f"{ self .commodity } _in" ,
150- val = 0.0 ,
151- shape = self .n_timesteps ,
152- units = self .commodity_rate_units ,
153- desc = f"{ self .commodity } input timeseries from production to storage" ,
154- )
155-
156142 # Capacity outputs
157143 self .add_output (
158144 "storage_capacity" ,
@@ -175,87 +161,6 @@ def setup(self):
175161 units = self .commodity_rate_units ,
176162 )
177163
178- self .add_output (
179- "storage_duration" ,
180- units = f"({ self .commodity_amount_units } )/({ self .commodity_rate_units } )" ,
181- desc = "Estimated storage duration based on max capacity and discharge rate" ,
182- )
183-
184- # Storage performance outputs
185- self .add_output (
186- "SOC" ,
187- val = 0.0 ,
188- shape = self .n_timesteps ,
189- units = "percent" ,
190- desc = "State of charge of storage" ,
191- )
192-
193- self .add_output (
194- f"storage_{ self .commodity } _discharge" ,
195- val = 0.0 ,
196- shape = self .n_timesteps ,
197- units = self .commodity_rate_units ,
198- desc = f"{ self .commodity } output from storage only" ,
199- )
200-
201- self .add_output (
202- f"storage_{ self .commodity } _charge" ,
203- val = 0.0 ,
204- shape = self .n_timesteps ,
205- units = self .commodity_rate_units ,
206- desc = f"{ self .commodity } input to storage only" ,
207- )
208-
209- self .add_output (
210- f"storage_{ self .commodity } _out" ,
211- val = 0.0 ,
212- shape = self .n_timesteps ,
213- units = self .commodity_rate_units ,
214- desc = f"{ self .commodity } input and output from storage" ,
215- )
216-
217- # Combined storage and input outputs
218- self .add_output (
219- f"unmet_{ self .commodity } _demand_out" ,
220- val = 0.0 ,
221- shape = self .n_timesteps ,
222- units = self .commodity_rate_units ,
223- desc = f"Unmet { self .commodity } demand" ,
224- )
225-
226- self .add_output (
227- f"unused_{ self .commodity } _out" ,
228- val = 0.0 ,
229- shape = self .n_timesteps ,
230- units = self .commodity_rate_units ,
231- desc = "Unused generated commodity" ,
232- )
233-
234- self .dt_hr = self .dt / 3600 # convert from seconds to hours
235-
236- # create a variable to determine whether we are using feedback control
237- # for this technology
238- using_feedback_control = False
239- # create inputs for pyomo control model
240- if "tech_to_dispatch_connections" in self .options ["plant_config" ]:
241- self .tech_group_name = self .pathname .split ("." )
242- for _source_tech , intended_dispatch_tech in self .options ["plant_config" ][
243- "tech_to_dispatch_connections"
244- ]:
245- if any (intended_dispatch_tech in name for name in self .tech_group_name ):
246- self .add_discrete_input ("pyomo_dispatch_solver" , val = dummy_function )
247- using_feedback_control = True
248- break
249-
250- if not using_feedback_control :
251- # using an open-loop storage controller
252- self .add_input (
253- f"{ self .commodity } _set_point" ,
254- val = 0.0 ,
255- shape = self .n_timesteps ,
256- units = self .commodity_rate_units ,
257- )
258-
259164 def compute (self , inputs , outputs , discrete_inputs = [], discrete_outputs = []):
260165 """
261166 Part 0: get demand profile based on user input parameters:
@@ -357,221 +262,28 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
357262 [self .config .min_soc_fraction , commodity_storage_soc [0 ] / rated_storage_capacity ]
358263 )
359264
360- # 2. Simulate the storage performance using the `simulate()`
361- if "pyomo_dispatch_solver" in discrete_inputs :
362- # Simulate the storage with provided dispatch inputs
363- dispatch = discrete_inputs ["pyomo_dispatch_solver" ]
364- # kwargs are tech-specific inputs to the simulate() method
365- kwargs = {
366- "charge_rate" : storage_max_fill_rate ,
367- "discharge_rate" : storage_max_empty_rate ,
368- "storage_capacity" : rated_storage_capacity ,
369- }
370-
371- # Make dictionary of inputs containing information to pass to the controller
372- # (such as demand profile, charge rate, and storage capacity)
373- inputs_adjusted = dict (inputs .items ())
374- inputs_adjusted ["storage_capacity" ] = np .array ([rated_storage_capacity ])
375- inputs_adjusted ["max_charge_rate" ] = np .array ([storage_max_fill_rate ])
376-
377- if self .config .set_demand_as_avg_commodity_in :
378- inputs_adjusted [f"{ self .commodity } _demand" ] = commodity_demand
379- storage_commodity_out , soc = dispatch (self .simulate , kwargs , inputs_adjusted )
380-
381- else :
382- # Simulate the storage with provided inputs using dispatch commands from
383- # an open-loop controller. The commodity_set_point should come from an
384- # open-loop controller. commodity_set_point is negative when commanding
385- # storage to charge and positive when commanding storage to discharge
386- storage_commodity_out , soc = self .simulate (
387- inputs [f"{ self .commodity } _set_point" ],
388- storage_max_fill_rate ,
389- storage_max_empty_rate ,
390- rated_storage_capacity ,
391- )
392-
393- # soc output from simulating storage is represented as a percentage
394- storage_commodity_out = np .array (storage_commodity_out )
395-
396- # 3. Calculate outputs
397-
398- # calculate combined commodity out from inflow source and storage
399- # (note: storage_commodity_out is negative when charging)
400- combined_commodity_out = inputs [f"{ self .commodity } _in" ] + storage_commodity_out
401-
402- # find the total commodity out to meet demand
403- total_commodity_out = np .minimum (commodity_demand , combined_commodity_out )
404-
405- # determine how much of the inflow commodity was unused
406- unused_commodity = np .maximum (0 , combined_commodity_out - commodity_demand )
407-
408- # determine how much demand was not met
409- unmet_demand = np .maximum (0 , commodity_demand - combined_commodity_out )
410-
411- # Output the storage performance outputs
412- outputs [f"storage_{ self .commodity } _charge" ] = np .where (
413- storage_commodity_out < 0 , storage_commodity_out , 0
414- )
415- outputs [f"storage_{ self .commodity } _discharge" ] = np .where (
416- storage_commodity_out > 0 , storage_commodity_out , 0
417- )
418- outputs [f"unmet_{ self .commodity } _demand_out" ] = unmet_demand
419- outputs [f"unused_{ self .commodity } _out" ] = unused_commodity
420- outputs [f"storage_{ self .commodity } _out" ] = storage_commodity_out
421- outputs ["SOC" ] = soc
422-
423- # commodity_out is the commodity_in - charge_storage + discharge_storage
424- outputs [f"{ self .commodity } _out" ] = total_commodity_out
425-
426265 # Output the calculated storage sizes (charge rate and capacity)
427266 outputs ["max_charge_rate" ] = storage_max_fill_rate
428267 outputs ["max_discharge_rate" ] = storage_max_empty_rate
429268 outputs ["storage_capacity" ] = rated_storage_capacity
430- outputs ["storage_duration" ] = outputs ["storage_capacity" ] / outputs ["max_discharge_rate" ]
431269
432- # The rated_commodity_production is based on the discharge rate
433- # (which is assumed equal to the charge rate)
434- outputs [f"rated_{ self .commodity } _production" ] = storage_max_fill_rate
270+ # 2. Simulate the storage performance using the `simulate()`
435271
436- # The total_commodity_produced is the sum of the commodity discharged from storage
437- outputs [f"total_{ self .commodity } _produced" ] = np .sum (total_commodity_out )
272+ # Make dictionary of inputs containing information to pass to the controller
273+ # (such as demand profile, charge rate, and storage capacity)
274+ inputs_adjusted = dict (inputs .items ())
275+ if self .config .set_demand_as_avg_commodity_in :
276+ inputs_adjusted [f"{ self .commodity } _demand" ] = commodity_demand
438277
439- # Adjust the total_commodity_produced to a year-long simulation
440- outputs [f"annual_{ self .commodity } _produced" ] = outputs [
441- f"total_{ self .commodity } _produced"
442- ] * (1 / self .fraction_of_year_simulated )
278+ if "pyomo_dispatch_solver" in discrete_inputs :
279+ inputs_adjusted ["storage_capacity" ] = np .array ([rated_storage_capacity ])
280+ inputs_adjusted ["max_charge_rate" ] = np .array ([storage_max_fill_rate ])
443281
444- # Capacity factor is total discharged commodity / maximum discharged commodity possible
445- outputs ["capacity_factor" ] = outputs [f"total_{ self .commodity } _produced" ] / (
446- outputs [f"rated_{ self .commodity } _production" ] * self .n_timesteps
282+ outputs = self .run_storage (
283+ storage_max_fill_rate ,
284+ storage_max_empty_rate ,
285+ rated_storage_capacity ,
286+ inputs_adjusted ,
287+ outputs ,
288+ discrete_inputs ,
447289 )
448-
449- def simulate (
450- self ,
451- storage_dispatch_commands : list ,
452- charge_rate : float ,
453- discharge_rate : float ,
454- storage_capacity : float ,
455- sim_start_index : int = 0 ,
456- ):
457- """Run the storage model over a control window of ``n_control_window`` timesteps.
458-
459- Iterates through ``storage_dispatch_commands`` one timestep at a time.
460- A negative command requests charging; a positive command requests
461- discharging. Each command is clipped to the most restrictive of three
462- limits before it is applied:
463-
464- 1. **SOC headroom** - the remaining capacity (charge) or remaining
465- stored commodity (discharge), converted to a rate via
466- ``storage_capacity / dt_hr``.
467- 2. **Hardware rate limit** - ``charge_rate`` or ``discharge_rate``,
468- divided by the corresponding efficiency so the limit is expressed
469- in pre-efficiency rate units.
470- 3. **Commanded magnitude** - the absolute value of the dispatch command
471- itself (we never exceed what was asked for).
472-
473- After clipping, the result is scaled by the charge or discharge
474- efficiency to obtain the actual commodity flow into or out of the
475- storage, and the SOC is updated accordingly.
476-
477- This method is separated from ``compute()`` so the Pyomo dispatch
478- controller can call it directly to evaluate candidate schedules.
479-
480- Args:
481- storage_dispatch_commands (array_like[float]):
482- Dispatch set-points for each timestep in ``commodity_rate_units``.
483- Negative values command charging; positive values command
484- discharging. Length must equal ``config.n_control_window``.
485- charge_rate (float):
486- Maximum commodity input rate to storage in
487- ``commodity_rate_units`` (before charge efficiency is applied).
488- discharge_rate (float):
489- Maximum commodity output rate from storage in
490- ``commodity_rate_units`` (before discharge efficiency is applied).
491- storage_capacity (float):
492- Rated storage capacity in ``commodity_amount_units``.
493- sim_start_index (int, optional):
494- Starting index for writing into persistent output arrays.
495- Defaults to 0.
496-
497- Returns:
498- tuple[np.ndarray, np.ndarray]
499- storage_commodity_out_timesteps :
500- Commodity flow per timestep in ``commodity_rate_units``.
501- Positive = discharge (commodity leaving storage),
502- negative = charge (commodity entering storage).
503- soc_timesteps :
504- State of charge at the end of each timestep, in percent
505- (0-100).
506- """
507-
508- n = len (storage_dispatch_commands )
509- storage_commodity_out_timesteps = np .zeros (n )
510- soc_timesteps = np .zeros (n )
511-
512- # Early return when storage cannot operate: zero capacity or both
513- # charge and discharge rates are zero.
514- if storage_capacity <= 0 or (charge_rate <= 0 and discharge_rate <= 0 ):
515- soc_timesteps [:] = self .current_soc * 100.0
516- return storage_commodity_out_timesteps , soc_timesteps
517-
518- # Pre-compute scalar constants to avoid repeated attribute lookups
519- # and redundant divisions inside the per-timestep loop.
520- charge_eff = self .config .charge_efficiency
521- discharge_eff = self .config .discharge_efficiency
522- soc_max = self .config .max_soc_fraction
523- soc_min = self .config .min_soc_fraction
524-
525- commands = np .asarray (storage_dispatch_commands , dtype = float )
526- soc = float (self .current_soc )
527-
528- for t , cmd in enumerate (commands ):
529- if cmd < 0.0 :
530- # --- Charging ---
531- # headroom: how much more commodity the storage can accept,
532- # expressed as a rate (commodity_rate_units).
533- headroom = (soc_max - soc ) * storage_capacity / self .dt_hr
534-
535- # Clip to the most restrictive limit, then apply efficiency.
536- # max(0, ...) guards against negative headroom when SOC
537- # slightly exceeds soc_max.
538- # correct headroom to not include charge_eff.
539- actual_charge = max (0.0 , min (headroom / charge_eff , charge_rate , - cmd )) * charge_eff
540-
541- # Update SOC (actual_charge is in post-efficiency units)
542- soc += actual_charge / storage_capacity
543-
544- # Update the amount of commodity used to charge from the input stream
545- # If charge_eff<1, more commodity is pulled from the input stream than
546- # the commodity that goes into the storage.
547- storage_commodity_out_timesteps [t ] = - actual_charge / charge_eff
548- else :
549- # --- Discharging ---
550- # headroom: how much commodity can still be drawn before
551- # hitting the minimum SOC, expressed as a rate.
552- headroom = (soc - soc_min ) * storage_capacity / self .dt_hr
553-
554- # Clip to the most restrictive limit without applied efficiency.
555- # Discharge efficiency losses occur as energy leaves storage.
556- actual_discharge = max (
557- 0.0 , min (headroom , discharge_rate / discharge_eff , cmd / discharge_eff )
558- )
559-
560- # Update SOC (actual_discharge is before efficiency losses are applied.)
561- soc -= actual_discharge / storage_capacity
562-
563- # If discharge_eff<1, then less commodity is output from the storage
564- # than the commodity discharged from storage
565- storage_commodity_out_timesteps [t ] = actual_discharge * discharge_eff
566-
567- soc_timesteps [t ] = soc * 100.0
568-
569- # Persist the final SOC so subsequent simulate() calls (e.g. from the
570- # Pyomo controller across rolling windows) start where we left off.
571- self .current_soc = soc
572- return storage_commodity_out_timesteps , soc_timesteps
573-
574-
575- def dummy_function ():
576- # this function is required for initializing the pyomo control input and nothing else
577- pass
0 commit comments