Standardized Performance Outputs: Generic Storage & Control#493
Standardized Performance Outputs: Generic Storage & Control#493elenya-grant wants to merge 78 commits intoNatLabRockies:developfrom
Conversation
…ART into converter_baseclass
johnjasa
left a comment
There was a problem hiding this comment.
Thanks for this, Elenya! I think it's a great step in standardizing some of these storage and control models -- there's a lot of complex interactions here.
I've left some comments and pushed up minor changes. The comments are mostly questions to provoke introspection into the approaches used here or minor suggestions for clarity, nothing too major.
| # Estimate the rated commodity production as the maximum value from the commodity_set_point | ||
| outputs[f"rated_{self.commodity}_production"] = inputs[f"{self.commodity}_set_point"].max() |
There was a problem hiding this comment.
Is this the best way of getting the rated production? If the set point never reaches the actual maximum of the system, is this a problem and could lead to confusing metrics? Legitimately asking, mostly about the generality of this and if it works well in all cases.
There was a problem hiding this comment.
Idk a better way - but this is just a pass-through component. In this case, it'd be better for this to be done in the DemandOpenLoopStorageController instead, but then we wouldn't be consistent about whether these outputs are standardized in performance models vs control strategies which would lead to errors if someone used a controller with the standard outputs with a storage model that has the standard outputs. I think issue #498 explains this though.
There was a problem hiding this comment.
I don't think we should determine rated production this way. I think both the storage and the controller need to require the technology rated production like we do with other systems. For storage, the charge and discharge rates and the rated storage capacity should be given at a minimum I think now that we are using the set_point paradigm, even for the generic pass-through component.
There was a problem hiding this comment.
Does this mean you want the SimpleGenericStorage then to have inputs for charge rate and capacity?
| # Calculate the total and annual commodity produced | ||
| outputs[f"total_{self.commodity}_produced"] = outputs[f"{self.commodity}_out"].sum() | ||
| outputs[f"annual_{self.commodity}_produced"] = outputs[ | ||
| f"total_{self.commodity}_produced" | ||
| ] * (1 / self.fraction_of_year_simulated) | ||
|
|
||
| # Calculate the maximum commodity production over the simulation | ||
| max_production = ( | ||
| inputs[f"{self.commodity}_set_point"].max() * self.n_timesteps * (self.dt / 3600) | ||
| ) | ||
|
|
||
| outputs["capacity_factor"] = outputs[f"total_{self.commodity}_produced"] / max_production |
There was a problem hiding this comment.
Tagging @jaredthomas68 and @genevievestarke for special attention to this math to make sure it matches what you expect.
There was a problem hiding this comment.
The capacity factor should be based on rated production in the denominator, not max production. Rated production should be a user input to the system (see previous comment).
There was a problem hiding this comment.
Capacity factors for storage systems (at least batteries), as far as I understand, are typically based on the nameplate discharge rate of the system.
cp = total_product_discharged_in_time_period / (name_plate_discharge_rate*time_period_duration) # of course make sure units make senseOf course, the above leads to some fairly low capacity factors, like about 0.167 for a 4 hour battery discharged daily as discussed in the ATB
There was a problem hiding this comment.
@jaredthomas68 - the SimpleGenericStorage has no inputs related to nameplate discharge rate or anything. That information is only available in the DemandOpenLoopStorageController. This is captured in Issue #498.
|
|
||
| commodity_production = inputs[f"{commodity_name}_in"] | ||
| # The commodity_set_point is the production set by the controller | ||
| commodity_production = inputs[f"{self.commodity}_set_point"] |
There was a problem hiding this comment.
Given that this might not match the actual production, would desired_commodity_production (or another similar name) be more apt?
| self.commodity = self.config.commodity_name | ||
| self.commodity_rate_units = self.config.commodity_units | ||
| self.commodity_amount_units = f"({self.commodity_rate_units})*h" |
There was a problem hiding this comment.
Move this to initialize to match other components?
There was a problem hiding this comment.
the tech config is not accessbile at the initialize(). I get this error:
RuntimeError: StorageAutoSizingModel: Option 'tech_config' is required but has not been set.
| self.commodity = self.config.commodity_name | ||
| self.commodity_rate_units = self.config.commodity_units | ||
| self.commodity_amount_units = f"({self.commodity_rate_units})*h" |
There was a problem hiding this comment.
Move to initialize to match other components?
There was a problem hiding this comment.
the tech config is not accessbile at the initialize(). I get this error:
RuntimeError: StorageAutoSizingModel: Option 'tech_config' is required but has not been set.
| Note: this storage performance model is intended to be used with the | ||
| `DemandOpenLoopStorageController` controller. |
There was a problem hiding this comment.
Can it be used with other controllers? Maybe this (or whatever is appropriate)
| Note: this storage performance model is intended to be used with the | |
| `DemandOpenLoopStorageController` controller. | |
| Note: this storage performance model is intended to be used with the | |
| `DemandOpenLoopStorageController` controller and has not been tested | |
| with other controllers, but may work. |
There was a problem hiding this comment.
I don't think it will work with other controllers at all yet, but I would be fine with something close to @johnjasa 's suggestion:
| Note: this storage performance model is intended to be used with the | |
| `DemandOpenLoopStorageController` controller. | |
| Note: this storage performance model is intended to be used with the | |
| `DemandOpenLoopStorageController` controller and has not been tested | |
| with other controllers. |
| # Step 2: Simulate the storage performance based on the sizes calculated | ||
|
|
||
| # Initialize output arrays of charge and discharge | ||
| discharge_storage = np.zeros(self.n_timesteps) | ||
| charge_storage = np.zeros(self.n_timesteps) | ||
| output_array = np.zeros(self.n_timesteps) | ||
|
|
||
| # Initialize state-of-charge value as the soc at t=0 | ||
| soc = deepcopy(commodity_storage_soc[0]) | ||
|
|
||
| # Simulate a basic storage component | ||
| for t, demand_t in enumerate(commodity_demand): | ||
| input_flow = commodity_production[t] | ||
| available_charge = float(commodity_storage_capacity_kg - soc) | ||
| available_discharge = float(soc) | ||
|
|
||
| # If demand is greater than the input, discharge storage | ||
| if demand_t > input_flow: | ||
| # Discharge storage to meet demand. | ||
| discharge_needed = demand_t - input_flow | ||
| discharge = min(discharge_needed, available_discharge, storage_max_fill_rate) | ||
| # Update SOC | ||
| soc -= discharge | ||
|
|
||
| discharge_storage[t] = discharge | ||
| output_array[t] = input_flow + discharge | ||
|
|
||
| # If input is greater than the demand, charge storage | ||
| else: | ||
| # Charge storage with unused input | ||
| unused_input = input_flow - demand_t | ||
| charge = min(unused_input, available_charge, storage_max_fill_rate) | ||
| # Update SOC | ||
| soc += charge | ||
|
|
||
| charge_storage[t] = charge | ||
| output_array[t] = demand_t |
There was a problem hiding this comment.
The overall reason for this code logic to be necessary here is unclear to me. Is it because this component is only meant to be used with the passthrough controller, so we need to actually calculate these charge and discharge timeseries somewhere because it's not done in the controller, and that's here?
This is the biggest question I have where chatting about it might be useful (I just messaged you).
There was a problem hiding this comment.
If the dispatch logic is needed, perhaps the sizing should be part of the controller instead of the storage model? @jmartin4u
There was a problem hiding this comment.
See issue #498. This PR is not intended to fix the discrepancies between the storage control strategies and the storage performance models, but it sure makes those discrepancies more apparent. I agree that this could be refactored in the future but this PR intended to just make consistent outputs from performance models.
jaredthomas68
left a comment
There was a problem hiding this comment.
Heading in the right direction. We need to correct some math and inputs and also discuss what lives where.
| Note: this storage performance model is intended to be used with the | ||
| `DemandOpenLoopStorageController` controller. |
There was a problem hiding this comment.
I don't think it will work with other controllers at all yet, but I would be fine with something close to @johnjasa 's suggestion:
| Note: this storage performance model is intended to be used with the | |
| `DemandOpenLoopStorageController` controller. | |
| Note: this storage performance model is intended to be used with the | |
| `DemandOpenLoopStorageController` controller and has not been tested | |
| with other controllers. |
| # Estimate the rated commodity production as the maximum value from the commodity_set_point | ||
| outputs[f"rated_{self.commodity}_production"] = inputs[f"{self.commodity}_set_point"].max() |
There was a problem hiding this comment.
I don't think we should determine rated production this way. I think both the storage and the controller need to require the technology rated production like we do with other systems. For storage, the charge and discharge rates and the rated storage capacity should be given at a minimum I think now that we are using the set_point paradigm, even for the generic pass-through component.
| outputs[f"total_{self.commodity}_produced"] = outputs[f"{self.commodity}_out"].sum() | ||
| outputs[f"annual_{self.commodity}_produced"] = outputs[ | ||
| f"total_{self.commodity}_produced" | ||
| ] * (1 / self.fraction_of_year_simulated) |
There was a problem hiding this comment.
I think just dividing will be more clear
| ] * (1 / self.fraction_of_year_simulated) | |
| ]/self.fraction_of_year_simulated |
| # Calculate the total and annual commodity produced | ||
| outputs[f"total_{self.commodity}_produced"] = outputs[f"{self.commodity}_out"].sum() | ||
| outputs[f"annual_{self.commodity}_produced"] = outputs[ | ||
| f"total_{self.commodity}_produced" | ||
| ] * (1 / self.fraction_of_year_simulated) | ||
|
|
||
| # Calculate the maximum commodity production over the simulation | ||
| max_production = ( | ||
| inputs[f"{self.commodity}_set_point"].max() * self.n_timesteps * (self.dt / 3600) | ||
| ) | ||
|
|
||
| outputs["capacity_factor"] = outputs[f"total_{self.commodity}_produced"] / max_production |
There was a problem hiding this comment.
The capacity factor should be based on rated production in the denominator, not max production. Rated production should be a user input to the system (see previous comment).
| max_production = ( | ||
| inputs[f"{self.commodity}_set_point"].max() * self.n_timesteps * (self.dt / 3600) | ||
| ) |
There was a problem hiding this comment.
We should calculate the maximum theoretical production based on name-plate capacity based on the user inputs (see comment above), not the actual in the given scenario as is being done here.
| # Calculate the total and annual commodity produced | ||
| outputs[f"total_{self.commodity}_produced"] = outputs[f"{self.commodity}_out"].sum() | ||
| outputs[f"annual_{self.commodity}_produced"] = outputs[ | ||
| f"total_{self.commodity}_produced" | ||
| ] * (1 / self.fraction_of_year_simulated) | ||
|
|
||
| # Calculate the maximum commodity production over the simulation | ||
| max_production = ( | ||
| inputs[f"{self.commodity}_set_point"].max() * self.n_timesteps * (self.dt / 3600) | ||
| ) | ||
|
|
||
| outputs["capacity_factor"] = outputs[f"total_{self.commodity}_produced"] / max_production |
There was a problem hiding this comment.
Capacity factors for storage systems (at least batteries), as far as I understand, are typically based on the nameplate discharge rate of the system.
cp = total_product_discharged_in_time_period / (name_plate_discharge_rate*time_period_duration) # of course make sure units make senseOf course, the above leads to some fairly low capacity factors, like about 0.167 for a 4 hour battery discharged daily as discussed in the ATB
| # Step 2: Simulate the storage performance based on the sizes calculated | ||
|
|
||
| # Initialize output arrays of charge and discharge | ||
| discharge_storage = np.zeros(self.n_timesteps) | ||
| charge_storage = np.zeros(self.n_timesteps) | ||
| output_array = np.zeros(self.n_timesteps) | ||
|
|
||
| # Initialize state-of-charge value as the soc at t=0 | ||
| soc = deepcopy(commodity_storage_soc[0]) | ||
|
|
||
| # Simulate a basic storage component | ||
| for t, demand_t in enumerate(commodity_demand): | ||
| input_flow = commodity_production[t] | ||
| available_charge = float(commodity_storage_capacity_kg - soc) | ||
| available_discharge = float(soc) | ||
|
|
||
| # If demand is greater than the input, discharge storage | ||
| if demand_t > input_flow: | ||
| # Discharge storage to meet demand. | ||
| discharge_needed = demand_t - input_flow | ||
| discharge = min(discharge_needed, available_discharge, storage_max_fill_rate) | ||
| # Update SOC | ||
| soc -= discharge | ||
|
|
||
| discharge_storage[t] = discharge | ||
| output_array[t] = input_flow + discharge | ||
|
|
||
| # If input is greater than the demand, charge storage | ||
| else: | ||
| # Charge storage with unused input | ||
| unused_input = input_flow - demand_t | ||
| charge = min(unused_input, available_charge, storage_max_fill_rate) | ||
| # Update SOC | ||
| soc += charge | ||
|
|
||
| charge_storage[t] = charge | ||
| output_array[t] = demand_t |
There was a problem hiding this comment.
If the dispatch logic is needed, perhaps the sizing should be part of the controller instead of the storage model? @jmartin4u
| model.prob.get_val("finance_subgroup_nh3.total_opex_adjusted")[0], rel=1e-6 | ||
| ) | ||
| == 79744581.00552343 | ||
| == 79421959.33317558 |
There was a problem hiding this comment.
I don't think the test values should change for these code changes. Maybe you can convince me though. As I see it all the changes should be more a matter of where calculations are done and what components connect to what rather than changing any actual calculations.
…/GreenHEART into converter_baseclass_storage
Standardized Performance Outputs: Generic Storage & Control
This PR is a follow-on to PR #463 and should not be merged in until after #463 is.
This PR updates the generic storage performance models and the control strategies so storage models have the same outputs as converters. There are 3 storage performance models:
h2integrate/storage/battery/pysam_battery.py: This was updated in PR Standardize performance model outputs #463h2integrate/storage/simple_generic_storage.py: basically did nothingh2integrate/storage/simple_storage_auto_sizing.py: calculates storage capacity and charge rate needed to meet a demand.To be consistent with the
pysam_batterymodel, the generic storage performance models needed to output the standardized outputs (like capacity factor, rated production, etc) but these generic performance models did very minimal calculations and did not outputcommodity_out, most calculations were done in the controllers, not the performance models.Basically, the control strategies have been updated to output
commodity_set_pointinstead ofcommodity_out.commodity_set_pointis what the controller tells the performance model is the "target"commodity_out. Since the performance model may not be able to achieve thecommodity_set_point(due to a variety of possible differences in the control model and the performance model, like losses), the performance model outputcommodity_outmay not equal thecommodity_set_point. Thecommodity_set_pointis the input to the storage performance models, which now outputcommodity_out,annual_commodity_production,capacity_factor, etc. The computations now done in some of the storage performance models may be somewhat duplicative of the performance of some controllers, but this ensures that the storage controllers are compatible with any storage performance model.Section 1: Type of Contribution
Section 2: Draft PR Checklist
TODO:
These TODOs are only if PR #407 gets merged in before this one is ready. Otherwise, a separate PR will include these TODOs after #407 is merged.
commodity_nametocommodityfor the generic storage performance models and control strategiescommodity_unitstocommodity_rate_unitsfor the generic storage performance models and control strategiesType of Reviewer Feedback Requested (on Draft PR)
Structural feedback:
Implementation feedback:
commodity_set_point?Other feedback:
Section 3: General PR Checklist
docs/files are up-to-date, or added when necessaryCHANGELOG.mdhas been updated to describe the changes made in this PRSection 3: Related Issues
This partially resolves some of Issue #486, and would resolve a step in Issue #485
New issue made: #498
Section 4: Impacted Areas of the Software
Section 4.1: New Files
N/A
Section 4.2: Modified Files
h2integrate/storage/simple_generic_storage.py: addedcommodity_set_pointas an input, updatedcompute()method to better reflect performance of a "pass through" type storage component (this storage model has no inputs related to capacity, which is why thecompute()method basically now acts like a pass-through component)h2integrate/storage/simple_storage_auto_sizing.py: addedcommodity_set_pointas an input, updatedcompute()method to better reflect simple performance of a storage componenth2integrate/control/control_strategies/demand_openloop_controller.py: changed output variable namecommodity_outtocommodity_set_pointh2integrate/control/control_strategies/passthrough_openloop_controller.py: changed output variable namecommodity_outtocommodity_set_pointh2integrate/postprocess/test/test_sql_timeseries_to_csv.py: updated subtest value, see justification in Section 6pytest examples/test/test_all_examples.py::test_ammonia_synloop_example: added subtest for annual ammonia production and updated 3 subtest values that changed, see justification in Section 6.Section 5: Additional Supporting Information
Section 6: Test Results, if applicable
h2integrate/postprocess/test/test_sql_timeseries_to_csv.py::test_save_csv_all_results: subtest for number of columns increased from 35 to 36 because of the new outputcommodity_set_point.pytest examples/test/test_all_examples.py::test_ammonia_synloop_example: subtests for ammonia OpEx, total adjusted OpEx for ammonia finance subgroup, and the LCOA failed, the test values decreased.cat_opex + h2o_opex - o2_opex, all of these are multipliers of the annual ammonia produced. All these opex values increased, but theo2_opexincreased much more than the increase incat_opexandh2o_opexcombined, meaning a net reduction in OpEx.max_hydrogen_capacityof theammonia_synloopmodel is 10589.36 kg/h, this value has not changed.hydrogen_into theammonia_synloopmodel decreased from 12549 kg/h to 9306 kg/h. This indicates that now more hydrogen is being used to make ammonia rather than being "curtailed". The total hydrogen consumed is the same as before (meaning the totalhydrogen_inis the same), but thehydrogen_outof the ammonia model decreased (likely because more hydrogen is being used)"generic_storage_model"as the performance model since this acts like the pass-through controller. This would make the hydrogen into the ammonia model the same as before (equal to the hydrogen out of the electrolyzer model). But - we would have to set the storage capacity and charge rate so that the hydrogen storage cost is the same as before.Section 7 (Optional): New Model Checklist
docs/developer_guide/coding_guidelines.mdattrsclass to define theConfigto load in attributes for the modelBaseConfigorCostModelBaseConfiginitialize()method,setup()method,compute()methodCostModelBaseClasssupported_models.pycreate_financial_modelinh2integrate_model.pytest_all_examples.pydocs/user_guide/model_overview.mddocs/section<model_name>.mdis added to the_toc.yml