From daf4cf5ee60bbdf1f75f7c305bdcc6e5e7fc3e80 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:42:21 -0600 Subject: [PATCH 01/69] draft of integrating helper methods into SLC baseclass --- .../system_level/demand_following_control.py | 129 +++++++++++++-- .../system_level/system_level_control_base.py | 154 +++++++++++++++++- 2 files changed, 263 insertions(+), 20 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index bb83712d0..0b2e6f056 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -1,10 +1,18 @@ import numpy as np +import networkx as nx +from attrs import field, define +from h2integrate.core.utilities import BaseConfig from h2integrate.control.control_strategies.system_level.system_level_control_base import ( SystemLevelControlBase, ) +@define(kw_only=True) +class DemandFollowingControlConfig(BaseConfig): + use_average_conversion_factor: bool = field(default=False) + + class DemandFollowingControl(SystemLevelControlBase): """Demand-following system-level controller. @@ -31,24 +39,40 @@ class DemandFollowingControl(SystemLevelControlBase): (each receives ``remaining_demand / n_dispatchable``). """ - def compute(self, inputs, outputs): - commodity = self.commodity - demand = inputs[self.demand_input_name].copy() + def setup(self): + super().setup() + + self.config = DemandFollowingControlConfig.from_dict( + self.options["plant_config"]["system_level_control"].get("control_parameters", {}) + ) + + def get_setpoints_for_commodity_subset( + self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None + ): + if tech_subset is None: + tech_subset = set(self.input_techs) + + fixed_tech_subset = set(self.fixed_techs) & set(tech_subset) + flexible_tech_subest = set(self.flexible_techs) & set(tech_subset) + storage_tech_subset = set(self.storage_techs) & set(tech_subset) + dispatchable_tech_subset = set(self.dispatchable_techs) & set(tech_subset) # 1. Fixed techs: always produce, subtract from demand - for fixed_tech in self.fixed_techs: + for fixed_tech in fixed_tech_subset: commodity_from_tech = self._get_commodity_for_tech(fixed_tech) for tech_commodity in commodity_from_tech: if tech_commodity == commodity: - demand = self._subtract_fixed(fixed_tech, demand, commodity, inputs) + commodity_demand = self._subtract_fixed( + fixed_tech, commodity_demand, commodity, inputs + ) # 2. Flexible techs: operate at full production - for flexible_tech in self.flexible_techs: + for flexible_tech in flexible_tech_subest: commodity_from_tech = self._get_commodity_for_tech(flexible_tech) for tech_commodity in commodity_from_tech: if tech_commodity == commodity: - demand = self._subtract_flexible( - flexible_tech, demand, commodity, inputs, outputs + commodity_demand = self._subtract_flexible( + flexible_tech, commodity_demand, commodity, inputs, outputs ) else: if f"{flexible_tech}_rated_{tech_commodity}_production" in inputs: @@ -60,26 +84,101 @@ def compute(self, inputs, outputs): # 3. Storage dispatch # number of storage components that produce the demanded commodity n_storage = len( - [s for s in self.storage_techs if commodity in self._get_commodity_for_tech(s)] + [s for s in storage_tech_subset if commodity in self._get_commodity_for_tech(s)] ) - for storage_tech in self.storage_techs: + for storage_tech in storage_tech_subset: commodity_from_tech = self._get_commodity_for_tech(storage_tech) if commodity in commodity_from_tech: - demand = self._dispatch_storage( - storage_tech, demand / n_storage, commodity, inputs, outputs + commodity_demand = self._dispatch_storage( + storage_tech, commodity_demand / n_storage, commodity, inputs, outputs ) # 4. Dispatchable techs - remaining_demand = np.maximum(demand, 0.0) + remaining_demand = np.maximum(commodity_demand, 0.0) # calculate the number of dispatchable technologies that # produce the demanded commodity n_dispatchable = len( - [s for s in self.dispatchable_techs if commodity in self._get_commodity_for_tech(s)] + [s for s in dispatchable_tech_subset if commodity in self._get_commodity_for_tech(s)] ) - for dispatchable_tech in self.dispatchable_techs: + for dispatchable_tech in dispatchable_tech_subset: commodity_from_tech = self._get_commodity_for_tech(dispatchable_tech) if commodity in commodity_from_tech: outputs[f"{dispatchable_tech}_{commodity}_set_point"] = ( remaining_demand / n_dispatchable ) + + def compute(self, inputs, outputs): + if not self.multi_commodity_system: + self.get_setpoints_for_commodity_subset( + inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() + ) + return + + # should probably also get a list of generators, feedstocks, and storage + # should also get an idea of what components are in each "step" of the conversion + converter_techs, converter_order, converter_ancestors = self.find_converter_techs( + include_feedstock_sources=True + ) + converter_upstreams = self._get_converter_input_techs(converter_order, converter_ancestors) + converter_tech_names = {v[1] for k, v in converter_order.items()} + converter_cnt = list(converter_order.keys()) + converter_cnt.sort() + conversion_factors = {} + + demand_converter = None + demand_commodity = self.demand_input_name.replace("_demand", "") + + # demand_converter = None + for converter_ii in converter_cnt: + input_cmod, tech, output_cmod = converter_order[converter_ii] + tech_ancestors = ( + set(converter_ancestors[converter_ii]) & converter_upstreams[(input_cmod, tech)] + ) + conversion_ratio = self.get_converter_conversion_ratio( + inputs, + input_cmod, + output_cmod, + tech, + list(tech_ancestors), + return_avg=self.config.use_average_conversion_factor, + ) + conversion_factors[converter_ii] = conversion_ratio + # check if the tech has an edge with the demand component + if output_cmod == demand_commodity: + demand_converter = str(tech) + # if self.technology_graph.has_edge(tech,self.demand_tech): + # demand_converter = tech + if demand_converter is None: + raise ValueError(f"no converters produce the demanded commodity {demand_commodity}") + + if not self.technology_graph.has_edge(demand_converter, self.demand_tech): + raise ValueError("logic is wrong") + # node_order = list(self.technology_graph.nodes()) + # nodes_after_last_converter = node_order[node_order.index(tech)+1:] + inputs[self.demand_input_name].copy() + + list(self.technology_graph.predecessors(self.demand_tech)) + list(nx.all_simple_paths(self.technology_graph, demand_converter, self.demand_tech)) + + # if demand_converter is None: + # # If a converter isnt directly connected to the demand tech, assume its the last one + # # TODO: update so that it finds the converter that IS connected to the demand tech + # demand_converter = tech + # work backward from commodity demand + converter_cnt.reverse() + for converter_ii in converter_cnt: + input_cmod, tech, output_cmod = converter_order[converter_ii] + # conversion is input_cmod/output_cmod + tech_ancestors = converter_ancestors[converter_ii] + conversion_factors[converter_ii] + upstream_techs = converter_upstreams[(input_cmod, tech)] + upstream_converters = upstream_techs & set(converter_tech_names) + if len(upstream_converters) == 0: + # no converters are upstream + pass + else: + # there are converters upstream + pass + + {k[0] for k in converter_techs if k[0] != self.commodity} diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 301cffe54..5b2ef60cf 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,3 +1,5 @@ +import itertools + import numpy as np import networkx as nx import openmdao.api as om @@ -754,7 +756,7 @@ def get_upstream_techs_for_commodity( if include_feedstock_sources: input_techs = self.input_techs | set(self.feedstock_comps) else: - input_techs = self.input_techs.copy() + input_techs = set(self.input_techs) # All graph ancestors of tech_name (any depth) ancestors = nx.ancestors(self.technology_graph, tech_name) @@ -781,9 +783,20 @@ def find_converter_techs(self, include_feedstock_sources=True): in the set of candidate technologies. Defaults to True. Returns: - set[tuple[str, str, str]]: Set of ``(input_commodity, tech_name, output_commodity)`` - tuples for each detected conversion. Returns ``None`` for single-commodity systems. + 3-element tuple containing: + + - **converter_techs** (set[tuple[str, str, str]]): Set of + ``(input_commodity, tech_name, output_commodity)`` tuples for each + detected conversion. Returns ``None`` for single-commodity systems. + - **converter_order** (dict): Dictionary defining the directional order of converters. + Keys are an integer indicating order (lower numbers indicate a more upstream + converter). The values are the same as the elements of ``converter_techs`` + - **converter_ancestors** (dict): Dictionary defining the technologies that + are upstream and connected to the converter. Same keys as ``converter_order``. + Dictionary values are the upstream technologies of the converter. """ + # TODO: add an input thats `include_demand_component` + if include_feedstock_sources: input_techs = self.input_techs | set(self.feedstock_comps) else: @@ -794,9 +807,11 @@ def find_converter_techs(self, include_feedstock_sources=True): return converter_techs = set() + converter_order = {} + converter_ancestors = {} node_order = list(self.technology_graph.nodes()) edges = list(self.technology_graph.edges(data="commodity")) - + ii = 0 # Track the most recently discovered converter so we can scope # upstream searches for chained converters (A→B→C where B and C # both convert). Without this, C would see A's commodity as upstream @@ -843,6 +858,135 @@ def find_converter_techs(self, include_feedstock_sources=True): for in_comm in consumed: for out_comm in produced: converter_techs.add((in_comm, source_tech, out_comm)) + converter_order[ii] = (in_comm, source_tech, out_comm) + converter_ancestors[ii] = [ + e[0] + for e in self.techs_to_commodities + if e[1] == in_comm and e[0] in connected_ancestors + ] + ii += 1 last_converter = source_tech - return converter_techs + if len(converter_techs) < len(converter_order): + # remove duplicate converter orders + rev_converter_order = {v: k for k, v in converter_order.items()} + # re-reverse it + converter_order = {v: k for k, v in rev_converter_order.items()} + # remove duplicate converter orders + rev_converter_ancestors = {v: k for k, v in converter_ancestors.items()} + # re-reverse it + converter_ancestors = {v: k for k, v in rev_converter_ancestors.items()} + return converter_techs, converter_order, converter_ancestors + + def _get_converter_input_techs(self, converter_order, converter_ancestors): + """_summary_ + + Args: + converter_order (dict[int, set[tuple[str, str, str]]]): _description_ + converter_ancestors (dict[int, list[str]]): _description_ + + Returns: + dict[tuple[str,str], set[str]]: Keys are set of + ``(input_commodity, tech_name)`` and the values are a set of + upstream technologies that output the `input_commodity` to `tech_name`. + + """ + # Make sure we iterate through the converters in the right order + converter_cnt = list(converter_order.keys()) + converter_cnt.sort() + previous_converters = set() # track previous converters + upstreams = {} + # NOTE: unsure how the below logic will work with splitters + for converter_ii in converter_cnt: + input_cmod, tech, output_cmod = converter_order[converter_ii] + # Get all the upstream technologies that produce a specific commodity + upstream1 = self.get_upstream_techs_for_commodity( + tech, input_cmod, include_feedstock_sources=True + ) + # Combined the upstream techs with all the previous converters + upstream_converter = set(upstream1) & previous_converters + # Remove any of the previous converters that arent connected to this converter + upstreams[(input_cmod, tech)] = set(upstream1) & ( + upstream_converter | set(converter_ancestors[converter_ii]) + ) + previous_converters.add(tech) + return upstreams + + def get_converter_conversion_ratio( + self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors, return_avg=True + ): + """Get conversion ratio of ``in_cmod/out_cmod`` for technology ``converter_tech`` + + Args: + inputs (dict): OpenMDAO inputs + in_cmod (str): commodity input to the ``converter_tech`` + out_cmod (str): commodity output from the ``converter_tech`` + converter_tech (str): name of the converter technologies + tech_ancestors (list[str] | set[str] | tuple[str]): upstream technologies + that produce ``in_cmod`` to the ``converter_tech`` + return_avg (bool): if True, return the average conversion ratio over the timesries. + Otherwise, return the mean. Defaults to True. + + Returns: + float | np.ndarray: conversion ratio of `in_cmod/out_cmod`. If return_avg is True, + then returns a scalar, otherwise returns an array + """ + input_name_fmt = "{tech}_{commod}_out" + in_names = [input_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] + total_in_cmod = [inputs[n] for n in in_names if n in inputs] + total_input = np.array(total_in_cmod).sum(axis=0) + total_output = inputs[input_name_fmt.format(tech=converter_tech, commod=out_cmod)] + # Check if the converter produced any `out_cmod` + if total_output.sum() > 0: + conversion_factor = np.nan_to_num(total_input / total_output) + return conversion_factor.mean() if return_avg else conversion_factor + return total_input.mean() if return_avg else total_input + + def get_multi_converter_conversion_ratio( + self, + up_converter, + up_converter_incmod, + down_converter, + down_converter_outcmod, + converter_techs, + converter_order, + converter_ancestors, + ): + """_summary_ + + Args: + up_converter (_type_): _description_ + up_converter_incmod (_type_): _description_ + down_converter (_type_): _description_ + down_converter_outcmod (_type_): _description_ + converter_order (_type_): _description_ + converter_ancestors (_type_): _description_ + """ + # check that theres a path + paths = list(nx.all_simple_paths(self.technology_graph, up_converter, down_converter)) + converter_tech_names = {v[1] for k, v in converter_order.items()} + + intermediate_converters = set() + # intermediate converters is a list of (converter_tech, output_commod) + for path in paths: + tech0 = path[0] # same as up_converter + for tech0, tech1 in itertools.pairwise(path): + if ( + commod := self.technology_graph.edges[tech0, tech1].get("commodity", None) + ) is not None: + # intermediate_commodities.add(commod) + tech0_is_intermediate = tech0 in converter_tech_names and tech0 != up_converter + tech1_is_intermediate = ( + tech1 in converter_tech_names and tech1 != down_converter + ) + if tech0_is_intermediate or tech1_is_intermediate: + # intermediate_converters.add(tech0) + intermediate_converters.add(tuple(tech0, commod)) + + # Determine which commodities are connected from ``up_converter`` to ``down_converter`` + converter_upstreams = self._get_converter_input_techs(converter_order, converter_ancestors) + converter_upstreams[tuple(up_converter_incmod, up_converter)] + + # loop through all the input commodities for the down converter + [k[0] for k, v in converter_upstreams.items() if k[1] == down_converter] + converter_upstreams[tuple(down_converter_outcmod, up_converter)] From 5c27e98683112fd722ca574ac2e140d0932ba8a9 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:43:32 -0600 Subject: [PATCH 02/69] added example for complex multi-commodity --- .../driver_config.yaml | 4 + .../complex_multi_commodity/plant_config.yaml | 103 +++++++ .../run_complex_multicommod.py | 21 ++ .../complex_multi_commodity/tech_config.yaml | 272 ++++++++++++++++++ .../top_level_config.yaml | 4 + 5 files changed, 404 insertions(+) create mode 100644 examples/35_system_level_control/complex_multi_commodity/driver_config.yaml create mode 100644 examples/35_system_level_control/complex_multi_commodity/plant_config.yaml create mode 100644 examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py create mode 100644 examples/35_system_level_control/complex_multi_commodity/tech_config.yaml create mode 100644 examples/35_system_level_control/complex_multi_commodity/top_level_config.yaml diff --git a/examples/35_system_level_control/complex_multi_commodity/driver_config.yaml b/examples/35_system_level_control/complex_multi_commodity/driver_config.yaml new file mode 100644 index 000000000..e6b823fec --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a hybrid plant to match the first example in H2Integrate +general: + folder_output: outputs diff --git a/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml b/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml new file mode 100644 index 000000000..8c55e4472 --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml @@ -0,0 +1,103 @@ +name: plant_config +description: This plant is located in MN, USA... +sites: + site: + latitude: 32.31714 + longitude: -100.18 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 + solar_resource: + resource_model: GOESAggregatedSolarAPI + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + - [wind, combiner, electricity, cable] + - [solar, combiner, electricity, cable] + - [combiner, battery, electricity, cable] + # combine the battery output with the wind and solar generation + - [battery, elec_combiner, electricity, cable] + - [combiner, elec_combiner, electricity, cable] + # connect the electricity supply to the electrolyzer + - [elec_combiner, electrolyzer, electricity, cable] + - [electrolyzer, h2_storage, hydrogen, pipe] + # combine the h2 from the electrolyzer and the h2_storage + - [electrolyzer, h2_combiner, hydrogen, pipe] + - [h2_storage, h2_combiner, hydrogen, pipe] + # subtract the hydrogen supplied from the hydrogen demand + # - [h2_combiner, h2_load_demand, hydrogen, pipe] + # connect the hydrogen supplied as the demand to the ammonia model + - [h2_combiner, ammonia, hydrogen, pipe] + - [n2_feedstock, ammonia, nitrogen, pipe] + - [electricity_feedstock, ammonia, electricity, cable] + - [ammonia, nh3_load_demand, ammonia, pipe] + # etc +tech_to_dispatch_connections: + - [combiner, battery] + - [battery, battery] +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] + - [site.solar_resource, solar, solar_resource_data] +system_level_control: + control_strategy: DemandFollowingControl + demand_component: nh3_load_demand + control_parameters: + use_average_conversion_factor: true + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +plant: + plant_life: 30 +finance_parameters: + finance_groups: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for real analysis + discount_rate: 0.06 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 0.724 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.025 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.2574 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.0 # average combined state and local sales tax https://taxfoundation.org/location/texas/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 7 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 + finance_subgroups: + h2: + commodity: hydrogen + commodity_stream: electrolyzer + technologies: [wind, solar, battery, electrolyzer, h2_storage] + nh3: + commodity: ammonia + commodity_stream: ammonia + technologies: + - wind + - solar + - battery + - electrolyzer + - h2_storage + - ammonia + n2: + commodity: nitrogen + commodity_stream: n2_feedstock + technologies: [n2_feedstock] diff --git a/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py new file mode 100644 index 000000000..fd3f337dc --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py @@ -0,0 +1,21 @@ +import os + +from h2integrate import EXAMPLE_DIR +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +os.chdir(EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity") + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("top_level_config.yaml") + +h2i.setup() + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() + +# TODO: make even more complex by adding in an ammonia storage and combiner that goes to the demand tech diff --git a/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml new file mode 100644 index 000000000..4a2ffb3a4 --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml @@ -0,0 +1,272 @@ +name: technology_config +description: This hybrid plant produces ammonia +technologies: + wind: + performance_model: + model: FlorisWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 148 # number of turbines in the farm + hub_height: 115.0 # turbine hub-height + operational_losses: 10.49 # percentage of non-wake losses + floris_wake_config: !include "floris_v4_default_template.yaml" #floris wake model file + floris_turbine_config: !include "floris_turbine_NREL_6MW_170.yaml" #turbine model file formatted for floris + resource_data_averaging_method: average #"weighted_average", "average" or "nearest" + operation_model: cosine-loss # turbine operation model + default_turbulence_intensity: 0.06 + enable_caching: true # whether to use cached results + cache_dir: cache # directory to save or load cached data + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 7.0 + turbine_D_spacing: 7.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1380.0 + opex_per_kW_per_year: 29.0 + cost_year: 2019 + solar: + performance_model: + model: PYSAMSolarPlantPerformanceModel + cost_model: + model: ATBResComPVCostModel + model_inputs: + shared_parameters: + pv_capacity_kWdc: 400000 # 400 MWdc + performance_parameters: + dc_ac_ratio: 1.3 + create_model_from: default + config_name: PVWattsSingleOwner + tilt_angle_func: lat-func + pysam_options: + SystemDesign: + inv_eff: 96.0 + module_type: 0 # 19% efficiency + losses: 14.08 # dc losses + Lifetime: + dc_degradation: [0] + cost_parameters: + capex_per_kWdc: 1323 + opex_per_kWdc_per_year: 18 + cost_year: 2019 + combiner: + performance_model: + model: GenericCombinerPerformanceModel + dispatch_rule_set: + model: PyomoDispatchGenericConverter + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + battery: + dispatch_rule_set: + model: PyomoRuleStorageBaseclass + control_strategy: + model: HeuristicLoadFollowingStorageController + performance_model: + model: PySAMBatteryPerformanceModel + cost_model: + model: ATBBatteryCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 96.0 # kW + max_capacity: 96.0 # kWh + init_soc_fraction: 0.9 + max_soc_fraction: 1.0 + min_soc_fraction: 0.2 + performance_parameters: + chemistry: LFPGraphite + demand_profile: 640000 # 640 MW + cost_parameters: + cost_year: 2019 + energy_capex: 310 # $/kWh from 2024 ATB year 2025 + power_capex: 311 # $/kW from 2024 ATB year 2025 + opex_fraction: 0.025 + control_parameters: + n_control_window_hours: 24 + system_commodity_interface_limit: 1e12 + elec_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + electrolyzer: + performance_model: + model: ECOElectrolyzerPerformanceModel + cost_model: + model: SingliticoCostModel + model_inputs: + shared_parameters: + location: onshore + electrolyzer_capex: 1295 # $/kW overnight installed capital costs for a 1 MW system in 2022 USD/kW (DOE hydrogen program record 24005 Clean Hydrogen Production Cost Scenarios with PEM Electrolyzer Technology 05/20/24) (https://www.hydrogen.energy.gov/docs/hydrogenprogramlibraries/pdfs/24005-clean-hydrogen-production-cost-pem-electrolyzer.pdf?sfvrsn=8cb10889_1) + performance_parameters: + size_mode: normal + n_clusters: 16 + cluster_rating_MW: 40 + eol_eff_percent_loss: 10 # eol defined as x% change in efficiency from bol + uptime_hours_until_eol: 80000 # number of 'on' hours until electrolyzer reaches eol + include_degradation_penalty: true # include degradation + turndown_ratio: 0.1 # turndown_ratio = minimum_cluster_power/cluster_rating_MW + financial_parameters: + capital_items: + depr_period: 7 # based on PEM Electrolysis H2A Production Case Study Documentation estimate of 7 years. also see https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + replacement_cost_percent: 0.15 # percent of capex - H2A default case + h2_storage: + performance_model: + model: StoragePerformanceModel + control_strategy: + model: DemandOpenLoopStorageController + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + commodity_amount_units: kg + demand_profile: 9306.754158 # 50 kg/h + # performance_parameters: + min_soc_fraction: 0.0 + max_soc_fraction: 1.0 + charge_efficiency: 1.0 + discharge_efficiency: 1.0 + max_capacity: 1500.0 + init_soc_fraction: 0.0 + max_charge_rate: 500.0 + cost_parameters: + capacity_capex: 200.0 + charge_capex: 240.0 + opex_fraction: 0.05 + cost_year: 2020 + # # since the storage is being auto-sized by the performance model, + # # we set the sizing mode to 'auto' rather than defining the capacities + # # in the input file + # sizing_mode: auto # set as "auto" so storage capacity doesnt have to be defined + h2_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + h2_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + demand_profile: 9306.754158 # 50 kg/h + n2_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: nitrogen + commodity_rate_units: t/h + performance_parameters: + rated_capacity: 50.0 # metric tonnes of N2/hour + cost_parameters: + cost_year: 2022 + price: 5.0 + annual_cost: 0. + start_up_cost: 0.0 + electricity_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: MW + performance_parameters: + rated_capacity: 29.0 # MW of electricity + cost_parameters: + cost_year: 2022 + price: 0.0 + annual_cost: 0. + start_up_cost: 0.0 + ammonia: + performance_model: + model: AmmoniaSynLoopPerformanceModel + cost_model: + model: AmmoniaSynLoopCostModel + model_inputs: # See converters/ammonia/Ammonia cost breakdown-ANL source.xlsx + shared_parameters: + production_capacity: 52777.6 + catalyst_consumption_rate: 0.000091295354067341 + catalyst_replacement_interval: 3 + performance_parameters: + size_mode: normal + capacity_factor: 0.9 + energy_demand: 0.530645243 + heat_output: 0.8299956 + feed_gas_t: 25.8 + feed_gas_p: 20 + feed_gas_x_n2: 0.25 + feed_gas_x_h2: 0.75 + feed_gas_mass_ratio: 1.13 + purge_gas_t: 7.5 + purge_gas_p: 275 + purge_gas_x_n2: 0.26 + purge_gas_x_h2: 0.68 + purge_gas_x_ar: 0.02 + purge_gas_x_nh3: 0.04 + purge_gas_mass_ratio: 0.07 + # --- Dynamic operating constraints (optional) --- + # Turndown ratio: minimum production as a fraction of rated capacity. + turndown_ratio: 0.2 + # Per-hour ramp limits as a fraction of rated capacity. + ramp_up_rate_fraction: 0.5 + ramp_down_rate_fraction: 0.5 + # Cold start: triggered after a long off-period; introduces a multi-hour delay. + include_cold_start: true + off_hours_cold_start: 6 + cold_start_delay_hours: 4 + # Warm start: triggered after any short off-period; introduces a sub-hour delay. + include_warm_start: true + off_hours_warm_start: 0.5 + warm_start_delay_hours: 0.5 + cost_parameters: + baseline_capacity: 52777.6 + base_cost_year: 2016 + capex_scaling_exponent: 0.6 + labor_scaling_exponent: 0.25 + asu_capex_base: 236920646 # See ASU + HB capex-NETL source.xlsx + synloop_capex_base: 302460908 # See ASU + HB capex-NETL source.xlsx + heat_capex_base: 7069100 + cool_capex_base: 4799200 + other_eqpt_capex_base: 0 + land_capex_base: 4112701.84103543 + deprec_noneq_capex_rate: 0.42 + labor_rate_base: 57 + num_workers_base: 50 + hours_yr: 2080 + gen_admin: 0.2 + prop_tax_ins: 0.02 + maint_rep: 0.005 + oxygen_byproduct_rate: 0.29405077250145 + water_consumption_rate: 0.049236824 + rebuild_cost_base: 0 + cooling_water_cost_base: 0.000113349938601175 + catalyst_cost_base: 23.19977341 + oxygen_price_base: 0.0285210891617726 + nh3_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: ammonia + commodity_rate_units: kg/h + demand_profile: 47499.84 # kg/h diff --git a/examples/35_system_level_control/complex_multi_commodity/top_level_config.yaml b/examples/35_system_level_control/complex_multi_commodity/top_level_config.yaml new file mode 100644 index 000000000..e09f3dcda --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/top_level_config.yaml @@ -0,0 +1,4 @@ +name: H2Integrate_config +driver_config: driver_config.yaml +plant_config: plant_config.yaml +technology_config: tech_config.yaml From b4d729b44a9c60b1f93e58832ebbc4f0f9876735 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:03:34 -0600 Subject: [PATCH 03/69] merged _find_converter_techs and _get_converter_input_techs into one method --- .../system_level/demand_following_control.py | 13 ++-- .../system_level/system_level_control_base.py | 59 ++++++++++++++----- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 0b2e6f056..bb14cccfa 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -117,10 +117,11 @@ def compute(self, inputs, outputs): # should probably also get a list of generators, feedstocks, and storage # should also get an idea of what components are in each "step" of the conversion - converter_techs, converter_order, converter_ancestors = self.find_converter_techs( + + converter_order, converter_upstreams = self.find_converter_techs( include_feedstock_sources=True ) - converter_upstreams = self._get_converter_input_techs(converter_order, converter_ancestors) + converter_tech_names = {v[1] for k, v in converter_order.items()} converter_cnt = list(converter_order.keys()) converter_cnt.sort() @@ -132,9 +133,7 @@ def compute(self, inputs, outputs): # demand_converter = None for converter_ii in converter_cnt: input_cmod, tech, output_cmod = converter_order[converter_ii] - tech_ancestors = ( - set(converter_ancestors[converter_ii]) & converter_upstreams[(input_cmod, tech)] - ) + tech_ancestors = converter_upstreams[(input_cmod, tech)] conversion_ratio = self.get_converter_conversion_ratio( inputs, input_cmod, @@ -170,7 +169,7 @@ def compute(self, inputs, outputs): for converter_ii in converter_cnt: input_cmod, tech, output_cmod = converter_order[converter_ii] # conversion is input_cmod/output_cmod - tech_ancestors = converter_ancestors[converter_ii] + tech_ancestors = converter_upstreams[(input_cmod, tech)] conversion_factors[converter_ii] upstream_techs = converter_upstreams[(input_cmod, tech)] upstream_converters = upstream_techs & set(converter_tech_names) @@ -181,4 +180,4 @@ def compute(self, inputs, outputs): # there are converters upstream pass - {k[0] for k in converter_techs if k[0] != self.commodity} + {k[0] for i, k in converter_order.items() if k[0] != self.commodity} diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 5b2ef60cf..b1eb3f075 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -783,17 +783,15 @@ def find_converter_techs(self, include_feedstock_sources=True): in the set of candidate technologies. Defaults to True. Returns: - 3-element tuple containing: - - - **converter_techs** (set[tuple[str, str, str]]): Set of - ``(input_commodity, tech_name, output_commodity)`` tuples for each - detected conversion. Returns ``None`` for single-commodity systems. - - **converter_order** (dict): Dictionary defining the directional order of converters. - Keys are an integer indicating order (lower numbers indicate a more upstream - converter). The values are the same as the elements of ``converter_techs`` - - **converter_ancestors** (dict): Dictionary defining the technologies that - are upstream and connected to the converter. Same keys as ``converter_order``. - Dictionary values are the upstream technologies of the converter. + 2-element tuple containing: + + - **converter_order** (dict[int, tuple[str, str, str]]): Dictionary + defining the directional order of converters. Keys are an integer indicating + order (lower numbers indicate a more upstream converter). The values are + ``(input_commodity, tech_name, output_commodity)`` tuples. + - **upstreams** (dict[tuple[str,str], set[str]]): Keys are set of + ``(input_commodity, tech_name)`` and the values are a set of + upstream technologies that output the `input_commodity` to `tech_name`. """ # TODO: add an input thats `include_demand_component` @@ -806,8 +804,16 @@ def find_converter_techs(self, include_feedstock_sources=True): if not self.multi_commodity_system: return + # ``converter_techs`` is a set of ``(input_commodity, tech_name, output_commodity)`` + # tuples for each detected conversion converter_techs = set() + # ``converter_order`` is a dictionary defining the directional order of the converters + # the keys are integers, lower numbers mean it comes first. + # Values are the format of entries in ``converter_techs`` converter_order = {} + # ``converter_ancestors`` is a dictionary with the same keys as ``converter_order`` + # and the values as upstream technologies that produce ``input_commodity`` and are + # connected to the converter tech (does not include converters in upstream technologies) converter_ancestors = {} node_order = list(self.technology_graph.nodes()) edges = list(self.technology_graph.edges(data="commodity")) @@ -876,10 +882,35 @@ def find_converter_techs(self, include_feedstock_sources=True): rev_converter_ancestors = {v: k for k, v in converter_ancestors.items()} # re-reverse it converter_ancestors = {v: k for k, v in rev_converter_ancestors.items()} - return converter_techs, converter_order, converter_ancestors + + # Make sure we iterate through the converters in the right order + converter_cnt = list(converter_order.keys()) + converter_cnt.sort() + previous_converters = set() # track previous converters + # ``upstreams`` is similar to ``converter_ancestors`` but has keys as a tuple formatted as + # ``(input_commodity, tech_name)``. The values are a set of the technologies upstream of + # ``tech_name`` that output ``input_commodity`` that is input to ``tech_name``. + # Key difference from ``converter_ancestors`` is that this includes upstream converter techs + upstreams = {} # upstreams is similar to + # NOTE: unsure how the below logic will work with splitters + for converter_ii in converter_cnt: + input_cmod, tech, output_cmod = converter_order[converter_ii] + # Get all the upstream technologies that produce a specific commodity + upstream1 = self.get_upstream_techs_for_commodity( + tech, input_cmod, include_feedstock_sources=True + ) + # Combined the upstream techs with all the previous converters + upstream_converter = set(upstream1) & previous_converters + # Remove any of the previous converters that arent connected to this converter + upstreams[(input_cmod, tech)] = set(upstream1) & ( + upstream_converter | set(converter_ancestors[converter_ii]) + ) + previous_converters.add(tech) + # return converter_techs, converter_order, converter_ancestors, upstreams + return converter_order, upstreams def _get_converter_input_techs(self, converter_order, converter_ancestors): - """_summary_ + """TODO: REMOVE THIS METHOD (lumped it into `find_converter_techs`) Args: converter_order (dict[int, set[tuple[str, str, str]]]): _description_ @@ -952,7 +983,7 @@ def get_multi_converter_conversion_ratio( converter_order, converter_ancestors, ): - """_summary_ + """NOT DONE Args: up_converter (_type_): _description_ From a1824305a6b09d674856f3f6aa130362a03a1a68 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:38:15 -0600 Subject: [PATCH 04/69] wip updated logic in demand following control --- .../system_level/demand_following_control.py | 93 ++++++++++++++++--- .../system_level/system_level_control_base.py | 11 +++ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index bb14cccfa..f5c3237cf 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -142,6 +142,28 @@ def compute(self, inputs, outputs): list(tech_ancestors), return_avg=self.config.use_average_conversion_factor, ) + if not self.config.use_average_conversion_factor: + if np.all(np.abs(conversion_ratio) == 0.0): + conversion_ratio_val = self.get_converter_capacity_conversion_ratio( + inputs, + input_cmod, + output_cmod, + tech, + list(tech_ancestors), + ) + conversion_ratio = np.full( + len(inputs[self.demand_input_name]), conversion_ratio_val + ) + + if self.config.use_average_conversion_factor: + if conversion_ratio == 0.0: + conversion_ratio = self.get_converter_capacity_conversion_ratio( + inputs, + input_cmod, + output_cmod, + tech, + list(tech_ancestors), + ) conversion_factors[converter_ii] = conversion_ratio # check if the tech has an edge with the demand component if output_cmod == demand_commodity: @@ -155,29 +177,72 @@ def compute(self, inputs, outputs): raise ValueError("logic is wrong") # node_order = list(self.technology_graph.nodes()) # nodes_after_last_converter = node_order[node_order.index(tech)+1:] - inputs[self.demand_input_name].copy() - list(self.technology_graph.predecessors(self.demand_tech)) - list(nx.all_simple_paths(self.technology_graph, demand_converter, self.demand_tech)) + # upstream_of_dmd = list(self.technology_graph.predecessors(self.demand_tech)) + upstream_of_dmd = list( + nx.all_simple_paths(self.technology_graph, demand_converter, self.demand_tech) + ) + upstream_of_dmd_techs = set() + for upstream_path in upstream_of_dmd: + techs_upstream = set(upstream_path) + upstream_of_dmd_techs &= techs_upstream + + # technologies from the last converter to the demand component + upstream_of_dmd_techs = upstream_of_dmd_techs - {self.demand_tech} + # set the setpoint of all the technologies creating the stream that feeds the demand + self.get_setpoints_for_commodity_subset( + inputs, + outputs, + self.commodity, + inputs[self.demand_input_name].copy(), + tech_subset=upstream_of_dmd_techs, + ) - # if demand_converter is None: - # # If a converter isnt directly connected to the demand tech, assume its the last one - # # TODO: update so that it finds the converter that IS connected to the demand tech - # demand_converter = tech - # work backward from commodity demand + # now go through the rest of the commodity streams and get the demand + demand = inputs[self.demand_input_name].copy() converter_cnt.reverse() for converter_ii in converter_cnt: input_cmod, tech, output_cmod = converter_order[converter_ii] - # conversion is input_cmod/output_cmod - tech_ancestors = converter_upstreams[(input_cmod, tech)] - conversion_factors[converter_ii] + conversion_ratio = conversion_factors[converter_ii] upstream_techs = converter_upstreams[(input_cmod, tech)] upstream_converters = upstream_techs & set(converter_tech_names) if len(upstream_converters) == 0: - # no converters are upstream - pass + # no other converters upstream + upstream_commodity_demand = demand * conversion_ratio + # set setpoints + self.get_setpoints_for_commodity_subset( + inputs, + outputs, + self.commodity, + upstream_commodity_demand, + tech_subset=upstream_techs, + ) + else: # there are converters upstream + pass - {k[0] for i, k in converter_order.items() if k[0] != self.commodity} + # if demand_converter is None: + # # If a converter isnt directly connected to the demand tech, assume its the last one + # # TODO: update so that it finds the converter that IS connected to the demand tech + # demand_converter = tech + # work backward from commodity demand + # tmp = {converter_order[i]:conversion_factors[i] for i in list(conversion_factors.keys())} + + # converter_cnt.reverse() + # for converter_ii in converter_cnt: + # input_cmod, tech, output_cmod = converter_order[converter_ii] + # # conversion is input_cmod/output_cmod + # tech_ancestors = converter_upstreams[(input_cmod, tech)] + # conversion_factors[converter_ii] + # upstream_techs = converter_upstreams[(input_cmod, tech)] + # upstream_converters = upstream_techs & set(converter_tech_names) + # if len(upstream_converters) == 0: + # # no converters are upstream + # pass + # else: + # # there are converters upstream + # pass + + # {k[0] for i, k in converter_order.items() if k[0] != self.commodity} diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index b1eb3f075..2523c677e 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -943,6 +943,16 @@ def _get_converter_input_techs(self, converter_order, converter_ancestors): previous_converters.add(tech) return upstreams + def get_converter_capacity_conversion_ratio( + self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors + ): + rated_name_fmt = "{tech}_rated_{commod}_production" + in_names = [rated_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] + total_in_cmod_capac = [inputs[n] for n in in_names if n in inputs] + total_input_capac = np.array(total_in_cmod_capac).sum() + total_output_capac = inputs[rated_name_fmt.format(tech=converter_tech, commod=out_cmod)] + return total_input_capac / total_output_capac[0] + def get_converter_conversion_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors, return_avg=True ): @@ -964,6 +974,7 @@ def get_converter_conversion_ratio( """ input_name_fmt = "{tech}_{commod}_out" in_names = [input_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] + # used_inputs = [n for n in in_names if n in inputs] total_in_cmod = [inputs[n] for n in in_names if n in inputs] total_input = np.array(total_in_cmod).sum(axis=0) total_output = inputs[input_name_fmt.format(tech=converter_tech, commod=out_cmod)] From 159e4d785b9b6023db614a9ea18d464c10ececc2 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:46:47 -0600 Subject: [PATCH 05/69] draft update to demand following control --- .../system_level/demand_following_control.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index f5c3237cf..63f8361ff 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -107,6 +107,7 @@ def get_setpoints_for_commodity_subset( outputs[f"{dispatchable_tech}_{commodity}_set_point"] = ( remaining_demand / n_dispatchable ) + return outputs def compute(self, inputs, outputs): if not self.multi_commodity_system: @@ -201,6 +202,12 @@ def compute(self, inputs, outputs): # now go through the rest of the commodity streams and get the demand demand = inputs[self.demand_input_name].copy() converter_cnt.reverse() + + def compounding_conversion(init_demand, conversion_ratios): + for c in conversion_ratios.items(): + init_demand = init_demand * c + yield init_demand + for converter_ii in converter_cnt: input_cmod, tech, output_cmod = converter_order[converter_ii] conversion_ratio = conversion_factors[converter_ii] @@ -213,13 +220,30 @@ def compute(self, inputs, outputs): self.get_setpoints_for_commodity_subset( inputs, outputs, - self.commodity, + output_cmod, # self.commodity, # should this be output_cmod upstream_commodity_demand, tech_subset=upstream_techs, ) else: + # TODO: finish this bit # there are converters upstream + # upstream_flows = set() + # for uc in upstream_converters: + # upstream_tech_flows = {k for k,v in converter_upstreams.items() if k[1] == uc} + # up_upstream_techs = [converter_upstreams[k] for k in upstream_tech_flows} + # up_upstream_converters = up_upstream_techs & set(converter_tech_names) + + # Set the commodity demand of this converter subset + upstream_commodity_demand = demand * conversion_ratio + # set setpoints + self.get_setpoints_for_commodity_subset( + inputs, + outputs, + self.commodity, + upstream_commodity_demand, + tech_subset=upstream_techs, + ) pass From 5e2023c556e97af8a40a04112955bdedb9245e48 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:46:21 -0600 Subject: [PATCH 06/69] added homework --- .../system_level/homework.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 h2integrate/control/control_strategies/system_level/homework.py diff --git a/h2integrate/control/control_strategies/system_level/homework.py b/h2integrate/control/control_strategies/system_level/homework.py new file mode 100644 index 000000000..c0a32c025 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/homework.py @@ -0,0 +1,112 @@ +import networkx as nx + + +# BELOW HERE IS THE INFORMATION YOU HAVE +tech_connections = [ + ["boat", "desalination", "raw_water"], + ["desalination", "electrolyzer", "water"], + ["wind", "elec_combiner", "electricity"], + ["solar", "elec_combiner", "electricity"], + ["elec_combiner", "battery", "electricity"], + ["battery", "elec_combiner_2", "electricity"], + ["elec_combiner", "elec_combiner_2", "electricity"], + ["elec_combiner_2", "electrolyzer", "electricity"], + ["electrolyzer", "h2_storage", "hydrogen"], + ["electrolyzer", "h2_combiner", "hydrogen"], + ["h2_storage", "h2_combiner", "hydrogen"], + ["h2_combiner", "haber_bosch", "hydrogen"], + ["grid", "haber_bosch", "electricity"], + ["haber_bosch", "nh3_demand", "ammonia"], +] + +input_techs = [ + "boat", + "desalination", + "wind", + "solar", + "battery", + "electrolyzer", + "h2_storage", + "haber_bosch", + "grid", +] +demand_tech = "nh3_demand" + +technology_graph = nx.DiGraph() +for connection in tech_connections: + technology_graph.add_edge(connection[0], connection[1], commodity=connection[2]) + +# techs and their output commodities +techs_to_commodities = { + ("wind", "electricity"), + ("solar", "electricity"), + ("battery", "electricity"), + ("electrolyzer", "hydrogen"), + ("h2_storage", "hydrogen"), + ("boat", "raw_water"), + ("desalination", "water"), + ("grid", "electricity"), + ("haber_bosch", "ammonia"), +} + +upstreams = { + # (input_commodity, technology): {upstream techs that make input_commodity} + ("electricity", "haber_bosch"): {"grid"}, + ("hydrogen", "haber_bosch"): {"electrolyzer", "h2_storage"}, + ("electricity", "electrolyzer"): {"solar", "battery", "wind"}, + ("water", "electrolyzer"): {"desalination", "water_storage"}, + ("raw_water", "desalination"): {"boat"}, +} + +hb_e2a = 1.75 +hb_h2a = 2.0 +pem_e2h = 0.5 +pem_w2h = 5.0 +des_rw2w = 1 / 5 +conversion_factors = { + # (input_commodity, converter tech point, output_commodity): conversion factor + ("electricity", "haber_bosch", "ammonia"): hb_e2a, + ("hydrogen", "haber_bosch", "ammonia"): hb_h2a, + ("electricity", "electrolyzer", "hydrogen"): pem_e2h, + ("water", "electrolyzer", "hydrogen"): pem_w2h, + ("raw_water", "desalination", "water"): des_rw2w, +} + +ammonia_demand = 80.0 +converter_technologies = {k[1] for k, v in upstreams.items()} +# ABOVE HERE IS THE INFORMATION YOU HAVE + + +# how to convert the ammonia demand to the demand of other components at each step +# ex: grid_electricity_demand = ammonia_demand*1.75 +# grid demand is ammonia_demand*1.75 +# how do we get the +# 0) electricity demand for grid +# 1) hydrogen demand for the electrolyzer and h2 storage system +# 2) electricity demand for the wind, solar, and battery system +# 3) water demand for the desalination plant +# 4) raw_water demand for the boat + +# --- put attempted solution here --- + +# --- put attempted solution above --- + + +# Below can be used to test your result to see if its been done properly +nh3_dmd = 80.0 +# NOTE: the expected results formatting is a little stupid +expected_results = { + ("electricity", "haber_bosch"): nh3_dmd * hb_e2a, # electricity demand for grid + ("hydrogen", "haber_bosch"): nh3_dmd + * hb_h2a, # hydrogen demand for the electrolyzer and h2 storage system + ("electricity", "electrolyzer"): nh3_dmd + * hb_h2a + * pem_e2h, # electricity demand for the wind, solar, and battery system + ("water", "electrolyzer"): nh3_dmd + * hb_h2a + * pem_w2h, # water demand for the desalination plant + ("raw_water", "desalination"): nh3_dmd + * hb_h2a + * pem_w2h + * des_rw2w, # raw_water demand for the boat +} From fe43b1805abed5c6fd1f4265deb6cbacab043505 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:49:49 -0600 Subject: [PATCH 07/69] moved getting conversion factors to its own method --- .../system_level/demand_following_control.py | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 63f8361ff..49dc93d08 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -109,31 +109,10 @@ def get_setpoints_for_commodity_subset( ) return outputs - def compute(self, inputs, outputs): - if not self.multi_commodity_system: - self.get_setpoints_for_commodity_subset( - inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() - ) - return - - # should probably also get a list of generators, feedstocks, and storage - # should also get an idea of what components are in each "step" of the conversion - - converter_order, converter_upstreams = self.find_converter_techs( - include_feedstock_sources=True - ) - - converter_tech_names = {v[1] for k, v in converter_order.items()} - converter_cnt = list(converter_order.keys()) - converter_cnt.sort() + def get_conversion_factors(self, converter_order, converter_upstreams, inputs): conversion_factors = {} - - demand_converter = None - demand_commodity = self.demand_input_name.replace("_demand", "") - - # demand_converter = None - for converter_ii in converter_cnt: - input_cmod, tech, output_cmod = converter_order[converter_ii] + for converter_ii, converter_info in converter_order.items(): + input_cmod, tech, output_cmod = converter_info tech_ancestors = converter_upstreams[(input_cmod, tech)] conversion_ratio = self.get_converter_conversion_ratio( inputs, @@ -166,11 +145,40 @@ def compute(self, inputs, outputs): list(tech_ancestors), ) conversion_factors[converter_ii] = conversion_ratio - # check if the tech has an edge with the demand component - if output_cmod == demand_commodity: - demand_converter = str(tech) - # if self.technology_graph.has_edge(tech,self.demand_tech): - # demand_converter = tech + # TODO: update so key is converter_info + return conversion_factors + + def compute(self, inputs, outputs): + if not self.multi_commodity_system: + self.get_setpoints_for_commodity_subset( + inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() + ) + return + + # should probably also get a list of generators, feedstocks, and storage + # should also get an idea of what components are in each "step" of the conversion + + converter_order, converter_upstreams = self.find_converter_techs( + include_feedstock_sources=True + ) + + converter_tech_names = {v[1] for k, v in converter_order.items()} + converter_cnt = list(converter_order.keys()) + converter_cnt.sort() + + demand_converter = None + demand_commodity = self.demand_input_name.replace("_demand", "") + + conversion_factors = self.get_conversion_factors( + converter_order, converter_upstreams, inputs + ) + + # demand_converter = None + # check if the tech has an edge with the demand component + # if output_cmod == demand_commodity: + # demand_converter = str(tech) + # if self.technology_graph.has_edge(tech,self.demand_tech): + # demand_converter = tech if demand_converter is None: raise ValueError(f"no converters produce the demanded commodity {demand_commodity}") From 2de15d377f648add07a1bf4087c0b25e371d148d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:47:44 -0600 Subject: [PATCH 08/69] updated demand following for new approach --- .../system_level/demand_following_control.py | 316 ++++++++++++++---- 1 file changed, 243 insertions(+), 73 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 49dc93d08..43881970b 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -6,6 +6,7 @@ from h2integrate.control.control_strategies.system_level.system_level_control_base import ( SystemLevelControlBase, ) +import itertools @define(kw_only=True) @@ -148,6 +149,53 @@ def get_conversion_factors(self, converter_order, converter_upstreams, inputs): # TODO: update so key is converter_info return conversion_factors + def convert_combined_conversion_factors_to_tech_demand( + self, + reversed_grouped_techs, + simple_graph, + grouped_techs_compounding_conversion_factors, + use_simple_keynames=True, + ): + tech_groups_demand = {} + run_with_complex_keynames = False + for stuff, conv_fac in grouped_techs_compounding_conversion_factors.items(): + output_cmod, input_cmod, tech = stuff + tech_to_demand = [ + s + for s in list(simple_graph.predecessors(tech)) + if simple_graph.edges[s, tech].get("commodity", "") == input_cmod + ] + if len(tech_to_demand) != 1: + raise ValueError("Unexpected situation!") + # f"{input_cmod} demand for {tech_to_demand} so {tech} can make {output_cmod}" + if tech_to_demand[0] in reversed_grouped_techs: + { + "techs": list(reversed_grouped_techs[tech_to_demand[0]]), + "conversion factor": conv_fac, + } + else: + {"techs": tech_to_demand[0], "conversion factor": conv_fac} + # NOTE: could throw this in a function so that the keys are simple if needed + # below has complicated keys in case theres a more complex architecture (such as splitters) + key = ( + (input_cmod, tech_to_demand[0]) + if use_simple_keynames + else (input_cmod, tech_to_demand[0], (tech, output_cmod)) + ) + + if use_simple_keynames and key in tech_groups_demand: + run_with_complex_keynames = True + break + if run_with_complex_keynames: + result = self.convert_combined_conversion_factors_to_tech_demand( + self.reversed_grouped_techs, + simple_graph, + grouped_techs_compounding_conversion_factors, + use_simple_keynames=False, + ) + return result + return tech_groups_demand + def compute(self, inputs, outputs): if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( @@ -155,6 +203,8 @@ def compute(self, inputs, outputs): ) return + n_timesteps = len(inputs[self.demand_input_name]) + # should probably also get a list of generators, feedstocks, and storage # should also get an idea of what components are in each "step" of the conversion @@ -166,94 +216,214 @@ def compute(self, inputs, outputs): converter_cnt = list(converter_order.keys()) converter_cnt.sort() - demand_converter = None - demand_commodity = self.demand_input_name.replace("_demand", "") + # demand_converter = None + # demand_commodity = self.demand_input_name.replace("_demand", "") conversion_factors = self.get_conversion_factors( converter_order, converter_upstreams, inputs ) + # 1. Get the nodes of the technology graph that aren't a controllable technology + non_input_techs = ( + set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} + ) + # Group together technologies that are connected to a converter + # I.e., group together an electrolyzer an hydrogen storage, + # name this group as the shared commodity with a unique number + grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} + # 2. Make a dictionary for future-use that has keys of the technology names and the group they belong to + reversed_grouped_techs = {} + for k, v in grouped_techs.items(): + for vv in list(v): + reversed_grouped_techs[vv] = k + + # 3. Add in a conversion factor of 1 for all non-converter technologies + for tc in self.techs_to_commodities: + t, c = tc + if t not in converter_tech_names: + conversion_factors[(c, t, c)] = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + ) + + # 4. Add conversion factors of 1 for the technologies that are non_input_techs + # Also add these technologies to the reversed_group_techs + for non_t in list(non_input_techs): + up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs + down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs + + commod = None + if up_techs: + for t in list(up_techs): + commod = self.technology_graph.edges[t, non_t].get("commodity", None) + if commod is not None: + reversed_grouped_techs[non_t] = reversed_grouped_techs[t] + break + + if down_techs and commod is None: + for t in list(down_techs): + commod = self.technology_graph.edges[non_t, t].get("commodity", None) + if commod is not None: + reversed_grouped_techs[non_t] = reversed_grouped_techs[t] + break + conversion_factors[(commod, non_t, commod)] = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + ) + + # 5. Make the edges of the grouped technologies + simple_edges_real = [] + for e in list(self.technology_graph.edges(data="commodity")): + s0, d0, c = e + + s = reversed_grouped_techs.get(s0, s0) + d = reversed_grouped_techs.get(d0, d0) + + if s != d: + simple_edges_real.append((s, d, c)) + simple_graph = nx.DiGraph() + for connection in simple_edges_real: + # NOTE: this could be done in the above loop + simple_graph.add_edge(connection[0], connection[1], commodity=connection[2]) + + # 6. Get the compounding conversion factors + in_degs = dict(simple_graph.in_degree) # number of input things + # out_degs = dict(simple_graph.out_degree) # number of output things + starting_techs = {k for k, v in in_degs.items() if v == 0} + # demand_commodity = self.demand_input_name.split("_demand",-1)[0] + grouped_techs_compounding_conversion_factors = {} + for starting_tech in list(starting_techs): + paths = list(nx.all_simple_paths(simple_graph, starting_tech, self.demand_tech)) + commodity_graph = nx.DiGraph() # nodes are commodities + + # input_cmod, output_cmod, and tech or group name + # shouldnt be more than 1 path + path = paths[0] + # n_conversions = len(path) - 1 # remove starting tech + reverse_path = path[::-1] + # for p0,p1 in zip(path[:-1], path[1:]): + + commodity_conversions = [ + simple_graph.edges[p0, p1].get("commodity", None) + for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) + ] + commodity_nodes = list(itertools.pairwise(commodity_conversions)) + techs = reverse_path[1:] + for i, commod_node in enumerate(commodity_nodes): + # ammonia, hydrogen + down_cmod, up_cmod = commod_node + commodity_graph.add_edge(down_cmod, up_cmod, tech=techs[i]) + + commodity_edges = commodity_graph.edges(data="tech") + path_conversion = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + ) + + for edge in commodity_edges: + # in_cmod is demand of next tech + out_cmod, in_cmod, tech = edge + if tech in grouped_techs: + techs_in_group = list(grouped_techs[tech]) + conversion = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + ) + for t in techs_in_group: + if t in converter_tech_names: + conversion *= conversion_factors[(in_cmod, t, out_cmod)] + else: + conversion *= conversion_factors[(out_cmod, t, out_cmod)] + # converter_tech = [t for t in list(grouped_techs[tech]) if t in converter_technologies] + # converter_conversion = 1.0 + # for ct in converter_tech: + # converter_conversion *= conversion_factors[(in_cmod, ct, out_cmod)] + # TODO: add check if any other non-converter techs have a non-1 conversion factor + else: + conversion = conversion_factors[(in_cmod, tech, out_cmod)] + path_conversion *= conversion + grouped_techs_compounding_conversion_factors[(out_cmod, in_cmod, tech)] = ( + path_conversion + ) + + # TODO: add logic to get demand converter? # demand_converter = None # check if the tech has an edge with the demand component # if output_cmod == demand_commodity: # demand_converter = str(tech) # if self.technology_graph.has_edge(tech,self.demand_tech): # demand_converter = tech - if demand_converter is None: - raise ValueError(f"no converters produce the demanded commodity {demand_commodity}") + # if demand_converter is None: + # raise ValueError(f"no converters produce the demanded commodity {demand_commodity}") + + # if not self.technology_graph.has_edge(demand_converter, self.demand_tech): + # raise ValueError("logic is wrong") + # # node_order = list(self.technology_graph.nodes()) + # # nodes_after_last_converter = node_order[node_order.index(tech)+1:] + + # # upstream_of_dmd = list(self.technology_graph.predecessors(self.demand_tech)) + # upstream_of_dmd = list( + # nx.all_simple_paths(self.technology_graph, demand_converter, self.demand_tech) + # ) + # upstream_of_dmd_techs = set() + # for upstream_path in upstream_of_dmd: + # techs_upstream = set(upstream_path) + # upstream_of_dmd_techs &= techs_upstream + + # # technologies from the last converter to the demand component + # upstream_of_dmd_techs = upstream_of_dmd_techs - {self.demand_tech} + # # set the setpoint of all the technologies creating the stream that feeds the demand + # self.get_setpoints_for_commodity_subset( + # inputs, + # outputs, + # self.commodity, + # inputs[self.demand_input_name].copy(), + # tech_subset=upstream_of_dmd_techs, + # ) + + # # now go through the rest of the commodity streams and get the demand + # demand = inputs[self.demand_input_name].copy() + # converter_cnt.reverse() - if not self.technology_graph.has_edge(demand_converter, self.demand_tech): - raise ValueError("logic is wrong") - # node_order = list(self.technology_graph.nodes()) - # nodes_after_last_converter = node_order[node_order.index(tech)+1:] + # def compounding_conversion(init_demand, conversion_ratios): + # for c in conversion_ratios.items(): + # init_demand = init_demand * c + # yield init_demand - # upstream_of_dmd = list(self.technology_graph.predecessors(self.demand_tech)) - upstream_of_dmd = list( - nx.all_simple_paths(self.technology_graph, demand_converter, self.demand_tech) - ) - upstream_of_dmd_techs = set() - for upstream_path in upstream_of_dmd: - techs_upstream = set(upstream_path) - upstream_of_dmd_techs &= techs_upstream - - # technologies from the last converter to the demand component - upstream_of_dmd_techs = upstream_of_dmd_techs - {self.demand_tech} - # set the setpoint of all the technologies creating the stream that feeds the demand - self.get_setpoints_for_commodity_subset( - inputs, - outputs, - self.commodity, - inputs[self.demand_input_name].copy(), - tech_subset=upstream_of_dmd_techs, - ) - - # now go through the rest of the commodity streams and get the demand - demand = inputs[self.demand_input_name].copy() - converter_cnt.reverse() - - def compounding_conversion(init_demand, conversion_ratios): - for c in conversion_ratios.items(): - init_demand = init_demand * c - yield init_demand - - for converter_ii in converter_cnt: - input_cmod, tech, output_cmod = converter_order[converter_ii] - conversion_ratio = conversion_factors[converter_ii] - upstream_techs = converter_upstreams[(input_cmod, tech)] - upstream_converters = upstream_techs & set(converter_tech_names) - if len(upstream_converters) == 0: - # no other converters upstream - upstream_commodity_demand = demand * conversion_ratio - # set setpoints - self.get_setpoints_for_commodity_subset( - inputs, - outputs, - output_cmod, # self.commodity, # should this be output_cmod - upstream_commodity_demand, - tech_subset=upstream_techs, - ) + # for converter_ii in converter_cnt: + # input_cmod, tech, output_cmod = converter_order[converter_ii] + # conversion_ratio = conversion_factors[converter_ii] + # upstream_techs = converter_upstreams[(input_cmod, tech)] + # upstream_converters = upstream_techs & set(converter_tech_names) + # if len(upstream_converters) == 0: + # # no other converters upstream + # upstream_commodity_demand = demand * conversion_ratio + # # set setpoints + # self.get_setpoints_for_commodity_subset( + # inputs, + # outputs, + # output_cmod, # self.commodity, # should this be output_cmod + # upstream_commodity_demand, + # tech_subset=upstream_techs, + # ) - else: - # TODO: finish this bit - # there are converters upstream - # upstream_flows = set() - # for uc in upstream_converters: - # upstream_tech_flows = {k for k,v in converter_upstreams.items() if k[1] == uc} - # up_upstream_techs = [converter_upstreams[k] for k in upstream_tech_flows} - # up_upstream_converters = up_upstream_techs & set(converter_tech_names) - - # Set the commodity demand of this converter subset - upstream_commodity_demand = demand * conversion_ratio - # set setpoints - self.get_setpoints_for_commodity_subset( - inputs, - outputs, - self.commodity, - upstream_commodity_demand, - tech_subset=upstream_techs, - ) + # else: + # # TODO: finish this bit + # # there are converters upstream + # # upstream_flows = set() + # # for uc in upstream_converters: + # # upstream_tech_flows = {k for k,v in converter_upstreams.items() if k[1] == uc} + # # up_upstream_techs = [converter_upstreams[k] for k in upstream_tech_flows} + # # up_upstream_converters = up_upstream_techs & set(converter_tech_names) + + # # Set the commodity demand of this converter subset + # upstream_commodity_demand = demand * conversion_ratio + # # set setpoints + # self.get_setpoints_for_commodity_subset( + # inputs, + # outputs, + # self.commodity, + # upstream_commodity_demand, + # tech_subset=upstream_techs, + # ) - pass + # pass # if demand_converter is None: # # If a converter isnt directly connected to the demand tech, assume its the last one From a3bc25d07a8e2ef0ff951aa92caf5f75b11b8d8b Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:49:14 -0600 Subject: [PATCH 09/69] added more difficult example --- .../complex_multi_commodity/plant_config.yaml | 12 +- .../plant_config_v2.yaml | 108 ++++++++++++++++++ .../run_complex_multicommod.py | 3 +- .../complex_multi_commodity/tech_config.yaml | 36 +++++- .../top_level_config_v2.yaml | 4 + 5 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml create mode 100644 examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml diff --git a/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml b/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml index 8c55e4472..01f46de5a 100644 --- a/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml @@ -33,10 +33,10 @@ technology_interconnections: # subtract the hydrogen supplied from the hydrogen demand # - [h2_combiner, h2_load_demand, hydrogen, pipe] # connect the hydrogen supplied as the demand to the ammonia model - - [h2_combiner, ammonia, hydrogen, pipe] - - [n2_feedstock, ammonia, nitrogen, pipe] - - [electricity_feedstock, ammonia, electricity, cable] - - [ammonia, nh3_load_demand, ammonia, pipe] + - [h2_combiner, haber_bosch, hydrogen, pipe] + - [n2_feedstock, haber_bosch, nitrogen, pipe] + - [electricity_feedstock, haber_bosch, electricity, cable] + - [haber_bosch, nh3_load_demand, ammonia, pipe] # etc tech_to_dispatch_connections: - [combiner, battery] @@ -89,14 +89,14 @@ finance_parameters: technologies: [wind, solar, battery, electrolyzer, h2_storage] nh3: commodity: ammonia - commodity_stream: ammonia + commodity_stream: haber_bosch technologies: - wind - solar - battery - electrolyzer - h2_storage - - ammonia + - haber_bosch n2: commodity: nitrogen commodity_stream: n2_feedstock diff --git a/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml b/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml new file mode 100644 index 000000000..c1062b3ed --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml @@ -0,0 +1,108 @@ +name: plant_config +description: This plant is located in MN, USA... +sites: + site: + latitude: 32.31714 + longitude: -100.18 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 + solar_resource: + resource_model: GOESAggregatedSolarAPI + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + - [wind, combiner, electricity, cable] + - [solar, combiner, electricity, cable] + - [combiner, battery, electricity, cable] + # combine the battery output with the wind and solar generation + - [battery, elec_combiner, electricity, cable] + - [combiner, elec_combiner, electricity, cable] + # connect the electricity supply to the electrolyzer + - [elec_combiner, electrolyzer, electricity, cable] + - [electrolyzer, h2_storage, hydrogen, pipe] + # combine the h2 from the electrolyzer and the h2_storage + - [electrolyzer, h2_combiner, hydrogen, pipe] + - [h2_storage, h2_combiner, hydrogen, pipe] + # subtract the hydrogen supplied from the hydrogen demand + # - [h2_combiner, h2_load_demand, hydrogen, pipe] + # connect the hydrogen supplied as the demand to the ammonia model + - [h2_combiner, haber_bosch, hydrogen, pipe] + - [n2_feedstock, haber_bosch, nitrogen, pipe] + - [electricity_feedstock, haber_bosch, electricity, cable] + # ammonia system + - [haber_bosch, nh3_storage, ammonia, pipe] + - [nh3_storage, nh3_combiner, ammonia, pipe] + - [haber_bosch, nh3_combiner, ammonia, pipe] + - [nh3_combiner, nh3_load_demand, ammonia, pipe] + + # etc +tech_to_dispatch_connections: + - [combiner, battery] + - [battery, battery] +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] + - [site.solar_resource, solar, solar_resource_data] +system_level_control: + control_strategy: DemandFollowingControl + demand_component: nh3_load_demand + control_parameters: + use_average_conversion_factor: true + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +plant: + plant_life: 30 +finance_parameters: + finance_groups: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for real analysis + discount_rate: 0.06 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 0.724 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.025 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.2574 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.0 # average combined state and local sales tax https://taxfoundation.org/location/texas/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 7 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 + finance_subgroups: + h2: + commodity: hydrogen + commodity_stream: electrolyzer + technologies: [wind, solar, battery, electrolyzer, h2_storage] + nh3: + commodity: ammonia + commodity_stream: haber_bosch + technologies: + - wind + - solar + - battery + - electrolyzer + - h2_storage + - haber_bosch + n2: + commodity: nitrogen + commodity_stream: n2_feedstock + technologies: [n2_feedstock] diff --git a/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py index fd3f337dc..857102985 100644 --- a/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py +++ b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py @@ -8,7 +8,8 @@ ################################## # Create an H2I model with a fixed electricity load demand -h2i = H2IntegrateModel("top_level_config.yaml") +# h2i = H2IntegrateModel("top_level_config.yaml") +h2i = H2IntegrateModel("top_level_config_v2.yaml") h2i.setup() diff --git a/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml index 4a2ffb3a4..7251331f3 100644 --- a/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml @@ -197,7 +197,7 @@ technologies: price: 0.0 annual_cost: 0. start_up_cost: 0.0 - ammonia: + haber_bosch: performance_model: model: AmmoniaSynLoopPerformanceModel cost_model: @@ -270,3 +270,37 @@ technologies: commodity: ammonia commodity_rate_units: kg/h demand_profile: 47499.84 # kg/h + # below is only used in V2 + nh3_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: ammonia + commodity_rate_units: kg/h + nh3_storage: + performance_model: + model: StoragePerformanceModel + control_strategy: + model: DemandOpenLoopStorageController + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: ammonia + commodity_rate_units: kg/h + commodity_amount_units: kg + demand_profile: 47499.84 # 50 kg/h + # performance_parameters: + min_soc_fraction: 0.0 + max_soc_fraction: 1.0 + charge_efficiency: 1.0 + discharge_efficiency: 1.0 + max_capacity: 15000.0 + init_soc_fraction: 0.0 + max_charge_rate: 5000.0 + cost_parameters: + capacity_capex: 200.0 + charge_capex: 240.0 + opex_fraction: 0.05 + cost_year: 2020 diff --git a/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml b/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml new file mode 100644 index 000000000..41cd4d2ec --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml @@ -0,0 +1,4 @@ +name: H2Integrate_config +driver_config: driver_config.yaml +plant_config: plant_config_v2.yaml +technology_config: tech_config.yaml From fa1d1c8a45ef467af8d674cddd80d19ceb721002 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:13:11 -0600 Subject: [PATCH 10/69] updated example --- .../run_complex_multicommod.py | 16 +- .../complex_multi_commodity/tech_config.yaml | 34 -- .../tech_config_v2.yaml | 306 ++++++++++++++++++ .../top_level_config_v2.yaml | 2 +- 4 files changed, 322 insertions(+), 36 deletions(-) create mode 100644 examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml diff --git a/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py index 857102985..ad4489c86 100644 --- a/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py +++ b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py @@ -9,6 +9,8 @@ ################################## # Create an H2I model with a fixed electricity load demand # h2i = H2IntegrateModel("top_level_config.yaml") + +print("Starting V2 ...") h2i = H2IntegrateModel("top_level_config_v2.yaml") h2i.setup() @@ -16,7 +18,19 @@ # Run the model h2i.run() +print("Ran V2 successfully!") + + +print("Starting V1 ...") +h2i = H2IntegrateModel("top_level_config.yaml") + +h2i.setup() + +# Run the model +h2i.run() + +print("Ran V1 successfully!") # Post-process the results -h2i.post_process() +# h2i.post_process() # TODO: make even more complex by adding in an ammonia storage and combiner that goes to the demand tech diff --git a/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml index 7251331f3..207fb12bb 100644 --- a/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml @@ -270,37 +270,3 @@ technologies: commodity: ammonia commodity_rate_units: kg/h demand_profile: 47499.84 # kg/h - # below is only used in V2 - nh3_combiner: - performance_model: - model: GenericCombinerPerformanceModel - model_inputs: - performance_parameters: - commodity: ammonia - commodity_rate_units: kg/h - nh3_storage: - performance_model: - model: StoragePerformanceModel - control_strategy: - model: DemandOpenLoopStorageController - cost_model: - model: GenericStorageCostModel - model_inputs: - shared_parameters: - commodity: ammonia - commodity_rate_units: kg/h - commodity_amount_units: kg - demand_profile: 47499.84 # 50 kg/h - # performance_parameters: - min_soc_fraction: 0.0 - max_soc_fraction: 1.0 - charge_efficiency: 1.0 - discharge_efficiency: 1.0 - max_capacity: 15000.0 - init_soc_fraction: 0.0 - max_charge_rate: 5000.0 - cost_parameters: - capacity_capex: 200.0 - charge_capex: 240.0 - opex_fraction: 0.05 - cost_year: 2020 diff --git a/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml b/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml new file mode 100644 index 000000000..7251331f3 --- /dev/null +++ b/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml @@ -0,0 +1,306 @@ +name: technology_config +description: This hybrid plant produces ammonia +technologies: + wind: + performance_model: + model: FlorisWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 148 # number of turbines in the farm + hub_height: 115.0 # turbine hub-height + operational_losses: 10.49 # percentage of non-wake losses + floris_wake_config: !include "floris_v4_default_template.yaml" #floris wake model file + floris_turbine_config: !include "floris_turbine_NREL_6MW_170.yaml" #turbine model file formatted for floris + resource_data_averaging_method: average #"weighted_average", "average" or "nearest" + operation_model: cosine-loss # turbine operation model + default_turbulence_intensity: 0.06 + enable_caching: true # whether to use cached results + cache_dir: cache # directory to save or load cached data + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 7.0 + turbine_D_spacing: 7.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1380.0 + opex_per_kW_per_year: 29.0 + cost_year: 2019 + solar: + performance_model: + model: PYSAMSolarPlantPerformanceModel + cost_model: + model: ATBResComPVCostModel + model_inputs: + shared_parameters: + pv_capacity_kWdc: 400000 # 400 MWdc + performance_parameters: + dc_ac_ratio: 1.3 + create_model_from: default + config_name: PVWattsSingleOwner + tilt_angle_func: lat-func + pysam_options: + SystemDesign: + inv_eff: 96.0 + module_type: 0 # 19% efficiency + losses: 14.08 # dc losses + Lifetime: + dc_degradation: [0] + cost_parameters: + capex_per_kWdc: 1323 + opex_per_kWdc_per_year: 18 + cost_year: 2019 + combiner: + performance_model: + model: GenericCombinerPerformanceModel + dispatch_rule_set: + model: PyomoDispatchGenericConverter + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + battery: + dispatch_rule_set: + model: PyomoRuleStorageBaseclass + control_strategy: + model: HeuristicLoadFollowingStorageController + performance_model: + model: PySAMBatteryPerformanceModel + cost_model: + model: ATBBatteryCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 96.0 # kW + max_capacity: 96.0 # kWh + init_soc_fraction: 0.9 + max_soc_fraction: 1.0 + min_soc_fraction: 0.2 + performance_parameters: + chemistry: LFPGraphite + demand_profile: 640000 # 640 MW + cost_parameters: + cost_year: 2019 + energy_capex: 310 # $/kWh from 2024 ATB year 2025 + power_capex: 311 # $/kW from 2024 ATB year 2025 + opex_fraction: 0.025 + control_parameters: + n_control_window_hours: 24 + system_commodity_interface_limit: 1e12 + elec_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + electrolyzer: + performance_model: + model: ECOElectrolyzerPerformanceModel + cost_model: + model: SingliticoCostModel + model_inputs: + shared_parameters: + location: onshore + electrolyzer_capex: 1295 # $/kW overnight installed capital costs for a 1 MW system in 2022 USD/kW (DOE hydrogen program record 24005 Clean Hydrogen Production Cost Scenarios with PEM Electrolyzer Technology 05/20/24) (https://www.hydrogen.energy.gov/docs/hydrogenprogramlibraries/pdfs/24005-clean-hydrogen-production-cost-pem-electrolyzer.pdf?sfvrsn=8cb10889_1) + performance_parameters: + size_mode: normal + n_clusters: 16 + cluster_rating_MW: 40 + eol_eff_percent_loss: 10 # eol defined as x% change in efficiency from bol + uptime_hours_until_eol: 80000 # number of 'on' hours until electrolyzer reaches eol + include_degradation_penalty: true # include degradation + turndown_ratio: 0.1 # turndown_ratio = minimum_cluster_power/cluster_rating_MW + financial_parameters: + capital_items: + depr_period: 7 # based on PEM Electrolysis H2A Production Case Study Documentation estimate of 7 years. also see https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + replacement_cost_percent: 0.15 # percent of capex - H2A default case + h2_storage: + performance_model: + model: StoragePerformanceModel + control_strategy: + model: DemandOpenLoopStorageController + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + commodity_amount_units: kg + demand_profile: 9306.754158 # 50 kg/h + # performance_parameters: + min_soc_fraction: 0.0 + max_soc_fraction: 1.0 + charge_efficiency: 1.0 + discharge_efficiency: 1.0 + max_capacity: 1500.0 + init_soc_fraction: 0.0 + max_charge_rate: 500.0 + cost_parameters: + capacity_capex: 200.0 + charge_capex: 240.0 + opex_fraction: 0.05 + cost_year: 2020 + # # since the storage is being auto-sized by the performance model, + # # we set the sizing mode to 'auto' rather than defining the capacities + # # in the input file + # sizing_mode: auto # set as "auto" so storage capacity doesnt have to be defined + h2_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + h2_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + demand_profile: 9306.754158 # 50 kg/h + n2_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: nitrogen + commodity_rate_units: t/h + performance_parameters: + rated_capacity: 50.0 # metric tonnes of N2/hour + cost_parameters: + cost_year: 2022 + price: 5.0 + annual_cost: 0. + start_up_cost: 0.0 + electricity_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: MW + performance_parameters: + rated_capacity: 29.0 # MW of electricity + cost_parameters: + cost_year: 2022 + price: 0.0 + annual_cost: 0. + start_up_cost: 0.0 + haber_bosch: + performance_model: + model: AmmoniaSynLoopPerformanceModel + cost_model: + model: AmmoniaSynLoopCostModel + model_inputs: # See converters/ammonia/Ammonia cost breakdown-ANL source.xlsx + shared_parameters: + production_capacity: 52777.6 + catalyst_consumption_rate: 0.000091295354067341 + catalyst_replacement_interval: 3 + performance_parameters: + size_mode: normal + capacity_factor: 0.9 + energy_demand: 0.530645243 + heat_output: 0.8299956 + feed_gas_t: 25.8 + feed_gas_p: 20 + feed_gas_x_n2: 0.25 + feed_gas_x_h2: 0.75 + feed_gas_mass_ratio: 1.13 + purge_gas_t: 7.5 + purge_gas_p: 275 + purge_gas_x_n2: 0.26 + purge_gas_x_h2: 0.68 + purge_gas_x_ar: 0.02 + purge_gas_x_nh3: 0.04 + purge_gas_mass_ratio: 0.07 + # --- Dynamic operating constraints (optional) --- + # Turndown ratio: minimum production as a fraction of rated capacity. + turndown_ratio: 0.2 + # Per-hour ramp limits as a fraction of rated capacity. + ramp_up_rate_fraction: 0.5 + ramp_down_rate_fraction: 0.5 + # Cold start: triggered after a long off-period; introduces a multi-hour delay. + include_cold_start: true + off_hours_cold_start: 6 + cold_start_delay_hours: 4 + # Warm start: triggered after any short off-period; introduces a sub-hour delay. + include_warm_start: true + off_hours_warm_start: 0.5 + warm_start_delay_hours: 0.5 + cost_parameters: + baseline_capacity: 52777.6 + base_cost_year: 2016 + capex_scaling_exponent: 0.6 + labor_scaling_exponent: 0.25 + asu_capex_base: 236920646 # See ASU + HB capex-NETL source.xlsx + synloop_capex_base: 302460908 # See ASU + HB capex-NETL source.xlsx + heat_capex_base: 7069100 + cool_capex_base: 4799200 + other_eqpt_capex_base: 0 + land_capex_base: 4112701.84103543 + deprec_noneq_capex_rate: 0.42 + labor_rate_base: 57 + num_workers_base: 50 + hours_yr: 2080 + gen_admin: 0.2 + prop_tax_ins: 0.02 + maint_rep: 0.005 + oxygen_byproduct_rate: 0.29405077250145 + water_consumption_rate: 0.049236824 + rebuild_cost_base: 0 + cooling_water_cost_base: 0.000113349938601175 + catalyst_cost_base: 23.19977341 + oxygen_price_base: 0.0285210891617726 + nh3_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: ammonia + commodity_rate_units: kg/h + demand_profile: 47499.84 # kg/h + # below is only used in V2 + nh3_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: ammonia + commodity_rate_units: kg/h + nh3_storage: + performance_model: + model: StoragePerformanceModel + control_strategy: + model: DemandOpenLoopStorageController + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: ammonia + commodity_rate_units: kg/h + commodity_amount_units: kg + demand_profile: 47499.84 # 50 kg/h + # performance_parameters: + min_soc_fraction: 0.0 + max_soc_fraction: 1.0 + charge_efficiency: 1.0 + discharge_efficiency: 1.0 + max_capacity: 15000.0 + init_soc_fraction: 0.0 + max_charge_rate: 5000.0 + cost_parameters: + capacity_capex: 200.0 + charge_capex: 240.0 + opex_fraction: 0.05 + cost_year: 2020 diff --git a/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml b/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml index 41cd4d2ec..5f8defd20 100644 --- a/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml @@ -1,4 +1,4 @@ name: H2Integrate_config driver_config: driver_config.yaml plant_config: plant_config_v2.yaml -technology_config: tech_config.yaml +technology_config: tech_config_v2.yaml From 51976d3997430db159ba845b4e30136e8ed54aff Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:16:51 -0600 Subject: [PATCH 11/69] updated SLC baseclass and demand following and now example works yay --- .../system_level/demand_following_control.py | 275 ++++++++++-------- .../system_level/system_level_control_base.py | 19 +- 2 files changed, 163 insertions(+), 131 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 43881970b..b2c715469 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -1,3 +1,8 @@ +import operator +import warnings +import functools +import itertools + import numpy as np import networkx as nx from attrs import field, define @@ -6,7 +11,6 @@ from h2integrate.control.control_strategies.system_level.system_level_control_base import ( SystemLevelControlBase, ) -import itertools @define(kw_only=True) @@ -47,6 +51,8 @@ def setup(self): self.options["plant_config"]["system_level_control"].get("control_parameters", {}) ) + self.calls_to_compute = 0.0 + def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None ): @@ -66,6 +72,7 @@ def get_setpoints_for_commodity_subset( commodity_demand = self._subtract_fixed( fixed_tech, commodity_demand, commodity, inputs ) + self.tech_demands_set.append((fixed_tech, tech_commodity)) # 2. Flexible techs: operate at full production for flexible_tech in flexible_tech_subest: @@ -75,12 +82,14 @@ def get_setpoints_for_commodity_subset( commodity_demand = self._subtract_flexible( flexible_tech, commodity_demand, commodity, inputs, outputs ) + self.tech_demands_set.append((flexible_tech, tech_commodity)) else: if f"{flexible_tech}_rated_{tech_commodity}_production" in inputs: # set the per-tech set-point as the rated production outputs[f"{flexible_tech}_{tech_commodity}_set_point"] = inputs[ f"{flexible_tech}_rated_{tech_commodity}_production" ] * np.ones(self.n_timesteps) + self.tech_demands_set.append((flexible_tech, tech_commodity)) # 3. Storage dispatch # number of storage components that produce the demanded commodity @@ -93,6 +102,7 @@ def get_setpoints_for_commodity_subset( commodity_demand = self._dispatch_storage( storage_tech, commodity_demand / n_storage, commodity, inputs, outputs ) + self.tech_demands_set.append((storage_tech, commodity)) # 4. Dispatchable techs remaining_demand = np.maximum(commodity_demand, 0.0) @@ -108,11 +118,13 @@ def get_setpoints_for_commodity_subset( outputs[f"{dispatchable_tech}_{commodity}_set_point"] = ( remaining_demand / n_dispatchable ) + self.tech_demands_set.append((dispatchable_tech, commodity)) + return outputs def get_conversion_factors(self, converter_order, converter_upstreams, inputs): conversion_factors = {} - for converter_ii, converter_info in converter_order.items(): + for converter_info in converter_order.values(): input_cmod, tech, output_cmod = converter_info tech_ancestors = converter_upstreams[(input_cmod, tech)] conversion_ratio = self.get_converter_conversion_ratio( @@ -137,7 +149,11 @@ def get_conversion_factors(self, converter_order, converter_upstreams, inputs): ) if self.config.use_average_conversion_factor: - if conversion_ratio == 0.0: + if ( + (conversion_ratio == 0.0) + or np.isnan(conversion_ratio) + or np.isinf(conversion_ratio) + ): conversion_ratio = self.get_converter_capacity_conversion_ratio( inputs, input_cmod, @@ -145,13 +161,13 @@ def get_conversion_factors(self, converter_order, converter_upstreams, inputs): tech, list(tech_ancestors), ) - conversion_factors[converter_ii] = conversion_ratio + conversion_factors[converter_info] = conversion_ratio # TODO: update so key is converter_info return conversion_factors def convert_combined_conversion_factors_to_tech_demand( self, - reversed_grouped_techs, + grouped_techs, simple_graph, grouped_techs_compounding_conversion_factors, use_simple_keynames=True, @@ -168,15 +184,15 @@ def convert_combined_conversion_factors_to_tech_demand( if len(tech_to_demand) != 1: raise ValueError("Unexpected situation!") # f"{input_cmod} demand for {tech_to_demand} so {tech} can make {output_cmod}" - if tech_to_demand[0] in reversed_grouped_techs: - { - "techs": list(reversed_grouped_techs[tech_to_demand[0]]), + if tech_to_demand[0] in grouped_techs: + res = { + "techs": list(grouped_techs[tech_to_demand[0]]), "conversion factor": conv_fac, } else: - {"techs": tech_to_demand[0], "conversion factor": conv_fac} + res = {"techs": tech_to_demand[0], "conversion factor": conv_fac} # NOTE: could throw this in a function so that the keys are simple if needed - # below has complicated keys in case theres a more complex architecture (such as splitters) + # below has complicated keys in case theres a more complex architecture key = ( (input_cmod, tech_to_demand[0]) if use_simple_keynames @@ -186,9 +202,10 @@ def convert_combined_conversion_factors_to_tech_demand( if use_simple_keynames and key in tech_groups_demand: run_with_complex_keynames = True break + tech_groups_demand[key] = res if run_with_complex_keynames: result = self.convert_combined_conversion_factors_to_tech_demand( - self.reversed_grouped_techs, + grouped_techs, simple_graph, grouped_techs_compounding_conversion_factors, use_simple_keynames=False, @@ -196,7 +213,70 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand + def get_demand_converter_techs(self, converter_order, reversed_grouped_techs, n_timesteps): + missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) + self.demand_input_name.split("_demand", -1)[0] + # downstream_techs = set() + # missing_input_tech_commodities = set() + commodities_out = [self._get_commodity_for_tech(tech) for tech in list(missing_input_techs)] + commodities_out = set(functools.reduce(operator.iadd, commodities_out, [])) + converter_tech_names = {v[1] for k, v in converter_order.items()} + commodity_in_cmod_out = self.commodity in list(commodities_out) + if not commodity_in_cmod_out: + print("none of the demand commodities are made by missing techs") + + missing_tech_downstreams = [ + list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs + ] + missing_tech_downstreams_shared = {self.demand_tech} + other_converters = converter_tech_names - missing_input_techs + + for downstream in missing_tech_downstreams: + missing_tech_downstreams_shared = missing_tech_downstreams_shared & set(downstream) + # TODO: check that no other converters are inbetween + if set(downstream) & other_converters: + print("theres an extra converter between the missing techs and the demand") + if not missing_tech_downstreams_shared or len(missing_tech_downstreams_shared) > 1: + print("something unexpected happened") + + missing_converter_tech = missing_input_techs & converter_tech_names + if len(missing_converter_tech) > 1: + print("unsure how code will work with multiple converters connected to demand") + if not missing_converter_tech: + print("should have a converter before demand ...") + + for m0 in list(missing_converter_tech): + for m1 in list(missing_input_techs - missing_converter_tech): + m0_upstream = nx.has_path(self.technology_graph, m0, m1) + m1_upstream = nx.has_path(self.technology_graph, m1, m0) + if not m0_upstream or m1_upstream: + print("these technologies arent connected") + + # all checks have passed, these techs should be in the same group + # + # converter_upstreams[(demand_commodity, self.demand_tech)] = missing_input_techs + input_comps = {self.demand_tech} - missing_input_techs + + non_input_components = ( + set(functools.reduce(operator.iadd, missing_tech_downstreams, [])) - input_comps + ) + unique_number = ( + max([int(k.split("-", -1)[-1]) for k in list(reversed_grouped_techs.values())]) + ) + 1 + group_name = f"{self.commodity}-{int(unique_number)}" + rev_group_add_on = {k: group_name for k in list(non_input_components | missing_input_techs)} + non_converter_conversion = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + ) + conversion_factor_add_on = { + (self.commodity, tech, self.commodity): non_converter_conversion + for tech in list(missing_input_techs - missing_converter_tech) + } + return conversion_factor_add_on, rev_group_add_on + def compute(self, inputs, outputs): + self.calls_to_compute += 1 + if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() @@ -204,6 +284,7 @@ def compute(self, inputs, outputs): return n_timesteps = len(inputs[self.demand_input_name]) + self.demand_input_name.split("_demand", -1)[0] # should probably also get a list of generators, feedstocks, and storage # should also get an idea of what components are in each "step" of the conversion @@ -231,7 +312,8 @@ def compute(self, inputs, outputs): # I.e., group together an electrolyzer an hydrogen storage, # name this group as the shared commodity with a unique number grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} - # 2. Make a dictionary for future-use that has keys of the technology names and the group they belong to + # 2. Make a dictionary for future-use that has keys of the technology names and + # the group they belong to reversed_grouped_techs = {} for k, v in grouped_techs.items(): for vv in list(v): @@ -245,6 +327,26 @@ def compute(self, inputs, outputs): 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) ) + missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) + + group_tech_add_on = {} + if missing_input_techs: + # TODO: check that this works for the normal case + conversion_factor_add_on, rev_group_add_on = self.get_demand_converter_techs( + converter_order, reversed_grouped_techs, n_timesteps + ) + group_tech_add_on = {v: k for k, v in rev_group_add_on.items()} + for g in list(group_tech_add_on.keys()): + ts = [k for k, v in rev_group_add_on.items() if v == g] + group_tech_add_on[g] = set(ts) + + grouped_techs.update(group_tech_add_on) + conversion_factors.update(conversion_factor_add_on) + reversed_grouped_techs.update(rev_group_add_on) + + converter_upstreams[(self.commodity, self.demand_tech)] = ( + set(rev_group_add_on.keys()) & self.input_techs + ) # 4. Add conversion factors of 1 for the technologies that are non_input_techs # Also add these technologies to the reversed_group_techs for non_t in list(non_input_techs): @@ -330,10 +432,6 @@ def compute(self, inputs, outputs): conversion *= conversion_factors[(in_cmod, t, out_cmod)] else: conversion *= conversion_factors[(out_cmod, t, out_cmod)] - # converter_tech = [t for t in list(grouped_techs[tech]) if t in converter_technologies] - # converter_conversion = 1.0 - # for ct in converter_tech: - # converter_conversion *= conversion_factors[(in_cmod, ct, out_cmod)] # TODO: add check if any other non-converter techs have a non-1 conversion factor else: conversion = conversion_factors[(in_cmod, tech, out_cmod)] @@ -342,109 +440,42 @@ def compute(self, inputs, outputs): path_conversion ) - # TODO: add logic to get demand converter? - # demand_converter = None - # check if the tech has an edge with the demand component - # if output_cmod == demand_commodity: - # demand_converter = str(tech) - # if self.technology_graph.has_edge(tech,self.demand_tech): - # demand_converter = tech - # if demand_converter is None: - # raise ValueError(f"no converters produce the demanded commodity {demand_commodity}") - - # if not self.technology_graph.has_edge(demand_converter, self.demand_tech): - # raise ValueError("logic is wrong") - # # node_order = list(self.technology_graph.nodes()) - # # nodes_after_last_converter = node_order[node_order.index(tech)+1:] - - # # upstream_of_dmd = list(self.technology_graph.predecessors(self.demand_tech)) - # upstream_of_dmd = list( - # nx.all_simple_paths(self.technology_graph, demand_converter, self.demand_tech) - # ) - # upstream_of_dmd_techs = set() - # for upstream_path in upstream_of_dmd: - # techs_upstream = set(upstream_path) - # upstream_of_dmd_techs &= techs_upstream - - # # technologies from the last converter to the demand component - # upstream_of_dmd_techs = upstream_of_dmd_techs - {self.demand_tech} - # # set the setpoint of all the technologies creating the stream that feeds the demand - # self.get_setpoints_for_commodity_subset( - # inputs, - # outputs, - # self.commodity, - # inputs[self.demand_input_name].copy(), - # tech_subset=upstream_of_dmd_techs, - # ) - - # # now go through the rest of the commodity streams and get the demand - # demand = inputs[self.demand_input_name].copy() - # converter_cnt.reverse() - - # def compounding_conversion(init_demand, conversion_ratios): - # for c in conversion_ratios.items(): - # init_demand = init_demand * c - # yield init_demand - - # for converter_ii in converter_cnt: - # input_cmod, tech, output_cmod = converter_order[converter_ii] - # conversion_ratio = conversion_factors[converter_ii] - # upstream_techs = converter_upstreams[(input_cmod, tech)] - # upstream_converters = upstream_techs & set(converter_tech_names) - # if len(upstream_converters) == 0: - # # no other converters upstream - # upstream_commodity_demand = demand * conversion_ratio - # # set setpoints - # self.get_setpoints_for_commodity_subset( - # inputs, - # outputs, - # output_cmod, # self.commodity, # should this be output_cmod - # upstream_commodity_demand, - # tech_subset=upstream_techs, - # ) - - # else: - # # TODO: finish this bit - # # there are converters upstream - # # upstream_flows = set() - # # for uc in upstream_converters: - # # upstream_tech_flows = {k for k,v in converter_upstreams.items() if k[1] == uc} - # # up_upstream_techs = [converter_upstreams[k] for k in upstream_tech_flows} - # # up_upstream_converters = up_upstream_techs & set(converter_tech_names) - - # # Set the commodity demand of this converter subset - # upstream_commodity_demand = demand * conversion_ratio - # # set setpoints - # self.get_setpoints_for_commodity_subset( - # inputs, - # outputs, - # self.commodity, - # upstream_commodity_demand, - # tech_subset=upstream_techs, - # ) - - # pass - - # if demand_converter is None: - # # If a converter isnt directly connected to the demand tech, assume its the last one - # # TODO: update so that it finds the converter that IS connected to the demand tech - # demand_converter = tech - # work backward from commodity demand - # tmp = {converter_order[i]:conversion_factors[i] for i in list(conversion_factors.keys())} - - # converter_cnt.reverse() - # for converter_ii in converter_cnt: - # input_cmod, tech, output_cmod = converter_order[converter_ii] - # # conversion is input_cmod/output_cmod - # tech_ancestors = converter_upstreams[(input_cmod, tech)] - # conversion_factors[converter_ii] - # upstream_techs = converter_upstreams[(input_cmod, tech)] - # upstream_converters = upstream_techs & set(converter_tech_names) - # if len(upstream_converters) == 0: - # # no converters are upstream - # pass - # else: - # # there are converters upstream - # pass - - # {k[0] for i, k in converter_order.items() if k[0] != self.commodity} + compounding_conversion_factors = self.convert_combined_conversion_factors_to_tech_demand( + grouped_techs, + simple_graph, + grouped_techs_compounding_conversion_factors, + use_simple_keynames=True, + ) + if any(len(k) > 2 for k in list(compounding_conversion_factors.keys())): + raise NotImplementedError("This type of thing aint handled yet") + + self.tech_demands_set = [] + # Set demand for the techs in the "demand" group + demand_techs = converter_upstreams[(self.commodity, self.demand_tech)] + outputs = self.get_setpoints_for_commodity_subset( + inputs, + outputs, + self.commodity, + inputs[self.demand_input_name].copy(), + tech_subset=demand_techs, + ) + + for cmod_group, cf_techs in compounding_conversion_factors.items(): + commodity, _ = cmod_group + + inputs[self.demand_input_name] + commodity_demand = inputs[self.demand_input_name].copy() * cf_techs["conversion factor"] + + outputs = self.get_setpoints_for_commodity_subset( + inputs, + outputs, + commodity, + commodity_demand, + tech_subset=cf_techs["techs"], + ) + # NOTE: could add check to make sure everything was set + unset_techs_cmods = self.techs_to_commodities - set(self.tech_demands_set) + unset_techs = [k for k in list(unset_techs_cmods) if k[0] not in self.feedstock_comps] + if unset_techs: + warnings.warn(f"Commands not set for these technologies: {unset_techs}", UserWarning) + print(f"{self.calls_to_compute} calls to compute") diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 2523c677e..1e57424f2 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -815,7 +815,7 @@ def find_converter_techs(self, include_feedstock_sources=True): # and the values as upstream technologies that produce ``input_commodity`` and are # connected to the converter tech (does not include converters in upstream technologies) converter_ancestors = {} - node_order = list(self.technology_graph.nodes()) + list(self.technology_graph.nodes()) edges = list(self.technology_graph.edges(data="commodity")) ii = 0 # Track the most recently discovered converter so we can scope @@ -838,8 +838,9 @@ def find_converter_techs(self, include_feedstock_sources=True): # Only consider ancestors that appear after the last converter # in topological order, preventing double-counting across # chained converters. - converter_idx = node_order.index(last_converter) - nodes_after_converter = set(node_order[converter_idx + 1 :]) + # converter_idx = node_order.index(last_converter) + # nodes_after_converter = set(node_order[converter_idx + 1 :]) + nodes_after_converter = nx.descendants(self.technology_graph, last_converter) ancestors = all_ancestors & nodes_after_converter else: ancestors = all_ancestors @@ -879,9 +880,9 @@ def find_converter_techs(self, include_feedstock_sources=True): # re-reverse it converter_order = {v: k for k, v in rev_converter_order.items()} # remove duplicate converter orders - rev_converter_ancestors = {v: k for k, v in converter_ancestors.items()} # re-reverse it - converter_ancestors = {v: k for k, v in rev_converter_ancestors.items()} + # converter_ancestors = {v: list(k) for k, v in rev_converter_ancestors.items()} + converter_ancestors = {k: converter_ancestors[k] for k in list(converter_order.keys())} # Make sure we iterate through the converters in the right order converter_cnt = list(converter_order.keys()) @@ -979,10 +980,10 @@ def get_converter_conversion_ratio( total_input = np.array(total_in_cmod).sum(axis=0) total_output = inputs[input_name_fmt.format(tech=converter_tech, commod=out_cmod)] # Check if the converter produced any `out_cmod` - if total_output.sum() > 0: - conversion_factor = np.nan_to_num(total_input / total_output) - return conversion_factor.mean() if return_avg else conversion_factor - return total_input.mean() if return_avg else total_input + # if total_output.sum() > 0: + conversion_factor = np.nan_to_num(total_input / np.abs(total_output)) + return conversion_factor.mean() if return_avg else conversion_factor + # return total_input.mean() if return_avg else total_input def get_multi_converter_conversion_ratio( self, From 6a0325bed552e1edf8416e6cb7d4edfac1ff24d0 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:56:15 -0600 Subject: [PATCH 12/69] moved some logic from compute to setup --- .../system_level/demand_following_control.py | 241 +++++++++--------- 1 file changed, 120 insertions(+), 121 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index b2c715469..59d211de6 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -52,6 +52,103 @@ def setup(self): ) self.calls_to_compute = 0.0 + self.post_setup_multi_commodity() + + def post_setup_multi_commodity(self): + if not self.multi_commodity_system: + return + converter_order, converter_upstreams = self.find_converter_techs( + include_feedstock_sources=True + ) + converter_tech_names = {v[1] for k, v in converter_order.items()} + # 1. Get the nodes of the technology graph that aren't a controllable technology + non_input_techs = ( + set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} + ) + # Group together technologies that are connected to a converter + # I.e., group together an electrolyzer an hydrogen storage, + # name this group as the shared commodity with a unique number + grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} + # 2. Make a dictionary for future-use that has keys of the technology names and + # the group they belong to + reversed_grouped_techs = {} + for k, v in grouped_techs.items(): + for vv in list(v): + reversed_grouped_techs[vv] = k + + non_converter_convsion_factors = {} + # 3. Add in a conversion factor of 1 for all non-converter technologies + for tc in self.techs_to_commodities: + t, c = tc + if t not in converter_tech_names: + non_converter_convsion_factors[(c, t, c)] = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) + ) + + missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) + if missing_input_techs: + # TODO: check that this works for the normal case + conversion_factor_add_on, rev_group_add_on = self.get_demand_converter_techs( + converter_order, reversed_grouped_techs + ) + group_tech_add_on = {v: k for k, v in rev_group_add_on.items()} + for g in list(group_tech_add_on.keys()): + ts = [k for k, v in rev_group_add_on.items() if v == g] + group_tech_add_on[g] = set(ts) + + grouped_techs.update(group_tech_add_on) + non_converter_convsion_factors.update(conversion_factor_add_on) + reversed_grouped_techs.update(rev_group_add_on) + + converter_upstreams[(self.commodity, self.demand_tech)] = ( + set(rev_group_add_on.keys()) & self.input_techs + ) + + # 4. Add conversion factors of 1 for the technologies that are non_input_techs + # Also add these technologies to the reversed_group_techs + for non_t in list(non_input_techs): + up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs + down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs + + commod = None + if up_techs: + for t in list(up_techs): + commod = self.technology_graph.edges[t, non_t].get("commodity", None) + if commod is not None: + reversed_grouped_techs[non_t] = reversed_grouped_techs[t] + break + + if down_techs and commod is None: + for t in list(down_techs): + commod = self.technology_graph.edges[non_t, t].get("commodity", None) + if commod is not None: + reversed_grouped_techs[non_t] = reversed_grouped_techs[t] + break + non_converter_convsion_factors[(commod, non_t, commod)] = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) + ) + + # 5. Make the edges of the grouped technologies + simple_edges_real = [] + for e in list(self.technology_graph.edges(data="commodity")): + s0, d0, c = e + + s = reversed_grouped_techs.get(s0, s0) + d = reversed_grouped_techs.get(d0, d0) + + if s != d: + simple_edges_real.append((s, d, c)) + simple_graph = nx.DiGraph() + for connection in simple_edges_real: + # NOTE: this could be done in the above loop + simple_graph.add_edge(connection[0], connection[1], commodity=connection[2]) + + self.simple_graph = simple_graph + self.non_converter_conversion_factors = non_converter_convsion_factors + self.grouped_techs = grouped_techs + self.converter_order = converter_order + self.converter_upstreams = converter_upstreams + self.converter_tech_names = converter_tech_names def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None @@ -213,9 +310,9 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand - def get_demand_converter_techs(self, converter_order, reversed_grouped_techs, n_timesteps): + def get_demand_converter_techs(self, converter_order, reversed_grouped_techs): missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) - self.demand_input_name.split("_demand", -1)[0] + # downstream_techs = set() # missing_input_tech_commodities = set() commodities_out = [self._get_commodity_for_tech(tech) for tech in list(missing_input_techs)] @@ -266,7 +363,7 @@ def get_demand_converter_techs(self, converter_order, reversed_grouped_techs, n_ group_name = f"{self.commodity}-{int(unique_number)}" rev_group_add_on = {k: group_name for k in list(non_input_components | missing_input_techs)} non_converter_conversion = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) ) conversion_factor_add_on = { (self.commodity, tech, self.commodity): non_converter_conversion @@ -283,128 +380,29 @@ def compute(self, inputs, outputs): ) return - n_timesteps = len(inputs[self.demand_input_name]) - self.demand_input_name.split("_demand", -1)[0] - - # should probably also get a list of generators, feedstocks, and storage - # should also get an idea of what components are in each "step" of the conversion - - converter_order, converter_upstreams = self.find_converter_techs( - include_feedstock_sources=True + converter_conversion_factors = self.get_conversion_factors( + self.converter_order, self.converter_upstreams, inputs ) - converter_tech_names = {v[1] for k, v in converter_order.items()} - converter_cnt = list(converter_order.keys()) - converter_cnt.sort() - - # demand_converter = None - # demand_commodity = self.demand_input_name.replace("_demand", "") - - conversion_factors = self.get_conversion_factors( - converter_order, converter_upstreams, inputs + conversion_factors = ( + self.non_converter_conversion_factors.copy() | converter_conversion_factors ) - # 1. Get the nodes of the technology graph that aren't a controllable technology - non_input_techs = ( - set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} - ) - # Group together technologies that are connected to a converter - # I.e., group together an electrolyzer an hydrogen storage, - # name this group as the shared commodity with a unique number - grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} - # 2. Make a dictionary for future-use that has keys of the technology names and - # the group they belong to - reversed_grouped_techs = {} - for k, v in grouped_techs.items(): - for vv in list(v): - reversed_grouped_techs[vv] = k - - # 3. Add in a conversion factor of 1 for all non-converter technologies - for tc in self.techs_to_commodities: - t, c = tc - if t not in converter_tech_names: - conversion_factors[(c, t, c)] = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) - ) - - missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) - - group_tech_add_on = {} - if missing_input_techs: - # TODO: check that this works for the normal case - conversion_factor_add_on, rev_group_add_on = self.get_demand_converter_techs( - converter_order, reversed_grouped_techs, n_timesteps - ) - group_tech_add_on = {v: k for k, v in rev_group_add_on.items()} - for g in list(group_tech_add_on.keys()): - ts = [k for k, v in rev_group_add_on.items() if v == g] - group_tech_add_on[g] = set(ts) - - grouped_techs.update(group_tech_add_on) - conversion_factors.update(conversion_factor_add_on) - reversed_grouped_techs.update(rev_group_add_on) - - converter_upstreams[(self.commodity, self.demand_tech)] = ( - set(rev_group_add_on.keys()) & self.input_techs - ) - # 4. Add conversion factors of 1 for the technologies that are non_input_techs - # Also add these technologies to the reversed_group_techs - for non_t in list(non_input_techs): - up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs - down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs - - commod = None - if up_techs: - for t in list(up_techs): - commod = self.technology_graph.edges[t, non_t].get("commodity", None) - if commod is not None: - reversed_grouped_techs[non_t] = reversed_grouped_techs[t] - break - - if down_techs and commod is None: - for t in list(down_techs): - commod = self.technology_graph.edges[non_t, t].get("commodity", None) - if commod is not None: - reversed_grouped_techs[non_t] = reversed_grouped_techs[t] - break - conversion_factors[(commod, non_t, commod)] = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) - ) - - # 5. Make the edges of the grouped technologies - simple_edges_real = [] - for e in list(self.technology_graph.edges(data="commodity")): - s0, d0, c = e - - s = reversed_grouped_techs.get(s0, s0) - d = reversed_grouped_techs.get(d0, d0) - - if s != d: - simple_edges_real.append((s, d, c)) - simple_graph = nx.DiGraph() - for connection in simple_edges_real: - # NOTE: this could be done in the above loop - simple_graph.add_edge(connection[0], connection[1], commodity=connection[2]) - # 6. Get the compounding conversion factors - in_degs = dict(simple_graph.in_degree) # number of input things - # out_degs = dict(simple_graph.out_degree) # number of output things + in_degs = dict(self.simple_graph.in_degree) starting_techs = {k for k, v in in_degs.items() if v == 0} - # demand_commodity = self.demand_input_name.split("_demand",-1)[0] grouped_techs_compounding_conversion_factors = {} for starting_tech in list(starting_techs): - paths = list(nx.all_simple_paths(simple_graph, starting_tech, self.demand_tech)) + paths = list(nx.all_simple_paths(self.simple_graph, starting_tech, self.demand_tech)) commodity_graph = nx.DiGraph() # nodes are commodities - # input_cmod, output_cmod, and tech or group name - # shouldnt be more than 1 path + if len(paths) > 1: + print("There should only be one path") path = paths[0] - # n_conversions = len(path) - 1 # remove starting tech reverse_path = path[::-1] - # for p0,p1 in zip(path[:-1], path[1:]): commodity_conversions = [ - simple_graph.edges[p0, p1].get("commodity", None) + self.simple_graph.edges[p0, p1].get("commodity", None) for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) ] commodity_nodes = list(itertools.pairwise(commodity_conversions)) @@ -416,19 +414,21 @@ def compute(self, inputs, outputs): commodity_edges = commodity_graph.edges(data="tech") path_conversion = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) ) for edge in commodity_edges: # in_cmod is demand of next tech out_cmod, in_cmod, tech = edge - if tech in grouped_techs: - techs_in_group = list(grouped_techs[tech]) + if tech in self.grouped_techs: + techs_in_group = list(self.grouped_techs[tech]) conversion = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(n_timesteps) + 1.0 + if self.config.use_average_conversion_factor + else np.ones(self.n_timesteps) ) for t in techs_in_group: - if t in converter_tech_names: + if t in self.converter_tech_names: conversion *= conversion_factors[(in_cmod, t, out_cmod)] else: conversion *= conversion_factors[(out_cmod, t, out_cmod)] @@ -441,17 +441,17 @@ def compute(self, inputs, outputs): ) compounding_conversion_factors = self.convert_combined_conversion_factors_to_tech_demand( - grouped_techs, - simple_graph, + self.grouped_techs, + self.simple_graph, grouped_techs_compounding_conversion_factors, use_simple_keynames=True, ) if any(len(k) > 2 for k in list(compounding_conversion_factors.keys())): - raise NotImplementedError("This type of thing aint handled yet") + raise NotImplementedError("This type of system cannot be handled") self.tech_demands_set = [] # Set demand for the techs in the "demand" group - demand_techs = converter_upstreams[(self.commodity, self.demand_tech)] + demand_techs = self.converter_upstreams[(self.commodity, self.demand_tech)] outputs = self.get_setpoints_for_commodity_subset( inputs, outputs, @@ -478,4 +478,3 @@ def compute(self, inputs, outputs): unset_techs = [k for k in list(unset_techs_cmods) if k[0] not in self.feedstock_comps] if unset_techs: warnings.warn(f"Commands not set for these technologies: {unset_techs}", UserWarning) - print(f"{self.calls_to_compute} calls to compute") From c38cf9a2797fee60368494894e19819f5731b29c Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:05:39 -0600 Subject: [PATCH 13/69] removed unused functions and make print statements userwarnings --- .../system_level/demand_following_control.py | 33 ++++--- .../system_level/system_level_control_base.py | 86 ------------------- 2 files changed, 22 insertions(+), 97 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 59d211de6..294fb52a6 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -51,7 +51,6 @@ def setup(self): self.options["plant_config"]["system_level_control"].get("control_parameters", {}) ) - self.calls_to_compute = 0.0 self.post_setup_multi_commodity() def post_setup_multi_commodity(self): @@ -320,7 +319,11 @@ def get_demand_converter_techs(self, converter_order, reversed_grouped_techs): converter_tech_names = {v[1] for k, v in converter_order.items()} commodity_in_cmod_out = self.commodity in list(commodities_out) if not commodity_in_cmod_out: - print("none of the demand commodities are made by missing techs") + warnings.warn( + "none of the demand commodities are made by missing techs", + UserWarning, + stacklevel=3, + ) missing_tech_downstreams = [ list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs @@ -332,22 +335,30 @@ def get_demand_converter_techs(self, converter_order, reversed_grouped_techs): missing_tech_downstreams_shared = missing_tech_downstreams_shared & set(downstream) # TODO: check that no other converters are inbetween if set(downstream) & other_converters: - print("theres an extra converter between the missing techs and the demand") + warnings.warn( + "theres an extra converter between the missing techs and the demand", + UserWarning, + stacklevel=3, + ) if not missing_tech_downstreams_shared or len(missing_tech_downstreams_shared) > 1: - print("something unexpected happened") + warnings.warn("something unexpected happened", UserWarning, stacklevel=3) missing_converter_tech = missing_input_techs & converter_tech_names if len(missing_converter_tech) > 1: - print("unsure how code will work with multiple converters connected to demand") + warnings.warn( + "unsure how code will work with multiple converters connected to demand", + UserWarning, + stacklevel=3, + ) if not missing_converter_tech: - print("should have a converter before demand ...") + warnings.warn("should have a converter before demand ...", UserWarning, stacklevel=3) for m0 in list(missing_converter_tech): for m1 in list(missing_input_techs - missing_converter_tech): m0_upstream = nx.has_path(self.technology_graph, m0, m1) m1_upstream = nx.has_path(self.technology_graph, m1, m0) if not m0_upstream or m1_upstream: - print("these technologies arent connected") + warnings.warn("these technologies arent connected", UserWarning, stacklevel=3) # all checks have passed, these techs should be in the same group # @@ -372,8 +383,6 @@ def get_demand_converter_techs(self, converter_order, reversed_grouped_techs): return conversion_factor_add_on, rev_group_add_on def compute(self, inputs, outputs): - self.calls_to_compute += 1 - if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() @@ -397,7 +406,7 @@ def compute(self, inputs, outputs): commodity_graph = nx.DiGraph() # nodes are commodities if len(paths) > 1: - print("There should only be one path") + warnings.warn("There should only be one path", UserWarning, stacklevel=3) path = paths[0] reverse_path = path[::-1] @@ -477,4 +486,6 @@ def compute(self, inputs, outputs): unset_techs_cmods = self.techs_to_commodities - set(self.tech_demands_set) unset_techs = [k for k in list(unset_techs_cmods) if k[0] not in self.feedstock_comps] if unset_techs: - warnings.warn(f"Commands not set for these technologies: {unset_techs}", UserWarning) + warnings.warn( + f"Commands not set for these technologies: {unset_techs}", UserWarning, stacklevel=3 + ) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 1e57424f2..2d8e8633e 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,5 +1,3 @@ -import itertools - import numpy as np import networkx as nx import openmdao.api as om @@ -910,40 +908,6 @@ def find_converter_techs(self, include_feedstock_sources=True): # return converter_techs, converter_order, converter_ancestors, upstreams return converter_order, upstreams - def _get_converter_input_techs(self, converter_order, converter_ancestors): - """TODO: REMOVE THIS METHOD (lumped it into `find_converter_techs`) - - Args: - converter_order (dict[int, set[tuple[str, str, str]]]): _description_ - converter_ancestors (dict[int, list[str]]): _description_ - - Returns: - dict[tuple[str,str], set[str]]: Keys are set of - ``(input_commodity, tech_name)`` and the values are a set of - upstream technologies that output the `input_commodity` to `tech_name`. - - """ - # Make sure we iterate through the converters in the right order - converter_cnt = list(converter_order.keys()) - converter_cnt.sort() - previous_converters = set() # track previous converters - upstreams = {} - # NOTE: unsure how the below logic will work with splitters - for converter_ii in converter_cnt: - input_cmod, tech, output_cmod = converter_order[converter_ii] - # Get all the upstream technologies that produce a specific commodity - upstream1 = self.get_upstream_techs_for_commodity( - tech, input_cmod, include_feedstock_sources=True - ) - # Combined the upstream techs with all the previous converters - upstream_converter = set(upstream1) & previous_converters - # Remove any of the previous converters that arent connected to this converter - upstreams[(input_cmod, tech)] = set(upstream1) & ( - upstream_converter | set(converter_ancestors[converter_ii]) - ) - previous_converters.add(tech) - return upstreams - def get_converter_capacity_conversion_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors ): @@ -983,53 +947,3 @@ def get_converter_conversion_ratio( # if total_output.sum() > 0: conversion_factor = np.nan_to_num(total_input / np.abs(total_output)) return conversion_factor.mean() if return_avg else conversion_factor - # return total_input.mean() if return_avg else total_input - - def get_multi_converter_conversion_ratio( - self, - up_converter, - up_converter_incmod, - down_converter, - down_converter_outcmod, - converter_techs, - converter_order, - converter_ancestors, - ): - """NOT DONE - - Args: - up_converter (_type_): _description_ - up_converter_incmod (_type_): _description_ - down_converter (_type_): _description_ - down_converter_outcmod (_type_): _description_ - converter_order (_type_): _description_ - converter_ancestors (_type_): _description_ - """ - # check that theres a path - paths = list(nx.all_simple_paths(self.technology_graph, up_converter, down_converter)) - converter_tech_names = {v[1] for k, v in converter_order.items()} - - intermediate_converters = set() - # intermediate converters is a list of (converter_tech, output_commod) - for path in paths: - tech0 = path[0] # same as up_converter - for tech0, tech1 in itertools.pairwise(path): - if ( - commod := self.technology_graph.edges[tech0, tech1].get("commodity", None) - ) is not None: - # intermediate_commodities.add(commod) - tech0_is_intermediate = tech0 in converter_tech_names and tech0 != up_converter - tech1_is_intermediate = ( - tech1 in converter_tech_names and tech1 != down_converter - ) - if tech0_is_intermediate or tech1_is_intermediate: - # intermediate_converters.add(tech0) - intermediate_converters.add(tuple(tech0, commod)) - - # Determine which commodities are connected from ``up_converter`` to ``down_converter`` - converter_upstreams = self._get_converter_input_techs(converter_order, converter_ancestors) - converter_upstreams[tuple(up_converter_incmod, up_converter)] - - # loop through all the input commodities for the down converter - [k[0] for k, v in converter_upstreams.items() if k[1] == down_converter] - converter_upstreams[tuple(down_converter_outcmod, up_converter)] From e019fc2be8d19210fd25ee4998ed49dfac87b2b1 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:36:13 -0600 Subject: [PATCH 14/69] udpated example and added integration tests --- .../complex_multi_commodity/plant_config.yaml | 18 ++-- .../plant_config_v2.yaml | 31 +++++-- .../system_level/test/test_slc_examples.py | 86 +++++++++++++++++++ 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml b/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml index 01f46de5a..5702ddf9e 100644 --- a/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/plant_config.yaml @@ -87,7 +87,7 @@ finance_parameters: commodity: hydrogen commodity_stream: electrolyzer technologies: [wind, solar, battery, electrolyzer, h2_storage] - nh3: + nh3_produced: commodity: ammonia commodity_stream: haber_bosch technologies: @@ -97,7 +97,15 @@ finance_parameters: - electrolyzer - h2_storage - haber_bosch - n2: - commodity: nitrogen - commodity_stream: n2_feedstock - technologies: [n2_feedstock] + - n2_feedstock + nh3_delivered: + commodity: ammonia + commodity_stream: nh3_load_demand + technologies: + - wind + - solar + - battery + - electrolyzer + - h2_storage + - haber_bosch + - n2_feedstock diff --git a/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml b/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml index c1062b3ed..50e1650ee 100644 --- a/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml @@ -92,7 +92,7 @@ finance_parameters: commodity: hydrogen commodity_stream: electrolyzer technologies: [wind, solar, battery, electrolyzer, h2_storage] - nh3: + nh3_produced: commodity: ammonia commodity_stream: haber_bosch technologies: @@ -102,7 +102,28 @@ finance_parameters: - electrolyzer - h2_storage - haber_bosch - n2: - commodity: nitrogen - commodity_stream: n2_feedstock - technologies: [n2_feedstock] + - n2_feedstock + ammonia_available: + commodity: ammonia + commodity_stream: nh3_combiner + technologies: + - wind + - solar + - battery + - electrolyzer + - h2_storage + - haber_bosch + - n2_feedstock + - nh3_storage + nh3_delivered: + commodity: ammonia + commodity_stream: nh3_load_demand + technologies: + - wind + - solar + - battery + - electrolyzer + - h2_storage + - haber_bosch + - n2_feedstock + - nh3_storage diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 65bfcc96b..33d4b2717 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -1,6 +1,9 @@ +import os + import numpy as np import pytest +from h2integrate import EXAMPLE_DIR from h2integrate.core.h2integrate_model import H2IntegrateModel @@ -398,3 +401,86 @@ def test_slc_upstream_demand(subtests, temp_copy_of_example): # check that no hydrogen systems are in model.prob.get_val(slc_h2s_output_var, units="kg/h") assert f"Variable '{slc_h2s_output_var}' not found. " in str(excinfo.value) + + +@pytest.mark.integration +def test_slc_complex_multi_commodity_v1(subtests): + ex_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" + os.chdir(ex_folder) + h2i = H2IntegrateModel(ex_folder / "top_level_config.yaml") + + h2i.setup() + + h2i.run() + + with subtests.test("LCOH"): + assert ( + pytest.approx(3.8867863862476097, rel=1e-6) + == h2i.model.get_val("finance_subgroup_h2.LCOH", units="USD/kg")[0] + ) + + with subtests.test("LCOA - Produced"): + assert ( + pytest.approx(1.2607467064967108, rel=1e-6) + == h2i.model.get_val("finance_subgroup_nh3_produced.LCOA", units="USD/kg")[0] + ) + + with subtests.test("LCOA - Delivered"): + assert ( + pytest.approx(1.360845569863152, rel=1e-6) + == h2i.model.get_val("finance_subgroup_nh3_delivered.LCOA", units="USD/kg")[0] + ) + + with subtests.test("Unmet Ammonia Demand"): + assert ( + pytest.approx(92862.44227354404, rel=1e-6) + == h2i.model.get_val("nh3_load_demand.unmet_ammonia_demand_out", units="t/h").sum() + ) + + with subtests.test("Ammonia Demand Capacity Factor"): + assert ( + pytest.approx(77.68258710060003, rel=1e-6) + == h2i.model.get_val("nh3_load_demand.capacity_factor", units="percent")[0] + ) + + +@pytest.mark.integration +def test_slc_complex_multi_commodity_v2(subtests): + ex_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" + os.chdir(ex_folder) + h2i = H2IntegrateModel(ex_folder / "top_level_config_v2.yaml") + + h2i.setup() + + h2i.run() + + with subtests.test("LCOH"): + assert pytest.approx(3.8867863862476097, rel=1e-6) == h2i.model.get_val( + "finance_subgroup_h2.LCOH", units="USD/kg" + ) + + with subtests.test("LCOA - Produced"): + assert pytest.approx(1.2607467064967108, rel=1e-6) == h2i.model.get_val( + "finance_subgroup_nh3_produced.LCOA", units="USD/kg" + ) + + with subtests.test("LCOA - Available"): + assert pytest.approx(1.2625680481267114, rel=1e-6) == h2i.model.get_val( + "finance_subgroup_ammonia_available.LCOA", units="USD/kg" + ) + + with subtests.test("LCOA - Delivered"): + assert pytest.approx(1.3628115196257977, rel=1e-6) == h2i.model.get_val( + "finance_subgroup_nh3_delivered.LCOA", units="USD/kg" + ) + + with subtests.test("Unmet Ammonia Demand"): + assert ( + pytest.approx(92862.44227354404, rel=1e-6) + == h2i.model.get_val("nh3_load_demand.unmet_ammonia_demand_out", units="t/h").sum() + ) + + with subtests.test("Ammonia Demand Capacity Factor"): + assert pytest.approx(77.68258710060003, rel=1e-6) == h2i.model.get_val( + "nh3_load_demand.capacity_factor", units="percent" + ) From 895de5e7c290cdb38ebf72a727fb755a2b86df2a Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:44:36 -0600 Subject: [PATCH 15/69] removed unnecessary TODO statement --- .../control_strategies/system_level/demand_following_control.py | 1 - 1 file changed, 1 deletion(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 294fb52a6..1d835ae6c 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -258,7 +258,6 @@ def get_conversion_factors(self, converter_order, converter_upstreams, inputs): list(tech_ancestors), ) conversion_factors[converter_info] = conversion_ratio - # TODO: update so key is converter_info return conversion_factors def convert_combined_conversion_factors_to_tech_demand( From d8aa350ab2c30ec237fd129e95210a7a4cfbed0b Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:06:49 -0600 Subject: [PATCH 16/69] removed usage of converter_order and replaced with converters (no need for dictionary, using a set instead) --- .../system_level/demand_following_control.py | 20 +++++++++---------- .../system_level/system_level_control_base.py | 7 +++---- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 1d835ae6c..faf193c52 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -56,10 +56,8 @@ def setup(self): def post_setup_multi_commodity(self): if not self.multi_commodity_system: return - converter_order, converter_upstreams = self.find_converter_techs( - include_feedstock_sources=True - ) - converter_tech_names = {v[1] for k, v in converter_order.items()} + converters, converter_upstreams = self.find_converter_techs(include_feedstock_sources=True) + converter_tech_names = {v[1] for v in list(converters)} # 1. Get the nodes of the technology graph that aren't a controllable technology non_input_techs = ( set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} @@ -88,7 +86,7 @@ def post_setup_multi_commodity(self): if missing_input_techs: # TODO: check that this works for the normal case conversion_factor_add_on, rev_group_add_on = self.get_demand_converter_techs( - converter_order, reversed_grouped_techs + converters, reversed_grouped_techs ) group_tech_add_on = {v: k for k, v in rev_group_add_on.items()} for g in list(group_tech_add_on.keys()): @@ -145,7 +143,7 @@ def post_setup_multi_commodity(self): self.simple_graph = simple_graph self.non_converter_conversion_factors = non_converter_convsion_factors self.grouped_techs = grouped_techs - self.converter_order = converter_order + self.converters = converters self.converter_upstreams = converter_upstreams self.converter_tech_names = converter_tech_names @@ -218,9 +216,9 @@ def get_setpoints_for_commodity_subset( return outputs - def get_conversion_factors(self, converter_order, converter_upstreams, inputs): + def get_conversion_factors(self, converters, converter_upstreams, inputs): conversion_factors = {} - for converter_info in converter_order.values(): + for converter_info in list(converters): input_cmod, tech, output_cmod = converter_info tech_ancestors = converter_upstreams[(input_cmod, tech)] conversion_ratio = self.get_converter_conversion_ratio( @@ -308,14 +306,14 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand - def get_demand_converter_techs(self, converter_order, reversed_grouped_techs): + def get_demand_converter_techs(self, converters, reversed_grouped_techs): missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) # downstream_techs = set() # missing_input_tech_commodities = set() commodities_out = [self._get_commodity_for_tech(tech) for tech in list(missing_input_techs)] commodities_out = set(functools.reduce(operator.iadd, commodities_out, [])) - converter_tech_names = {v[1] for k, v in converter_order.items()} + converter_tech_names = {v[1] for v in list(converters)} commodity_in_cmod_out = self.commodity in list(commodities_out) if not commodity_in_cmod_out: warnings.warn( @@ -389,7 +387,7 @@ def compute(self, inputs, outputs): return converter_conversion_factors = self.get_conversion_factors( - self.converter_order, self.converter_upstreams, inputs + self.converters, self.converter_upstreams, inputs ) conversion_factors = ( diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 2d8e8633e..501f6cf71 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -783,9 +783,7 @@ def find_converter_techs(self, include_feedstock_sources=True): Returns: 2-element tuple containing: - - **converter_order** (dict[int, tuple[str, str, str]]): Dictionary - defining the directional order of converters. Keys are an integer indicating - order (lower numbers indicate a more upstream converter). The values are + - **converter_order** (tuple[str, str, str]): Set of tuples formatted as ``(input_commodity, tech_name, output_commodity)`` tuples. - **upstreams** (dict[tuple[str,str], set[str]]): Keys are set of ``(input_commodity, tech_name)`` and the values are a set of @@ -906,7 +904,8 @@ def find_converter_techs(self, include_feedstock_sources=True): ) previous_converters.add(tech) # return converter_techs, converter_order, converter_ancestors, upstreams - return converter_order, upstreams + # return converter_order, upstreams + return converter_techs, upstreams def get_converter_capacity_conversion_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors From 694e51af80df1292eee3ca0491f653c27061c702 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:20:16 -0600 Subject: [PATCH 17/69] simplified logic for non-converter conversion factor dictionary --- .../system_level/demand_following_control.py | 29 +++++++++++-------- .../system_level/system_level_control_base.py | 2 +- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index faf193c52..8ce3f64f0 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -57,11 +57,6 @@ def post_setup_multi_commodity(self): if not self.multi_commodity_system: return converters, converter_upstreams = self.find_converter_techs(include_feedstock_sources=True) - converter_tech_names = {v[1] for v in list(converters)} - # 1. Get the nodes of the technology graph that aren't a controllable technology - non_input_techs = ( - set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} - ) # Group together technologies that are connected to a converter # I.e., group together an electrolyzer an hydrogen storage, # name this group as the shared commodity with a unique number @@ -73,14 +68,16 @@ def post_setup_multi_commodity(self): for vv in list(v): reversed_grouped_techs[vv] = k - non_converter_convsion_factors = {} # 3. Add in a conversion factor of 1 for all non-converter technologies - for tc in self.techs_to_commodities: - t, c = tc - if t not in converter_tech_names: - non_converter_convsion_factors[(c, t, c)] = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - ) + converter_tech_names = {v[1] for v in list(converters)} + conversion_factor = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) + ) + non_converter_convsion_factors = { + (tc[1], tc[0], tc[1]): conversion_factor + for tc in self.techs_to_commodities + if tc[0] not in converter_tech_names + } missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) if missing_input_techs: @@ -103,6 +100,11 @@ def post_setup_multi_commodity(self): # 4. Add conversion factors of 1 for the technologies that are non_input_techs # Also add these technologies to the reversed_group_techs + + # Get the nodes of the technology graph that aren't a controllable technology + non_input_techs = ( + set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} + ) for non_t in list(non_input_techs): up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs @@ -112,6 +114,7 @@ def post_setup_multi_commodity(self): for t in list(up_techs): commod = self.technology_graph.edges[t, non_t].get("commodity", None) if commod is not None: + # Add these technologies to the reversed_group_techs reversed_grouped_techs[non_t] = reversed_grouped_techs[t] break @@ -119,8 +122,10 @@ def post_setup_multi_commodity(self): for t in list(down_techs): commod = self.technology_graph.edges[non_t, t].get("commodity", None) if commod is not None: + # Add these technologies to the reversed_group_techs reversed_grouped_techs[non_t] = reversed_grouped_techs[t] break + # Add conversion factors of 1 for the technologies that are non_input_techs non_converter_convsion_factors[(commod, non_t, commod)] = ( 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) ) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 501f6cf71..feb69573b 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -783,7 +783,7 @@ def find_converter_techs(self, include_feedstock_sources=True): Returns: 2-element tuple containing: - - **converter_order** (tuple[str, str, str]): Set of tuples formatted as + - **converters** (tuple[str, str, str]): Set of tuples formatted as ``(input_commodity, tech_name, output_commodity)`` tuples. - **upstreams** (dict[tuple[str,str], set[str]]): Keys are set of ``(input_commodity, tech_name)`` and the values are a set of From 6e7ef18244196a206635f97978acb002a232ac39 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:59:31 -0600 Subject: [PATCH 18/69] cleanups to methods in demand following controller --- .../system_level/demand_following_control.py | 163 ++++++++++++++---- 1 file changed, 131 insertions(+), 32 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 8ce3f64f0..16bfe8d29 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -61,42 +61,39 @@ def post_setup_multi_commodity(self): # I.e., group together an electrolyzer an hydrogen storage, # name this group as the shared commodity with a unique number grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} - # 2. Make a dictionary for future-use that has keys of the technology names and - # the group they belong to - reversed_grouped_techs = {} - for k, v in grouped_techs.items(): - for vv in list(v): - reversed_grouped_techs[vv] = k # 3. Add in a conversion factor of 1 for all non-converter technologies converter_tech_names = {v[1] for v in list(converters)} - conversion_factor = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - ) - non_converter_convsion_factors = { - (tc[1], tc[0], tc[1]): conversion_factor + + conversion_factor_keys = [ + (tc[1], tc[0], tc[1]) for tc in self.techs_to_commodities if tc[0] not in converter_tech_names - } + ] + # missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) - missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) - if missing_input_techs: - # TODO: check that this works for the normal case - conversion_factor_add_on, rev_group_add_on = self.get_demand_converter_techs( - converters, reversed_grouped_techs - ) - group_tech_add_on = {v: k for k, v in rev_group_add_on.items()} - for g in list(group_tech_add_on.keys()): - ts = [k for k, v in rev_group_add_on.items() if v == g] - group_tech_add_on[g] = set(ts) + # NOTE: maybe only run below if theres a missing_input_tech + non_converter_input_techs_in_group, demand_group = self.get_demand_components_group( + converters, converter_upstreams + ) + grouped_techs.update(demand_group) - grouped_techs.update(group_tech_add_on) - non_converter_convsion_factors.update(conversion_factor_add_on) - reversed_grouped_techs.update(rev_group_add_on) + conversion_factor_keys += [ + (self.commodity, k, self.commodity) for k in non_converter_input_techs_in_group + ] - converter_upstreams[(self.commodity, self.demand_tech)] = ( - set(rev_group_add_on.keys()) & self.input_techs - ) + # Add demand component to converter_upstreams + demand_group_techs = list(demand_group.values())[0] + converter_upstreams[(self.commodity, self.demand_tech)] = ( + set(demand_group_techs) & self.input_techs + ) + + # 2. Make a dictionary for future-use that has keys of the technology names and + # the group they belong to + reversed_grouped_techs = {} + for k, v in grouped_techs.items(): + for vv in list(v): + reversed_grouped_techs[vv] = k # 4. Add conversion factors of 1 for the technologies that are non_input_techs # Also add these technologies to the reversed_group_techs @@ -126,12 +123,11 @@ def post_setup_multi_commodity(self): reversed_grouped_techs[non_t] = reversed_grouped_techs[t] break # Add conversion factors of 1 for the technologies that are non_input_techs - non_converter_convsion_factors[(commod, non_t, commod)] = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - ) + conversion_factor_keys.append((commod, non_t, commod)) # 5. Make the edges of the grouped technologies simple_edges_real = [] + simple_graph = nx.DiGraph() for e in list(self.technology_graph.edges(data="commodity")): s0, d0, c = e @@ -146,12 +142,28 @@ def post_setup_multi_commodity(self): simple_graph.add_edge(connection[0], connection[1], commodity=connection[2]) self.simple_graph = simple_graph - self.non_converter_conversion_factors = non_converter_convsion_factors + # self.non_converter_conversion_factors = non_converter_convsion_factors + conversion_factor = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) + ) + self.non_converter_conversion_factors = dict( + zip(conversion_factor_keys, [conversion_factor] * len(conversion_factor_keys)) + ) + self.grouped_techs = grouped_techs self.converters = converters self.converter_upstreams = converter_upstreams self.converter_tech_names = converter_tech_names + def dict_values_to_flat_list(self, dictionary): + flat_list = [] + for v in dictionary.values(): + if isinstance(v, set): + v = list(v) + + flat_list.extend(v) + return flat_list + def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None ): @@ -311,6 +323,93 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand + def check_techs_connected_to_demand(self, converter_tech_names, missing_input_techs): + successful = True + commodities_for_missing_techs = { + tech: self._get_commodity_for_tech(tech) for tech in list(missing_input_techs) + } + commodities_out = self.dict_values_to_flat_list(commodities_for_missing_techs) + + if self.commodity not in list(commodities_out): + warnings.warn( + "none of the demand commodities are made by missing techs", + UserWarning, + stacklevel=3, + ) + successful = False + + missing_tech_downstreams = [ + list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs + ] + missing_tech_downstreams_shared = {self.demand_tech} + other_converters = converter_tech_names - missing_input_techs + + for downstream in missing_tech_downstreams: + missing_tech_downstreams_shared = missing_tech_downstreams_shared & set(downstream) + # TODO: check that no other converters are inbetween + if set(downstream) & other_converters: + warnings.warn( + "theres an extra converter between the missing techs and the demand", + UserWarning, + stacklevel=3, + ) + successful = False + if not missing_tech_downstreams_shared or len(missing_tech_downstreams_shared) > 1: + warnings.warn("something unexpected happened", UserWarning, stacklevel=3) + successful = False + + missing_converter_tech = missing_input_techs & converter_tech_names + if len(missing_converter_tech) > 1: + warnings.warn( + "unsure how code will work with multiple converters connected to demand", + UserWarning, + stacklevel=3, + ) + successful = False + if not missing_converter_tech: + warnings.warn("should have a converter before demand ...", UserWarning, stacklevel=3) + successful = False + + for m0 in list(missing_converter_tech): + for m1 in list(missing_input_techs - missing_converter_tech): + m0_upstream = nx.has_path(self.technology_graph, m0, m1) + m1_upstream = nx.has_path(self.technology_graph, m1, m0) + if not m0_upstream or m1_upstream: + warnings.warn("these technologies arent connected", UserWarning, stacklevel=3) + successful = False + return successful + + def get_demand_components_group(self, converters, converter_upstreams): + found_input_techs = self.dict_values_to_flat_list(converter_upstreams) + missing_input_techs = set(self.input_techs) - set(found_input_techs) + + converter_tech_names = {v[1] for v in list(converters)} + + successful = self.check_techs_connected_to_demand(converter_tech_names, missing_input_techs) + if not successful: + msg = "A bug may exist. Please refer to earlier warnings" + warnings.warn(msg, UserWarning, stacklevel=3) + + input_comps = {self.demand_tech} - missing_input_techs + missing_techs_to_downstreams = { + tech: list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs + } + missing_tech_downstreams = self.dict_values_to_flat_list(missing_techs_to_downstreams) + non_input_components = set(missing_tech_downstreams) - input_comps + + unique_number = int(len(converter_upstreams) + 1) + group_name = f"{self.commodity}-{int(unique_number)}" + + missing_converter_tech = missing_input_techs & converter_tech_names + + # all techs in group, include non-controllable ones (like combiners) + all_techs_in_group = list(non_input_components | missing_input_techs) + # input techs in group except for the converter + non_converter_input_techs_in_group = list(missing_input_techs - missing_converter_tech) + demand_group = {group_name: all_techs_in_group} + + return non_converter_input_techs_in_group, demand_group + def get_demand_converter_techs(self, converters, reversed_grouped_techs): missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) From 6753dd890ece9240361a7223850df360e8200e98 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:02:24 -0600 Subject: [PATCH 19/69] minor cleanups to demand following control methods --- .../system_level/demand_following_control.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 16bfe8d29..bec042dd3 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -102,6 +102,8 @@ def post_setup_multi_commodity(self): non_input_techs = ( set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} ) + # Also add these technologies to the reversed_group_techs + for non_t in list(non_input_techs): up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs @@ -126,7 +128,6 @@ def post_setup_multi_commodity(self): conversion_factor_keys.append((commod, non_t, commod)) # 5. Make the edges of the grouped technologies - simple_edges_real = [] simple_graph = nx.DiGraph() for e in list(self.technology_graph.edges(data="commodity")): s0, d0, c = e @@ -135,11 +136,7 @@ def post_setup_multi_commodity(self): d = reversed_grouped_techs.get(d0, d0) if s != d: - simple_edges_real.append((s, d, c)) - simple_graph = nx.DiGraph() - for connection in simple_edges_real: - # NOTE: this could be done in the above loop - simple_graph.add_edge(connection[0], connection[1], commodity=connection[2]) + simple_graph.add_edge(s, d, commodity=c) self.simple_graph = simple_graph # self.non_converter_conversion_factors = non_converter_convsion_factors From a9ad4271649294fbf42b8c89b8a769caac866f78 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:11:36 -0600 Subject: [PATCH 20/69] cleanups to demand following methods --- .../system_level/demand_following_control.py | 101 ++++-------------- 1 file changed, 18 insertions(+), 83 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index bec042dd3..de30fbfa4 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -1,6 +1,4 @@ -import operator import warnings -import functools import itertools import numpy as np @@ -140,12 +138,14 @@ def post_setup_multi_commodity(self): self.simple_graph = simple_graph # self.non_converter_conversion_factors = non_converter_convsion_factors - conversion_factor = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - ) - self.non_converter_conversion_factors = dict( - zip(conversion_factor_keys, [conversion_factor] * len(conversion_factor_keys)) - ) + # conversion_factor = ( + # 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) + # ) + # self.non_converter_conversion_factors = dict( + # zip(conversion_factor_keys, [conversion_factor] * len(conversion_factor_keys)) + # ) + + self.non_converter_conversion_factor_keys = conversion_factor_keys self.grouped_techs = grouped_techs self.converters = converters @@ -407,79 +407,6 @@ def get_demand_components_group(self, converters, converter_upstreams): return non_converter_input_techs_in_group, demand_group - def get_demand_converter_techs(self, converters, reversed_grouped_techs): - missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) - - # downstream_techs = set() - # missing_input_tech_commodities = set() - commodities_out = [self._get_commodity_for_tech(tech) for tech in list(missing_input_techs)] - commodities_out = set(functools.reduce(operator.iadd, commodities_out, [])) - converter_tech_names = {v[1] for v in list(converters)} - commodity_in_cmod_out = self.commodity in list(commodities_out) - if not commodity_in_cmod_out: - warnings.warn( - "none of the demand commodities are made by missing techs", - UserWarning, - stacklevel=3, - ) - - missing_tech_downstreams = [ - list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs - ] - missing_tech_downstreams_shared = {self.demand_tech} - other_converters = converter_tech_names - missing_input_techs - - for downstream in missing_tech_downstreams: - missing_tech_downstreams_shared = missing_tech_downstreams_shared & set(downstream) - # TODO: check that no other converters are inbetween - if set(downstream) & other_converters: - warnings.warn( - "theres an extra converter between the missing techs and the demand", - UserWarning, - stacklevel=3, - ) - if not missing_tech_downstreams_shared or len(missing_tech_downstreams_shared) > 1: - warnings.warn("something unexpected happened", UserWarning, stacklevel=3) - - missing_converter_tech = missing_input_techs & converter_tech_names - if len(missing_converter_tech) > 1: - warnings.warn( - "unsure how code will work with multiple converters connected to demand", - UserWarning, - stacklevel=3, - ) - if not missing_converter_tech: - warnings.warn("should have a converter before demand ...", UserWarning, stacklevel=3) - - for m0 in list(missing_converter_tech): - for m1 in list(missing_input_techs - missing_converter_tech): - m0_upstream = nx.has_path(self.technology_graph, m0, m1) - m1_upstream = nx.has_path(self.technology_graph, m1, m0) - if not m0_upstream or m1_upstream: - warnings.warn("these technologies arent connected", UserWarning, stacklevel=3) - - # all checks have passed, these techs should be in the same group - # - # converter_upstreams[(demand_commodity, self.demand_tech)] = missing_input_techs - input_comps = {self.demand_tech} - missing_input_techs - - non_input_components = ( - set(functools.reduce(operator.iadd, missing_tech_downstreams, [])) - input_comps - ) - unique_number = ( - max([int(k.split("-", -1)[-1]) for k in list(reversed_grouped_techs.values())]) - ) + 1 - group_name = f"{self.commodity}-{int(unique_number)}" - rev_group_add_on = {k: group_name for k in list(non_input_components | missing_input_techs)} - non_converter_conversion = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - ) - conversion_factor_add_on = { - (self.commodity, tech, self.commodity): non_converter_conversion - for tech in list(missing_input_techs - missing_converter_tech) - } - return conversion_factor_add_on, rev_group_add_on - def compute(self, inputs, outputs): if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( @@ -491,9 +418,17 @@ def compute(self, inputs, outputs): self.converters, self.converter_upstreams, inputs ) - conversion_factors = ( - self.non_converter_conversion_factors.copy() | converter_conversion_factors + conversion_factor_of_1 = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) + ) + + non_converter_conversion_factors = dict( + zip( + self.non_converter_conversion_factor_keys, + [conversion_factor_of_1] * len(self.non_converter_conversion_factor_keys), + ) ) + conversion_factors = non_converter_conversion_factors | converter_conversion_factors # 6. Get the compounding conversion factors in_degs = dict(self.simple_graph.in_degree) From 7a5889e9e73d6b9cea7af2bcdd7877a940bb935e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:22:45 -0600 Subject: [PATCH 21/69] methodized another part of post_setup_multi_commodity --- .../system_level/demand_following_control.py | 98 ++++++++++++++----- 1 file changed, 73 insertions(+), 25 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index de30fbfa4..f830ed7b0 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -97,33 +97,41 @@ def post_setup_multi_commodity(self): # Also add these technologies to the reversed_group_techs # Get the nodes of the technology graph that aren't a controllable technology - non_input_techs = ( - set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} - ) + # non_input_techs = ( + # set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} + # ) # Also add these technologies to the reversed_group_techs - for non_t in list(non_input_techs): - up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs - down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs - - commod = None - if up_techs: - for t in list(up_techs): - commod = self.technology_graph.edges[t, non_t].get("commodity", None) - if commod is not None: - # Add these technologies to the reversed_group_techs - reversed_grouped_techs[non_t] = reversed_grouped_techs[t] - break - - if down_techs and commod is None: - for t in list(down_techs): - commod = self.technology_graph.edges[non_t, t].get("commodity", None) - if commod is not None: - # Add these technologies to the reversed_group_techs - reversed_grouped_techs[non_t] = reversed_grouped_techs[t] - break - # Add conversion factors of 1 for the technologies that are non_input_techs - conversion_factor_keys.append((commod, non_t, commod)) + non_input_techs_conversion_factor_keys, techs_to_groups = ( + self.get_non_input_techs_for_groups(grouped_techs) + ) + conversion_factor_keys += non_input_techs_conversion_factor_keys + reversed_grouped_techs.update( + techs_to_groups + ) # unsure why we're not updating grouped_techs + + # for non_t in list(non_input_techs): + # up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs + # down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs + + # commod = None + # if up_techs: + # for t in list(up_techs): + # commod = self.technology_graph.edges[t, non_t].get("commodity", None) + # if commod is not None: + # # Add these technologies to the reversed_group_techs + # reversed_grouped_techs[non_t] = reversed_grouped_techs[t] + # break + + # if down_techs and commod is None: + # for t in list(down_techs): + # commod = self.technology_graph.edges[non_t, t].get("commodity", None) + # if commod is not None: + # # Add these technologies to the reversed_group_techs + # reversed_grouped_techs[non_t] = reversed_grouped_techs[t] + # break + # # Add conversion factors of 1 for the technologies that are non_input_techs + # conversion_factor_keys.append((commod, non_t, commod)) # 5. Make the edges of the grouped technologies simple_graph = nx.DiGraph() @@ -407,6 +415,46 @@ def get_demand_components_group(self, converters, converter_upstreams): return non_converter_input_techs_in_group, demand_group + def get_non_input_techs_for_groups(self, grouped_techs): + def get_group_for_tech(tech_name): + group = [grp for grp, techs in grouped_techs.items() if tech_name in techs] + if len(group) == 0: + msg = f"Cannot find simplified group for technology {tech_name}" + raise ValueError(msg) + return group[0] + + techs_to_groups = {} + conversion_factor_keys = [] + + non_input_techs = ( + set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} + ) + # Also add these technologies to the reversed_group_techs + + for non_t in list(non_input_techs): + up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs + down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs + + commod = None + if up_techs: + for t in list(up_techs): + commod = self.technology_graph.edges[t, non_t].get("commodity", None) + if commod is not None: + # Add these technologies to the reversed_group_techs + techs_to_groups[non_t] = get_group_for_tech(t) + break + + if down_techs and commod is None: + for t in list(down_techs): + commod = self.technology_graph.edges[non_t, t].get("commodity", None) + if commod is not None: + # Add these technologies to the reversed_group_techs + techs_to_groups[non_t] = get_group_for_tech(t) + break + # Add conversion factors of 1 for the technologies that are non_input_techs + conversion_factor_keys.append((commod, non_t, commod)) + return conversion_factor_keys, techs_to_groups + def compute(self, inputs, outputs): if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( From 67456125577f5167932e01ded7b89df00aaecdfd Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:51:00 -0600 Subject: [PATCH 22/69] minor cleanups before moving and renaming methods called in post_setup_multi_commodity() --- .../system_level/demand_following_control.py | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index f830ed7b0..7127d9f4e 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -97,42 +97,17 @@ def post_setup_multi_commodity(self): # Also add these technologies to the reversed_group_techs # Get the nodes of the technology graph that aren't a controllable technology - # non_input_techs = ( - # set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} - # ) # Also add these technologies to the reversed_group_techs non_input_techs_conversion_factor_keys, techs_to_groups = ( self.get_non_input_techs_for_groups(grouped_techs) ) + # Add conversion factors of 1 for the technologies that are non_input_techs conversion_factor_keys += non_input_techs_conversion_factor_keys reversed_grouped_techs.update( techs_to_groups ) # unsure why we're not updating grouped_techs - # for non_t in list(non_input_techs): - # up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs - # down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs - - # commod = None - # if up_techs: - # for t in list(up_techs): - # commod = self.technology_graph.edges[t, non_t].get("commodity", None) - # if commod is not None: - # # Add these technologies to the reversed_group_techs - # reversed_grouped_techs[non_t] = reversed_grouped_techs[t] - # break - - # if down_techs and commod is None: - # for t in list(down_techs): - # commod = self.technology_graph.edges[non_t, t].get("commodity", None) - # if commod is not None: - # # Add these technologies to the reversed_group_techs - # reversed_grouped_techs[non_t] = reversed_grouped_techs[t] - # break - # # Add conversion factors of 1 for the technologies that are non_input_techs - # conversion_factor_keys.append((commod, non_t, commod)) - # 5. Make the edges of the grouped technologies simple_graph = nx.DiGraph() for e in list(self.technology_graph.edges(data="commodity")): @@ -145,13 +120,6 @@ def post_setup_multi_commodity(self): simple_graph.add_edge(s, d, commodity=c) self.simple_graph = simple_graph - # self.non_converter_conversion_factors = non_converter_convsion_factors - # conversion_factor = ( - # 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - # ) - # self.non_converter_conversion_factors = dict( - # zip(conversion_factor_keys, [conversion_factor] * len(conversion_factor_keys)) - # ) self.non_converter_conversion_factor_keys = conversion_factor_keys @@ -416,6 +384,7 @@ def get_demand_components_group(self, converters, converter_upstreams): return non_converter_input_techs_in_group, demand_group def get_non_input_techs_for_groups(self, grouped_techs): + # Get the nodes of the technology graph that aren't a controllable technology def get_group_for_tech(tech_name): group = [grp for grp, techs in grouped_techs.items() if tech_name in techs] if len(group) == 0: From fa810af48abcafe40a8e0b4205d22f1d7d372cc4 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:56:32 -0600 Subject: [PATCH 23/69] renamed methods used in post_setup_multi_commodity --- .../system_level/demand_following_control.py | 16 +++++++++------- .../system_level/system_level_control_base.py | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 7127d9f4e..fe52ad14f 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -54,7 +54,7 @@ def setup(self): def post_setup_multi_commodity(self): if not self.multi_commodity_system: return - converters, converter_upstreams = self.find_converter_techs(include_feedstock_sources=True) + converters, converter_upstreams = self._find_converter_techs(include_feedstock_sources=True) # Group together technologies that are connected to a converter # I.e., group together an electrolyzer an hydrogen storage, # name this group as the shared commodity with a unique number @@ -71,7 +71,7 @@ def post_setup_multi_commodity(self): # missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) # NOTE: maybe only run below if theres a missing_input_tech - non_converter_input_techs_in_group, demand_group = self.get_demand_components_group( + non_converter_input_techs_in_group, demand_group = self._find_demand_tech_group( converters, converter_upstreams ) grouped_techs.update(demand_group) @@ -100,7 +100,7 @@ def post_setup_multi_commodity(self): # Also add these technologies to the reversed_group_techs non_input_techs_conversion_factor_keys, techs_to_groups = ( - self.get_non_input_techs_for_groups(grouped_techs) + self._find_group_for_non_input_techs(grouped_techs) ) # Add conversion factors of 1 for the technologies that are non_input_techs conversion_factor_keys += non_input_techs_conversion_factor_keys @@ -296,7 +296,7 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand - def check_techs_connected_to_demand(self, converter_tech_names, missing_input_techs): + def _check_demand_tech_group_connections(self, converter_tech_names, missing_input_techs): successful = True commodities_for_missing_techs = { tech: self._get_commodity_for_tech(tech) for tech in list(missing_input_techs) @@ -352,13 +352,15 @@ def check_techs_connected_to_demand(self, converter_tech_names, missing_input_te successful = False return successful - def get_demand_components_group(self, converters, converter_upstreams): + def _find_demand_tech_group(self, converters, converter_upstreams): found_input_techs = self.dict_values_to_flat_list(converter_upstreams) missing_input_techs = set(self.input_techs) - set(found_input_techs) converter_tech_names = {v[1] for v in list(converters)} - successful = self.check_techs_connected_to_demand(converter_tech_names, missing_input_techs) + successful = self._check_demand_tech_group_connections( + converter_tech_names, missing_input_techs + ) if not successful: msg = "A bug may exist. Please refer to earlier warnings" warnings.warn(msg, UserWarning, stacklevel=3) @@ -383,7 +385,7 @@ def get_demand_components_group(self, converters, converter_upstreams): return non_converter_input_techs_in_group, demand_group - def get_non_input_techs_for_groups(self, grouped_techs): + def _find_group_for_non_input_techs(self, grouped_techs): # Get the nodes of the technology graph that aren't a controllable technology def get_group_for_tech(tech_name): group = [grp for grp, techs in grouped_techs.items() if tech_name in techs] diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index feb69573b..0c5248d30 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -770,7 +770,7 @@ def get_upstream_techs_for_commodity( # Intersect with controller-managed techs return list(ancestors_with_commodity & input_techs) - def find_converter_techs(self, include_feedstock_sources=True): + def _find_converter_techs(self, include_feedstock_sources=True): """Identify technologies that transform one commodity into another. A "converter" is a tech whose output commodities differ from the commodities From e64fe3d98e51e8579880f5a411238deb3d8843cf Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:01:49 -0600 Subject: [PATCH 24/69] moved methods called in post_setup_multi_commodity to SLC base class --- .../system_level/demand_following_control.py | 139 ----------------- .../system_level/system_level_control_base.py | 141 ++++++++++++++++++ 2 files changed, 141 insertions(+), 139 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index fe52ad14f..2685ec2af 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -128,15 +128,6 @@ def post_setup_multi_commodity(self): self.converter_upstreams = converter_upstreams self.converter_tech_names = converter_tech_names - def dict_values_to_flat_list(self, dictionary): - flat_list = [] - for v in dictionary.values(): - if isinstance(v, set): - v = list(v) - - flat_list.extend(v) - return flat_list - def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None ): @@ -296,136 +287,6 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand - def _check_demand_tech_group_connections(self, converter_tech_names, missing_input_techs): - successful = True - commodities_for_missing_techs = { - tech: self._get_commodity_for_tech(tech) for tech in list(missing_input_techs) - } - commodities_out = self.dict_values_to_flat_list(commodities_for_missing_techs) - - if self.commodity not in list(commodities_out): - warnings.warn( - "none of the demand commodities are made by missing techs", - UserWarning, - stacklevel=3, - ) - successful = False - - missing_tech_downstreams = [ - list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs - ] - missing_tech_downstreams_shared = {self.demand_tech} - other_converters = converter_tech_names - missing_input_techs - - for downstream in missing_tech_downstreams: - missing_tech_downstreams_shared = missing_tech_downstreams_shared & set(downstream) - # TODO: check that no other converters are inbetween - if set(downstream) & other_converters: - warnings.warn( - "theres an extra converter between the missing techs and the demand", - UserWarning, - stacklevel=3, - ) - successful = False - if not missing_tech_downstreams_shared or len(missing_tech_downstreams_shared) > 1: - warnings.warn("something unexpected happened", UserWarning, stacklevel=3) - successful = False - - missing_converter_tech = missing_input_techs & converter_tech_names - if len(missing_converter_tech) > 1: - warnings.warn( - "unsure how code will work with multiple converters connected to demand", - UserWarning, - stacklevel=3, - ) - successful = False - if not missing_converter_tech: - warnings.warn("should have a converter before demand ...", UserWarning, stacklevel=3) - successful = False - - for m0 in list(missing_converter_tech): - for m1 in list(missing_input_techs - missing_converter_tech): - m0_upstream = nx.has_path(self.technology_graph, m0, m1) - m1_upstream = nx.has_path(self.technology_graph, m1, m0) - if not m0_upstream or m1_upstream: - warnings.warn("these technologies arent connected", UserWarning, stacklevel=3) - successful = False - return successful - - def _find_demand_tech_group(self, converters, converter_upstreams): - found_input_techs = self.dict_values_to_flat_list(converter_upstreams) - missing_input_techs = set(self.input_techs) - set(found_input_techs) - - converter_tech_names = {v[1] for v in list(converters)} - - successful = self._check_demand_tech_group_connections( - converter_tech_names, missing_input_techs - ) - if not successful: - msg = "A bug may exist. Please refer to earlier warnings" - warnings.warn(msg, UserWarning, stacklevel=3) - - input_comps = {self.demand_tech} - missing_input_techs - missing_techs_to_downstreams = { - tech: list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs - } - missing_tech_downstreams = self.dict_values_to_flat_list(missing_techs_to_downstreams) - non_input_components = set(missing_tech_downstreams) - input_comps - - unique_number = int(len(converter_upstreams) + 1) - group_name = f"{self.commodity}-{int(unique_number)}" - - missing_converter_tech = missing_input_techs & converter_tech_names - - # all techs in group, include non-controllable ones (like combiners) - all_techs_in_group = list(non_input_components | missing_input_techs) - # input techs in group except for the converter - non_converter_input_techs_in_group = list(missing_input_techs - missing_converter_tech) - demand_group = {group_name: all_techs_in_group} - - return non_converter_input_techs_in_group, demand_group - - def _find_group_for_non_input_techs(self, grouped_techs): - # Get the nodes of the technology graph that aren't a controllable technology - def get_group_for_tech(tech_name): - group = [grp for grp, techs in grouped_techs.items() if tech_name in techs] - if len(group) == 0: - msg = f"Cannot find simplified group for technology {tech_name}" - raise ValueError(msg) - return group[0] - - techs_to_groups = {} - conversion_factor_keys = [] - - non_input_techs = ( - set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} - ) - # Also add these technologies to the reversed_group_techs - - for non_t in list(non_input_techs): - up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs - down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs - - commod = None - if up_techs: - for t in list(up_techs): - commod = self.technology_graph.edges[t, non_t].get("commodity", None) - if commod is not None: - # Add these technologies to the reversed_group_techs - techs_to_groups[non_t] = get_group_for_tech(t) - break - - if down_techs and commod is None: - for t in list(down_techs): - commod = self.technology_graph.edges[non_t, t].get("commodity", None) - if commod is not None: - # Add these technologies to the reversed_group_techs - techs_to_groups[non_t] = get_group_for_tech(t) - break - # Add conversion factors of 1 for the technologies that are non_input_techs - conversion_factor_keys.append((commod, non_t, commod)) - return conversion_factor_keys, techs_to_groups - def compute(self, inputs, outputs): if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 0c5248d30..ab2af7f8d 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np import networkx as nx import openmdao.api as om @@ -946,3 +948,142 @@ def get_converter_conversion_ratio( # if total_output.sum() > 0: conversion_factor = np.nan_to_num(total_input / np.abs(total_output)) return conversion_factor.mean() if return_avg else conversion_factor + + def dict_values_to_flat_list(self, dictionary): + flat_list = [] + for v in dictionary.values(): + if isinstance(v, set): + v = list(v) + + flat_list.extend(v) + return flat_list + + def _check_demand_tech_group_connections(self, converter_tech_names, missing_input_techs): + successful = True + commodities_for_missing_techs = { + tech: self._get_commodity_for_tech(tech) for tech in list(missing_input_techs) + } + commodities_out = self.dict_values_to_flat_list(commodities_for_missing_techs) + + if self.commodity not in list(commodities_out): + warnings.warn( + "none of the demand commodities are made by missing techs", + UserWarning, + stacklevel=3, + ) + successful = False + + missing_tech_downstreams = [ + list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs + ] + missing_tech_downstreams_shared = {self.demand_tech} + other_converters = converter_tech_names - missing_input_techs + + for downstream in missing_tech_downstreams: + missing_tech_downstreams_shared = missing_tech_downstreams_shared & set(downstream) + # TODO: check that no other converters are inbetween + if set(downstream) & other_converters: + warnings.warn( + "theres an extra converter between the missing techs and the demand", + UserWarning, + stacklevel=3, + ) + successful = False + if not missing_tech_downstreams_shared or len(missing_tech_downstreams_shared) > 1: + warnings.warn("something unexpected happened", UserWarning, stacklevel=3) + successful = False + + missing_converter_tech = missing_input_techs & converter_tech_names + if len(missing_converter_tech) > 1: + warnings.warn( + "unsure how code will work with multiple converters connected to demand", + UserWarning, + stacklevel=3, + ) + successful = False + if not missing_converter_tech: + warnings.warn("should have a converter before demand ...", UserWarning, stacklevel=3) + successful = False + + for m0 in list(missing_converter_tech): + for m1 in list(missing_input_techs - missing_converter_tech): + m0_upstream = nx.has_path(self.technology_graph, m0, m1) + m1_upstream = nx.has_path(self.technology_graph, m1, m0) + if not m0_upstream or m1_upstream: + warnings.warn("these technologies arent connected", UserWarning, stacklevel=3) + successful = False + return successful + + def _find_demand_tech_group(self, converters, converter_upstreams): + found_input_techs = self.dict_values_to_flat_list(converter_upstreams) + missing_input_techs = set(self.input_techs) - set(found_input_techs) + + converter_tech_names = {v[1] for v in list(converters)} + + successful = self._check_demand_tech_group_connections( + converter_tech_names, missing_input_techs + ) + if not successful: + msg = "A bug may exist. Please refer to earlier warnings" + warnings.warn(msg, UserWarning, stacklevel=3) + + input_comps = {self.demand_tech} - missing_input_techs + missing_techs_to_downstreams = { + tech: list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs + } + missing_tech_downstreams = self.dict_values_to_flat_list(missing_techs_to_downstreams) + non_input_components = set(missing_tech_downstreams) - input_comps + + unique_number = int(len(converter_upstreams) + 1) + group_name = f"{self.commodity}-{int(unique_number)}" + + missing_converter_tech = missing_input_techs & converter_tech_names + + # all techs in group, include non-controllable ones (like combiners) + all_techs_in_group = list(non_input_components | missing_input_techs) + # input techs in group except for the converter + non_converter_input_techs_in_group = list(missing_input_techs - missing_converter_tech) + demand_group = {group_name: all_techs_in_group} + + return non_converter_input_techs_in_group, demand_group + + def _find_group_for_non_input_techs(self, grouped_techs): + # Get the nodes of the technology graph that aren't a controllable technology + def get_group_for_tech(tech_name): + group = [grp for grp, techs in grouped_techs.items() if tech_name in techs] + if len(group) == 0: + msg = f"Cannot find simplified group for technology {tech_name}" + raise ValueError(msg) + return group[0] + + techs_to_groups = {} + conversion_factor_keys = [] + + non_input_techs = ( + set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} + ) + # Also add these technologies to the reversed_group_techs + + for non_t in list(non_input_techs): + up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs + down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs + + commod = None + if up_techs: + for t in list(up_techs): + commod = self.technology_graph.edges[t, non_t].get("commodity", None) + if commod is not None: + # Add these technologies to the reversed_group_techs + techs_to_groups[non_t] = get_group_for_tech(t) + break + + if down_techs and commod is None: + for t in list(down_techs): + commod = self.technology_graph.edges[non_t, t].get("commodity", None) + if commod is not None: + # Add these technologies to the reversed_group_techs + techs_to_groups[non_t] = get_group_for_tech(t) + break + # Add conversion factors of 1 for the technologies that are non_input_techs + conversion_factor_keys.append((commod, non_t, commod)) + return conversion_factor_keys, techs_to_groups From 1e06c2c73f94eb2898e37a7f73e53e026df15e0f Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:08:10 -0600 Subject: [PATCH 25/69] major bugfix in find_converter_techs - may break example --- .../system_level/demand_following_control.py | 26 ++++- .../system_level/system_level_control_base.py | 105 +++++++++++++++++- 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 2685ec2af..7a55debae 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -52,6 +52,7 @@ def setup(self): self.post_setup_multi_commodity() def post_setup_multi_commodity(self): + # TODO: move this method to SLC base class if not self.multi_commodity_system: return converters, converter_upstreams = self._find_converter_techs(include_feedstock_sources=True) @@ -120,14 +121,15 @@ def post_setup_multi_commodity(self): simple_graph.add_edge(s, d, commodity=c) self.simple_graph = simple_graph - self.non_converter_conversion_factor_keys = conversion_factor_keys - self.grouped_techs = grouped_techs self.converters = converters self.converter_upstreams = converter_upstreams self.converter_tech_names = converter_tech_names + conversion_recipes = self._make_conversion_factor_recipes() + self.conversion_recipes = conversion_recipes + def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None ): @@ -287,6 +289,15 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand + def new_compute(self, inputs, outputs): + if not self.multi_commodity_system: + self.get_setpoints_for_commodity_subset( + inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() + ) + return + + self.get_conversion_factors(self.converters, self.converter_upstreams, inputs) + def compute(self, inputs, outputs): if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( @@ -294,6 +305,8 @@ def compute(self, inputs, outputs): ) return + self.new_compute(inputs, outputs) + converter_conversion_factors = self.get_conversion_factors( self.converters, self.converter_upstreams, inputs ) @@ -313,6 +326,8 @@ def compute(self, inputs, outputs): # 6. Get the compounding conversion factors in_degs = dict(self.simple_graph.in_degree) starting_techs = {k for k, v in in_degs.items() if v == 0} + + compounding_conversion_factor_recipes = {} grouped_techs_compounding_conversion_factors = {} for starting_tech in list(starting_techs): paths = list(nx.all_simple_paths(self.simple_graph, starting_tech, self.demand_tech)) @@ -338,6 +353,7 @@ def compute(self, inputs, outputs): path_conversion = ( 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) ) + path_recipe = [] for edge in commodity_edges: # in_cmod is demand of next tech @@ -349,18 +365,24 @@ def compute(self, inputs, outputs): if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) ) + recipe = [] for t in techs_in_group: if t in self.converter_tech_names: conversion *= conversion_factors[(in_cmod, t, out_cmod)] + recipe.append((in_cmod, t, out_cmod)) else: conversion *= conversion_factors[(out_cmod, t, out_cmod)] + recipe.append((out_cmod, t, out_cmod)) # TODO: add check if any other non-converter techs have a non-1 conversion factor else: conversion = conversion_factors[(in_cmod, tech, out_cmod)] + recipe = [(in_cmod, tech, out_cmod)] path_conversion *= conversion + path_recipe.append(recipe) grouped_techs_compounding_conversion_factors[(out_cmod, in_cmod, tech)] = ( path_conversion ) + compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = path_recipe compounding_conversion_factors = self.convert_combined_conversion_factors_to_tech_demand( self.grouped_techs, diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index ab2af7f8d..746c286c9 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,4 +1,5 @@ import warnings +import itertools import numpy as np import networkx as nx @@ -813,9 +814,15 @@ def _find_converter_techs(self, include_feedstock_sources=True): # and the values as upstream technologies that produce ``input_commodity`` and are # connected to the converter tech (does not include converters in upstream technologies) converter_ancestors = {} - list(self.technology_graph.nodes()) + node_order = list(self.technology_graph.nodes()) edges = list(self.technology_graph.edges(data="commodity")) ii = 0 + + # NOTE: using tracked_ancestors would make this code very senstive to the order of + # tech connections + # I.e., would get different results if feedstocks connected to haber_bosch first + # tracked_ancestors = set() + # tracked_converters = set() # Track the most recently discovered converter so we can scope # upstream searches for chained converters (A→B→C where B and C # both convert). Without this, C would see A's commodity as upstream @@ -836,10 +843,10 @@ def _find_converter_techs(self, include_feedstock_sources=True): # Only consider ancestors that appear after the last converter # in topological order, preventing double-counting across # chained converters. - # converter_idx = node_order.index(last_converter) - # nodes_after_converter = set(node_order[converter_idx + 1 :]) - nodes_after_converter = nx.descendants(self.technology_graph, last_converter) + converter_idx = node_order.index(last_converter) + nodes_after_converter = set(node_order[converter_idx + 1 :]) ancestors = all_ancestors & nodes_after_converter + else: ancestors = all_ancestors @@ -859,9 +866,10 @@ def _find_converter_techs(self, include_feedstock_sources=True): produced = output_commodities - input_commodities # If both sides have unique commodities, this tech is a converter - if consumed and produced: + if consumed and produced and (source_tech not in self.storage_techs): for in_comm in consumed: for out_comm in produced: + # if in_comm != out_comm: converter_techs.add((in_comm, source_tech, out_comm)) converter_order[ii] = (in_comm, source_tech, out_comm) converter_ancestors[ii] = [ @@ -869,8 +877,11 @@ def _find_converter_techs(self, include_feedstock_sources=True): for e in self.techs_to_commodities if e[1] == in_comm and e[0] in connected_ancestors ] + # for tracked_a in converter_ancestors[ii]: + # tracked_ancestors.add(tracked_a) ii += 1 last_converter = source_tech + # tracked_converters.add(source_tech) if len(converter_techs) < len(converter_order): # remove duplicate converter orders @@ -894,6 +905,8 @@ def _find_converter_techs(self, include_feedstock_sources=True): # NOTE: unsure how the below logic will work with splitters for converter_ii in converter_cnt: input_cmod, tech, output_cmod = converter_order[converter_ii] + if input_cmod == output_cmod: + continue # Get all the upstream technologies that produce a specific commodity upstream1 = self.get_upstream_techs_for_commodity( tech, input_cmod, include_feedstock_sources=True @@ -938,6 +951,7 @@ def get_converter_conversion_ratio( float | np.ndarray: conversion ratio of `in_cmod/out_cmod`. If return_avg is True, then returns a scalar, otherwise returns an array """ + # TODO: update so that return_avg is not an input and thats done externally input_name_fmt = "{tech}_{commod}_out" in_names = [input_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] # used_inputs = [n for n in in_names if n in inputs] @@ -1043,7 +1057,7 @@ def _find_demand_tech_group(self, converters, converter_upstreams): all_techs_in_group = list(non_input_components | missing_input_techs) # input techs in group except for the converter non_converter_input_techs_in_group = list(missing_input_techs - missing_converter_tech) - demand_group = {group_name: all_techs_in_group} + demand_group = {group_name: set(all_techs_in_group)} return non_converter_input_techs_in_group, demand_group @@ -1087,3 +1101,82 @@ def get_group_for_tech(tech_name): # Add conversion factors of 1 for the technologies that are non_input_techs conversion_factor_keys.append((commod, non_t, commod)) return conversion_factor_keys, techs_to_groups + + def _make_conversion_factor_recipes(self): + if not self.multi_commodity_system: + return {} + + converter_tech_names = {v[1] for v in list(self.converters)} + + # 6. Get the compounding conversion factors + in_degs = dict(self.simple_graph.in_degree) + starting_techs = {k for k, v in in_degs.items() if v == 0} + + compounding_conversion_factor_recipes = {} + for starting_tech in list(starting_techs): + paths = list(nx.all_simple_paths(self.simple_graph, starting_tech, self.demand_tech)) + + if len(paths) > 1: + warnings.warn("There should only be one path", UserWarning, stacklevel=3) + path = paths[0] + reverse_path = path[::-1] + commodity_conversions = [ + self.simple_graph.edges[p0, p1].get("commodity", None) + for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) + ] + commodity_nodes = list(itertools.pairwise(commodity_conversions)) + techs = reverse_path[1:] + + commodity_graph = nx.DiGraph() # nodes are commodities + for i, commod_node in enumerate(commodity_nodes): + # ammonia, hydrogen + down_cmod, up_cmod = commod_node + commodity_graph.add_edge(down_cmod, up_cmod, tech=techs[i]) + + commodity_edges = commodity_graph.edges(data="tech") + + path_recipe = [] + + for edge in commodity_edges: + # in_cmod is demand of next tech + out_cmod, in_cmod, tech = edge + if tech in self.grouped_techs: + techs_in_group = list(self.grouped_techs[tech]) + + recipe = [] + for t in techs_in_group: + if t in converter_tech_names: + recipe.append((in_cmod, t, out_cmod)) + else: + recipe.append((out_cmod, t, out_cmod)) + # TODO: add check if any other non-converter techs have a non-1 conversion factor + else: + recipe = [(in_cmod, tech, out_cmod)] + path_recipe.append(recipe) + compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = path_recipe + + return compounding_conversion_factor_recipes + + def _get_techs_for_conversion(self, input_cmod, tech): + tech_to_demand = [ + s + for s in list(self.simple_graph.predecessors(tech)) + if self.simple_graph.edges[s, tech].get("commodity", "") == input_cmod + ] + if len(tech_to_demand) != 1: + raise ValueError("Unexpected situation!") + if tech_to_demand[0] in self.grouped_techs: + return list(self.grouped_techs[tech_to_demand[0]]) + return tech_to_demand[0] + + # def _get_conversion_from_recipe(self, conversion_factors, recipe): + # tech_to_demand = [ + # s + # for s in list(self.simple_graph.predecessors(tech)) + # if self.simple_graph.edges[s, tech].get("commodity", "") == input_cmod + # ] + # if len(tech_to_demand) != 1: + # raise ValueError("Unexpected situation!") + # if tech_to_demand[0] in self.grouped_techs: + # return list(self.grouped_techs[tech_to_demand[0]]) + # return tech_to_demand[0] From e8f87dd08560fbb152d78198b71e8585f61c1294 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:33:35 -0600 Subject: [PATCH 26/69] bugfix in _find_group_for_non_input_techs so simple_graph should be made correctly --- .../system_level/system_level_control_base.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 746c286c9..d4450d1fb 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1066,8 +1066,9 @@ def _find_group_for_non_input_techs(self, grouped_techs): def get_group_for_tech(tech_name): group = [grp for grp, techs in grouped_techs.items() if tech_name in techs] if len(group) == 0: - msg = f"Cannot find simplified group for technology {tech_name}" - raise ValueError(msg) + return None + # msg = f"Cannot find simplified group for technology {tech_name}" + # raise ValueError(msg) return group[0] techs_to_groups = {} @@ -1082,8 +1083,9 @@ def get_group_for_tech(tech_name): up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs + tech_group = get_group_for_tech(non_t) commod = None - if up_techs: + if up_techs and (tech_group is None): for t in list(up_techs): commod = self.technology_graph.edges[t, non_t].get("commodity", None) if commod is not None: @@ -1091,15 +1093,17 @@ def get_group_for_tech(tech_name): techs_to_groups[non_t] = get_group_for_tech(t) break - if down_techs and commod is None: + if down_techs and (commod is None) and (tech_group is None): for t in list(down_techs): commod = self.technology_graph.edges[non_t, t].get("commodity", None) if commod is not None: # Add these technologies to the reversed_group_techs techs_to_groups[non_t] = get_group_for_tech(t) + # techs_to_groups[t] = get_group_for_tech(t) break # Add conversion factors of 1 for the technologies that are non_input_techs - conversion_factor_keys.append((commod, non_t, commod)) + if tech_group is None: + conversion_factor_keys.append((commod, non_t, commod)) return conversion_factor_keys, techs_to_groups def _make_conversion_factor_recipes(self): From cc6e6b3b27d49284946fae13146022506b69efde Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:39:56 -0600 Subject: [PATCH 27/69] notes and fixes to demand following --- .../system_level/demand_following_control.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 7a55debae..8ce33ab37 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -120,6 +120,9 @@ def post_setup_multi_commodity(self): if s != d: simple_graph.add_edge(s, d, commodity=c) + # ESG TODO: check that simple_graph has the expected edges + # ---> I think its somewhat working now + self.simple_graph = simple_graph self.non_converter_conversion_factor_keys = conversion_factor_keys self.grouped_techs = grouped_techs @@ -128,6 +131,11 @@ def post_setup_multi_commodity(self): self.converter_tech_names = converter_tech_names conversion_recipes = self._make_conversion_factor_recipes() + + # ESG TODO: check that conversion_recipes is as expected + # The two below recipes are the same? Still need to debug the conversion recipes + # ('ammonia', 'hydrogen', 'ammonia-5') + # ('hydrogen', 'electricity', 'hydrogen-1') self.conversion_recipes = conversion_recipes def get_setpoints_for_commodity_subset( From 9cec8d22b651b9713dbeda276a4799ccd45d6765 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:01:23 -0600 Subject: [PATCH 28/69] updated and fixed and cleaned conversion ratio stuff --- .../system_level/demand_following_control.py | 56 +++++++++++++-- .../system_level/system_level_control_base.py | 69 ++++++++++++++++++- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 8ce33ab37..130bad813 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -297,23 +297,71 @@ def convert_combined_conversion_factors_to_tech_demand( return result return tech_groups_demand - def new_compute(self, inputs, outputs): + def compute(self, inputs, outputs): if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() ) return - self.get_conversion_factors(self.converters, self.converter_upstreams, inputs) + converter_conversion_factors = self.get_conversion_factors( + self.converters, self.converter_upstreams, inputs + ) - def compute(self, inputs, outputs): + conversion_factor_of_1 = ( + 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) + ) + + non_converter_conversion_factors = dict( + zip( + self.non_converter_conversion_factor_keys, + [conversion_factor_of_1] * len(self.non_converter_conversion_factor_keys), + ) + ) + conversion_factors = non_converter_conversion_factors | converter_conversion_factors + + self.tech_demands_set = [] + + demand_techs = self.converter_upstreams[(self.commodity, self.demand_tech)] + + outputs = self.get_setpoints_for_commodity_subset( + inputs, + outputs, + self.commodity, + inputs[self.demand_input_name].copy(), + tech_subset=demand_techs, + ) + + conversion_factors_tracker = {} + for recipe_name, recipe in self.conversion_recipes.items(): + commodity_to_demand = recipe_name[1] + techs_to_demand = self._get_techs_to_demand_from_recipe(recipe_name) + conversion_factor = self._get_conversion_from_recipe(conversion_factors, recipe) + demand = inputs[self.demand_input_name].copy() * conversion_factor + outputs = self.get_setpoints_for_commodity_subset( + inputs, + outputs, + commodity_to_demand, + demand, + tech_subset=techs_to_demand, + ) + conversion_factors_tracker[recipe_name] = conversion_factor + unset_techs_cmods = self.techs_to_commodities - set(self.tech_demands_set) + unset_techs = [k for k in list(unset_techs_cmods) if k[0] not in self.feedstock_comps] + if unset_techs: + warnings.warn( + f"Commands not set for these technologies: {unset_techs}", UserWarning, stacklevel=3 + ) + + def old_compute(self, inputs, outputs): + # TODO: remove if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() ) return - self.new_compute(inputs, outputs) + # self.new_compute(inputs, outputs) converter_conversion_factors = self.get_conversion_factors( self.converters, self.converter_upstreams, inputs diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index d4450d1fb..2067938d5 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -926,11 +926,24 @@ def get_converter_capacity_conversion_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors ): rated_name_fmt = "{tech}_rated_{commod}_production" + feedstock_name_fmt = "{tech}_{commod}_out" in_names = [rated_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] + in_feedstock_names = [ + feedstock_name_fmt.format(tech=t, commod=in_cmod) + for t in list(tech_ancestors) + if t in self.feedstock_comps + ] + total_in_cmod_capac = [inputs[n] for n in in_names if n in inputs] + avg_feedstock_capac = [inputs[n].mean() for n in in_feedstock_names if n in inputs] + total_input_capac = np.array(total_in_cmod_capac).sum() + total_feedstock_capac = np.array(avg_feedstock_capac).sum() + + total_commodity_in_capacity = total_input_capac + total_feedstock_capac + total_output_capac = inputs[rated_name_fmt.format(tech=converter_tech, commod=out_cmod)] - return total_input_capac / total_output_capac[0] + return total_commodity_in_capacity / total_output_capac[0] def get_converter_conversion_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors, return_avg=True @@ -1117,6 +1130,7 @@ def _make_conversion_factor_recipes(self): starting_techs = {k for k, v in in_degs.items() if v == 0} compounding_conversion_factor_recipes = {} + for starting_tech in list(starting_techs): paths = list(nx.all_simple_paths(self.simple_graph, starting_tech, self.demand_tech)) @@ -1142,6 +1156,7 @@ def _make_conversion_factor_recipes(self): path_recipe = [] for edge in commodity_edges: + # edge_recipe = [] # in_cmod is demand of next tech out_cmod, in_cmod, tech = edge if tech in self.grouped_techs: @@ -1156,8 +1171,11 @@ def _make_conversion_factor_recipes(self): # TODO: add check if any other non-converter techs have a non-1 conversion factor else: recipe = [(in_cmod, tech, out_cmod)] + # edge_recipe.append(recipe) path_recipe.append(recipe) - compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = path_recipe + compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = ( + path_recipe.copy() + ) return compounding_conversion_factor_recipes @@ -1173,6 +1191,53 @@ def _get_techs_for_conversion(self, input_cmod, tech): return list(self.grouped_techs[tech_to_demand[0]]) return tech_to_demand[0] + def _get_techs_to_demand_from_recipe(self, recipe_name): + output_cmod, input_cmod, tech_group = recipe_name + techs_to_demand = [ + s + for s in list(self.simple_graph.predecessors(tech_group)) + if self.simple_graph.edges[s, tech_group].get("commodity", "") == input_cmod + ] + if len(techs_to_demand) != 1: + raise ValueError("Unexpected situation!") + if techs_to_demand[0] in self.grouped_techs: + techs_in_group = list(self.grouped_techs[techs_to_demand[0]]) + else: + techs_in_group = techs_to_demand[0] + return techs_in_group + + def _get_conversion_from_recipe(self, conversion_factors, recipe): + # Get comp + path_conversion = 1.0 + + for path in recipe: + for tech_conversion in path: + path_conversion *= conversion_factors.get(tech_conversion, 1.0) + + return path_conversion + + # for edge in recipe: + # # in_cmod is demanded from techs upstream of tech_group + + # if tech in self.grouped_techs: + # techs_in_group = list(self.grouped_techs[tech]) + # conversion = 1.0 + + # else: + # conversion = conversion_factors[(in_cmod, tech, out_cmod)] + + # conversion_factor = 1.0 + + # res = { + # "techs": list(self.grouped_techs[tech_to_demand[0]]), + # # "conversion factor": conv_fac, + # } + + # res = { + # "techs": tech_to_demand[0], + # # "conversion factor": conv_fac + # } + # def _get_conversion_from_recipe(self, conversion_factors, recipe): # tech_to_demand = [ # s From a9f31e48347b06f11a6de0b41065b5aa67a16602 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:37:02 -0600 Subject: [PATCH 29/69] updated get_conversion_factors logic to be more simple --- .../system_level/demand_following_control.py | 62 +++++++++---------- .../system_level/system_level_control_base.py | 7 ++- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 130bad813..70332d33e 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -133,14 +133,13 @@ def post_setup_multi_commodity(self): conversion_recipes = self._make_conversion_factor_recipes() # ESG TODO: check that conversion_recipes is as expected - # The two below recipes are the same? Still need to debug the conversion recipes - # ('ammonia', 'hydrogen', 'ammonia-5') - # ('hydrogen', 'electricity', 'hydrogen-1') + # ---> I think its somewhat working now self.conversion_recipes = conversion_recipes def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None ): + # TODO: rename this method if tech_subset is None: tech_subset = set(self.input_techs) @@ -213,39 +212,35 @@ def get_conversion_factors(self, converters, converter_upstreams, inputs): input_cmod, tech, output_cmod = converter_info tech_ancestors = converter_upstreams[(input_cmod, tech)] conversion_ratio = self.get_converter_conversion_ratio( - inputs, - input_cmod, - output_cmod, - tech, - list(tech_ancestors), - return_avg=self.config.use_average_conversion_factor, + inputs, input_cmod, output_cmod, tech, list(tech_ancestors) ) - if not self.config.use_average_conversion_factor: - if np.all(np.abs(conversion_ratio) == 0.0): - conversion_ratio_val = self.get_converter_capacity_conversion_ratio( - inputs, - input_cmod, - output_cmod, - tech, - list(tech_ancestors), - ) - conversion_ratio = np.full( - len(inputs[self.demand_input_name]), conversion_ratio_val - ) + + has_nan = np.isnan(conversion_ratio).any() + has_inf = np.isinf(conversion_ratio).any() + is_zero = np.all(conversion_ratio == 0.0) + if has_inf or has_nan or is_zero: + # not all values are finite + # has_nan = np.isnan(conversion_ratio).any() + # has_inf = np.isinf(conversion_ratio).any() + if is_zero: + bad_indices = list(np.arange(0, len(conversion_ratio), 1)) + else: + inf_indices = np.argwhere(~np.isfinite(conversion_ratio)).flatten() + nan_indices = np.argwhere(~np.isnan(conversion_ratio)).flatten() + bad_indices = list(set(inf_indices) | set(nan_indices)) + + capacity_ratio = self.get_converter_capacity_conversion_ratio( + inputs, + input_cmod, + output_cmod, + tech, + list(tech_ancestors), + ) + conversion_ratio[bad_indices] = capacity_ratio if self.config.use_average_conversion_factor: - if ( - (conversion_ratio == 0.0) - or np.isnan(conversion_ratio) - or np.isinf(conversion_ratio) - ): - conversion_ratio = self.get_converter_capacity_conversion_ratio( - inputs, - input_cmod, - output_cmod, - tech, - list(tech_ancestors), - ) + conversion_ratio = conversion_ratio.mean() + conversion_factors[converter_info] = conversion_ratio return conversion_factors @@ -256,6 +251,7 @@ def convert_combined_conversion_factors_to_tech_demand( grouped_techs_compounding_conversion_factors, use_simple_keynames=True, ): + # TODO: remove tech_groups_demand = {} run_with_complex_keynames = False for stuff, conv_fac in grouped_techs_compounding_conversion_factors.items(): diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 2067938d5..78280301a 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -946,7 +946,7 @@ def get_converter_capacity_conversion_ratio( return total_commodity_in_capacity / total_output_capac[0] def get_converter_conversion_ratio( - self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors, return_avg=True + self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors ): """Get conversion ratio of ``in_cmod/out_cmod`` for technology ``converter_tech`` @@ -973,8 +973,9 @@ def get_converter_conversion_ratio( total_output = inputs[input_name_fmt.format(tech=converter_tech, commod=out_cmod)] # Check if the converter produced any `out_cmod` # if total_output.sum() > 0: - conversion_factor = np.nan_to_num(total_input / np.abs(total_output)) - return conversion_factor.mean() if return_avg else conversion_factor + # conversion_factor = np.nan_to_num(total_input / np.abs(total_output)) + conversion_factor = total_input / np.abs(total_output) + return conversion_factor # conversion_factor.mean() if return_avg else conversion_factor def dict_values_to_flat_list(self, dictionary): flat_list = [] From 7aa3bb6772de6108af9b56706d067bd82e7b6d62 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:38:17 -0600 Subject: [PATCH 30/69] removed unused methods from cleanup in demand_following_control --- .../system_level/demand_following_control.py | 180 ------------------ 1 file changed, 180 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 70332d33e..1b8212a4e 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -1,5 +1,4 @@ import warnings -import itertools import numpy as np import networkx as nx @@ -220,8 +219,6 @@ def get_conversion_factors(self, converters, converter_upstreams, inputs): is_zero = np.all(conversion_ratio == 0.0) if has_inf or has_nan or is_zero: # not all values are finite - # has_nan = np.isnan(conversion_ratio).any() - # has_inf = np.isinf(conversion_ratio).any() if is_zero: bad_indices = list(np.arange(0, len(conversion_ratio), 1)) else: @@ -244,55 +241,6 @@ def get_conversion_factors(self, converters, converter_upstreams, inputs): conversion_factors[converter_info] = conversion_ratio return conversion_factors - def convert_combined_conversion_factors_to_tech_demand( - self, - grouped_techs, - simple_graph, - grouped_techs_compounding_conversion_factors, - use_simple_keynames=True, - ): - # TODO: remove - tech_groups_demand = {} - run_with_complex_keynames = False - for stuff, conv_fac in grouped_techs_compounding_conversion_factors.items(): - output_cmod, input_cmod, tech = stuff - tech_to_demand = [ - s - for s in list(simple_graph.predecessors(tech)) - if simple_graph.edges[s, tech].get("commodity", "") == input_cmod - ] - if len(tech_to_demand) != 1: - raise ValueError("Unexpected situation!") - # f"{input_cmod} demand for {tech_to_demand} so {tech} can make {output_cmod}" - if tech_to_demand[0] in grouped_techs: - res = { - "techs": list(grouped_techs[tech_to_demand[0]]), - "conversion factor": conv_fac, - } - else: - res = {"techs": tech_to_demand[0], "conversion factor": conv_fac} - # NOTE: could throw this in a function so that the keys are simple if needed - # below has complicated keys in case theres a more complex architecture - key = ( - (input_cmod, tech_to_demand[0]) - if use_simple_keynames - else (input_cmod, tech_to_demand[0], (tech, output_cmod)) - ) - - if use_simple_keynames and key in tech_groups_demand: - run_with_complex_keynames = True - break - tech_groups_demand[key] = res - if run_with_complex_keynames: - result = self.convert_combined_conversion_factors_to_tech_demand( - grouped_techs, - simple_graph, - grouped_techs_compounding_conversion_factors, - use_simple_keynames=False, - ) - return result - return tech_groups_demand - def compute(self, inputs, outputs): if not self.multi_commodity_system: self.get_setpoints_for_commodity_subset( @@ -348,131 +296,3 @@ def compute(self, inputs, outputs): warnings.warn( f"Commands not set for these technologies: {unset_techs}", UserWarning, stacklevel=3 ) - - def old_compute(self, inputs, outputs): - # TODO: remove - if not self.multi_commodity_system: - self.get_setpoints_for_commodity_subset( - inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() - ) - return - - # self.new_compute(inputs, outputs) - - converter_conversion_factors = self.get_conversion_factors( - self.converters, self.converter_upstreams, inputs - ) - - conversion_factor_of_1 = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - ) - - non_converter_conversion_factors = dict( - zip( - self.non_converter_conversion_factor_keys, - [conversion_factor_of_1] * len(self.non_converter_conversion_factor_keys), - ) - ) - conversion_factors = non_converter_conversion_factors | converter_conversion_factors - - # 6. Get the compounding conversion factors - in_degs = dict(self.simple_graph.in_degree) - starting_techs = {k for k, v in in_degs.items() if v == 0} - - compounding_conversion_factor_recipes = {} - grouped_techs_compounding_conversion_factors = {} - for starting_tech in list(starting_techs): - paths = list(nx.all_simple_paths(self.simple_graph, starting_tech, self.demand_tech)) - commodity_graph = nx.DiGraph() # nodes are commodities - - if len(paths) > 1: - warnings.warn("There should only be one path", UserWarning, stacklevel=3) - path = paths[0] - reverse_path = path[::-1] - - commodity_conversions = [ - self.simple_graph.edges[p0, p1].get("commodity", None) - for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) - ] - commodity_nodes = list(itertools.pairwise(commodity_conversions)) - techs = reverse_path[1:] - for i, commod_node in enumerate(commodity_nodes): - # ammonia, hydrogen - down_cmod, up_cmod = commod_node - commodity_graph.add_edge(down_cmod, up_cmod, tech=techs[i]) - - commodity_edges = commodity_graph.edges(data="tech") - path_conversion = ( - 1.0 if self.config.use_average_conversion_factor else np.ones(self.n_timesteps) - ) - path_recipe = [] - - for edge in commodity_edges: - # in_cmod is demand of next tech - out_cmod, in_cmod, tech = edge - if tech in self.grouped_techs: - techs_in_group = list(self.grouped_techs[tech]) - conversion = ( - 1.0 - if self.config.use_average_conversion_factor - else np.ones(self.n_timesteps) - ) - recipe = [] - for t in techs_in_group: - if t in self.converter_tech_names: - conversion *= conversion_factors[(in_cmod, t, out_cmod)] - recipe.append((in_cmod, t, out_cmod)) - else: - conversion *= conversion_factors[(out_cmod, t, out_cmod)] - recipe.append((out_cmod, t, out_cmod)) - # TODO: add check if any other non-converter techs have a non-1 conversion factor - else: - conversion = conversion_factors[(in_cmod, tech, out_cmod)] - recipe = [(in_cmod, tech, out_cmod)] - path_conversion *= conversion - path_recipe.append(recipe) - grouped_techs_compounding_conversion_factors[(out_cmod, in_cmod, tech)] = ( - path_conversion - ) - compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = path_recipe - - compounding_conversion_factors = self.convert_combined_conversion_factors_to_tech_demand( - self.grouped_techs, - self.simple_graph, - grouped_techs_compounding_conversion_factors, - use_simple_keynames=True, - ) - if any(len(k) > 2 for k in list(compounding_conversion_factors.keys())): - raise NotImplementedError("This type of system cannot be handled") - - self.tech_demands_set = [] - # Set demand for the techs in the "demand" group - demand_techs = self.converter_upstreams[(self.commodity, self.demand_tech)] - outputs = self.get_setpoints_for_commodity_subset( - inputs, - outputs, - self.commodity, - inputs[self.demand_input_name].copy(), - tech_subset=demand_techs, - ) - - for cmod_group, cf_techs in compounding_conversion_factors.items(): - commodity, _ = cmod_group - - inputs[self.demand_input_name] - commodity_demand = inputs[self.demand_input_name].copy() * cf_techs["conversion factor"] - - outputs = self.get_setpoints_for_commodity_subset( - inputs, - outputs, - commodity, - commodity_demand, - tech_subset=cf_techs["techs"], - ) - # NOTE: could add check to make sure everything was set - unset_techs_cmods = self.techs_to_commodities - set(self.tech_demands_set) - unset_techs = [k for k in list(unset_techs_cmods) if k[0] not in self.feedstock_comps] - if unset_techs: - warnings.warn( - f"Commands not set for these technologies: {unset_techs}", UserWarning, stacklevel=3 - ) From 32c85f798fa75de16697c3a00c1aa99fb495b437 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:40:44 -0600 Subject: [PATCH 31/69] added post_setup_multi_commodity to SLC baseclass --- .../system_level/demand_following_control.py | 88 ------------------- .../system_level/system_level_control_base.py | 86 ++++++++++++++++++ 2 files changed, 86 insertions(+), 88 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 1b8212a4e..1f1a4132a 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -1,7 +1,6 @@ import warnings import numpy as np -import networkx as nx from attrs import field, define from h2integrate.core.utilities import BaseConfig @@ -48,93 +47,6 @@ def setup(self): self.options["plant_config"]["system_level_control"].get("control_parameters", {}) ) - self.post_setup_multi_commodity() - - def post_setup_multi_commodity(self): - # TODO: move this method to SLC base class - if not self.multi_commodity_system: - return - converters, converter_upstreams = self._find_converter_techs(include_feedstock_sources=True) - # Group together technologies that are connected to a converter - # I.e., group together an electrolyzer an hydrogen storage, - # name this group as the shared commodity with a unique number - grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} - - # 3. Add in a conversion factor of 1 for all non-converter technologies - converter_tech_names = {v[1] for v in list(converters)} - - conversion_factor_keys = [ - (tc[1], tc[0], tc[1]) - for tc in self.techs_to_commodities - if tc[0] not in converter_tech_names - ] - # missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) - - # NOTE: maybe only run below if theres a missing_input_tech - non_converter_input_techs_in_group, demand_group = self._find_demand_tech_group( - converters, converter_upstreams - ) - grouped_techs.update(demand_group) - - conversion_factor_keys += [ - (self.commodity, k, self.commodity) for k in non_converter_input_techs_in_group - ] - - # Add demand component to converter_upstreams - demand_group_techs = list(demand_group.values())[0] - converter_upstreams[(self.commodity, self.demand_tech)] = ( - set(demand_group_techs) & self.input_techs - ) - - # 2. Make a dictionary for future-use that has keys of the technology names and - # the group they belong to - reversed_grouped_techs = {} - for k, v in grouped_techs.items(): - for vv in list(v): - reversed_grouped_techs[vv] = k - - # 4. Add conversion factors of 1 for the technologies that are non_input_techs - # Also add these technologies to the reversed_group_techs - - # Get the nodes of the technology graph that aren't a controllable technology - # Also add these technologies to the reversed_group_techs - - non_input_techs_conversion_factor_keys, techs_to_groups = ( - self._find_group_for_non_input_techs(grouped_techs) - ) - # Add conversion factors of 1 for the technologies that are non_input_techs - conversion_factor_keys += non_input_techs_conversion_factor_keys - reversed_grouped_techs.update( - techs_to_groups - ) # unsure why we're not updating grouped_techs - - # 5. Make the edges of the grouped technologies - simple_graph = nx.DiGraph() - for e in list(self.technology_graph.edges(data="commodity")): - s0, d0, c = e - - s = reversed_grouped_techs.get(s0, s0) - d = reversed_grouped_techs.get(d0, d0) - - if s != d: - simple_graph.add_edge(s, d, commodity=c) - - # ESG TODO: check that simple_graph has the expected edges - # ---> I think its somewhat working now - - self.simple_graph = simple_graph - self.non_converter_conversion_factor_keys = conversion_factor_keys - self.grouped_techs = grouped_techs - self.converters = converters - self.converter_upstreams = converter_upstreams - self.converter_tech_names = converter_tech_names - - conversion_recipes = self._make_conversion_factor_recipes() - - # ESG TODO: check that conversion_recipes is as expected - # ---> I think its somewhat working now - self.conversion_recipes = conversion_recipes - def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None ): diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 78280301a..1d7767531 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -127,6 +127,8 @@ def setup(self): self._setup_tech_category("storage", self.storage_techs) self._setup_feedstock_category(self.feedstock_comps) + self._post_setup_multi_commodity() + def _setup_commodity( self, tech_name, @@ -734,6 +736,90 @@ def _feedstock_marginal_cost(self, inputs, marginal_cost_data): return np.full(self.n_timesteps, marginal_cost_scalar) + def _post_setup_multi_commodity(self): + if not self.multi_commodity_system: + return + converters, converter_upstreams = self._find_converter_techs(include_feedstock_sources=True) + # Group together technologies that are connected to a converter + # I.e., group together an electrolyzer an hydrogen storage, + # name this group as the shared commodity with a unique number + grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} + + # 3. Add in a conversion factor of 1 for all non-converter technologies + converter_tech_names = {v[1] for v in list(converters)} + + conversion_factor_keys = [ + (tc[1], tc[0], tc[1]) + for tc in self.techs_to_commodities + if tc[0] not in converter_tech_names + ] + # missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) + + # NOTE: maybe only run below if theres a missing_input_tech + non_converter_input_techs_in_group, demand_group = self._find_demand_tech_group( + converters, converter_upstreams + ) + grouped_techs.update(demand_group) + + conversion_factor_keys += [ + (self.commodity, k, self.commodity) for k in non_converter_input_techs_in_group + ] + + # Add demand component to converter_upstreams + demand_group_techs = list(demand_group.values())[0] + converter_upstreams[(self.commodity, self.demand_tech)] = ( + set(demand_group_techs) & self.input_techs + ) + + # 2. Make a dictionary for future-use that has keys of the technology names and + # the group they belong to + reversed_grouped_techs = {} + for k, v in grouped_techs.items(): + for vv in list(v): + reversed_grouped_techs[vv] = k + + # 4. Add conversion factors of 1 for the technologies that are non_input_techs + # Also add these technologies to the reversed_group_techs + + # Get the nodes of the technology graph that aren't a controllable technology + # Also add these technologies to the reversed_group_techs + + non_input_techs_conversion_factor_keys, techs_to_groups = ( + self._find_group_for_non_input_techs(grouped_techs) + ) + # Add conversion factors of 1 for the technologies that are non_input_techs + conversion_factor_keys += non_input_techs_conversion_factor_keys + reversed_grouped_techs.update( + techs_to_groups + ) # unsure why we're not updating grouped_techs + + # 5. Make the edges of the grouped technologies + simple_graph = nx.DiGraph() + for e in list(self.technology_graph.edges(data="commodity")): + s0, d0, c = e + + s = reversed_grouped_techs.get(s0, s0) + d = reversed_grouped_techs.get(d0, d0) + + if s != d: + simple_graph.add_edge(s, d, commodity=c) + + # ESG TODO: check that simple_graph has the expected edges + # ---> I think its somewhat working now + + self.simple_graph = simple_graph + self.non_converter_conversion_factor_keys = conversion_factor_keys + self.grouped_techs = grouped_techs + self.converters = converters + self.converter_upstreams = converter_upstreams + self.converter_tech_names = converter_tech_names + + conversion_recipes = self._make_conversion_factor_recipes() + + # ESG TODO: check that conversion_recipes is as expected + # ---> I think its somewhat working now + self.conversion_recipes = conversion_recipes + def get_upstream_techs_for_commodity( self, tech_name: str, commodity: str, include_feedstock_sources=True ): From e54171f238164e5a5b26c6c3b78f65e003da82a3 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:59:55 -0600 Subject: [PATCH 32/69] cleaned up SLC base --- .../system_level/system_level_control_base.py | 44 +------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 1d7767531..82f715612 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -907,8 +907,6 @@ def _find_converter_techs(self, include_feedstock_sources=True): # NOTE: using tracked_ancestors would make this code very senstive to the order of # tech connections # I.e., would get different results if feedstocks connected to haber_bosch first - # tracked_ancestors = set() - # tracked_converters = set() # Track the most recently discovered converter so we can scope # upstream searches for chained converters (A→B→C where B and C # both convert). Without this, C would see A's commodity as upstream @@ -963,11 +961,9 @@ def _find_converter_techs(self, include_feedstock_sources=True): for e in self.techs_to_commodities if e[1] == in_comm and e[0] in connected_ancestors ] - # for tracked_a in converter_ancestors[ii]: - # tracked_ancestors.add(tracked_a) + ii += 1 last_converter = source_tech - # tracked_converters.add(source_tech) if len(converter_techs) < len(converter_order): # remove duplicate converter orders @@ -976,7 +972,6 @@ def _find_converter_techs(self, include_feedstock_sources=True): converter_order = {v: k for k, v in rev_converter_order.items()} # remove duplicate converter orders # re-reverse it - # converter_ancestors = {v: list(k) for k, v in rev_converter_ancestors.items()} converter_ancestors = {k: converter_ancestors[k] for k in list(converter_order.keys())} # Make sure we iterate through the converters in the right order @@ -1243,7 +1238,6 @@ def _make_conversion_factor_recipes(self): path_recipe = [] for edge in commodity_edges: - # edge_recipe = [] # in_cmod is demand of next tech out_cmod, in_cmod, tech = edge if tech in self.grouped_techs: @@ -1258,7 +1252,7 @@ def _make_conversion_factor_recipes(self): # TODO: add check if any other non-converter techs have a non-1 conversion factor else: recipe = [(in_cmod, tech, out_cmod)] - # edge_recipe.append(recipe) + path_recipe.append(recipe) compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = ( path_recipe.copy() @@ -1302,37 +1296,3 @@ def _get_conversion_from_recipe(self, conversion_factors, recipe): path_conversion *= conversion_factors.get(tech_conversion, 1.0) return path_conversion - - # for edge in recipe: - # # in_cmod is demanded from techs upstream of tech_group - - # if tech in self.grouped_techs: - # techs_in_group = list(self.grouped_techs[tech]) - # conversion = 1.0 - - # else: - # conversion = conversion_factors[(in_cmod, tech, out_cmod)] - - # conversion_factor = 1.0 - - # res = { - # "techs": list(self.grouped_techs[tech_to_demand[0]]), - # # "conversion factor": conv_fac, - # } - - # res = { - # "techs": tech_to_demand[0], - # # "conversion factor": conv_fac - # } - - # def _get_conversion_from_recipe(self, conversion_factors, recipe): - # tech_to_demand = [ - # s - # for s in list(self.simple_graph.predecessors(tech)) - # if self.simple_graph.edges[s, tech].get("commodity", "") == input_cmod - # ] - # if len(tech_to_demand) != 1: - # raise ValueError("Unexpected situation!") - # if tech_to_demand[0] in self.grouped_techs: - # return list(self.grouped_techs[tech_to_demand[0]]) - # return tech_to_demand[0] From bfcb1d5fe7bad4e2143f161a57e16052d5db2779 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:55:50 -0600 Subject: [PATCH 33/69] added tests for slc baseclass methods in complex multicommod --- .../system_level/test/test_slc_baseclass.py | 553 ++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py new file mode 100644 index 000000000..2fd5e2064 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -0,0 +1,553 @@ +import numpy as np +import pytest +import openmdao.api as om + +from h2integrate import EXAMPLE_DIR, H2IntegrateModel +from h2integrate.core.inputs.validation import load_tech_yaml, load_plant_yaml, load_driver_yaml +from h2integrate.control.control_strategies.system_level.system_level_control_base import ( + SystemLevelControlBase, +) + + +def make_tech_classifiers(tech_list): + fixed_techs = [] + flexible_techs = ["wind", "solar"] + dispatchable_techs = ["electrolyzer", "haber_bosch", "natural_gas_plant", "grid_buy"] + storage_techs = ["battery", "h2_storage", "nh3_storage"] + feedstock_techs = ["ng_feedstock", "n2_feedstock", "electricity_feedstock"] + classifiers = {k: "flexible" for k in flexible_techs} + classifiers |= {k: "dispatchable" for k in dispatchable_techs} + classifiers |= {k: "storage" for k in storage_techs} + classifiers |= {k: "feedstock" for k in feedstock_techs} + classifiers |= {k: "fixed" for k in fixed_techs} + + classifiers |= {k: "connector" for k in tech_list if "combiner" in k} + classifiers |= {k: "connector" for k in tech_list if "splitter" in k} + classifiers |= {k: "feedstock" for k in tech_list if "feedstock" in k} + classifiers |= {k: "demand" for k in tech_list if "demand" in k} + + classified_techs = list(set(tech_list) & set(classifiers)) + tech_control_classifiers = {k: classifiers[k] for k in classified_techs} + return tech_control_classifiers + + +def make_slc_topology(plant_config, tech_config): + model = object.__new__(H2IntegrateModel) + model.slc = True + # plant_config["system_level_control"].pop("demand_component") + model.plant_config = plant_config + + tech_control_classifiers = make_tech_classifiers(list(tech_config["technologies"])) + model.tech_control_classifiers = tech_control_classifiers + model.technology_config = tech_config + model.technology_graph = model.create_technology_graph( + plant_config.get("technology_interconnections", {}) + ) + slc_topology = model._classify_slc_technologies() + return slc_topology + + +def make_and_setup_slc_baseclass(plant_config, tech_config) -> SystemLevelControlBase: + slc_config = make_slc_topology(plant_config, tech_config) + slc = object.__new__(SystemLevelControlBase) + # run the start of setup() + slc.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] + slc.commodity = slc_config["demand_commodity"] + slc.commodity_rate_units = slc_config.get("demand_commodity_rate_units", None) + slc.demand_tech = slc_config["demand_tech"] + slc.storage_techs_to_control = slc_config.get("storage_techs_to_control", {}) + slc.technology_graph = slc_config["technology_graph"] + slc.fixed_techs = [k for k, v in slc_config["tech_control_classifiers"].items() if v == "fixed"] + slc.flexible_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "flexible" + ] + slc.dispatchable_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "dispatchable" + ] + slc.storage_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "storage" + ] + slc.feedstock_comps = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] + + slc.input_techs = set( + slc.fixed_techs + slc.flexible_techs + slc.dispatchable_techs + slc.storage_techs + ) + + slc.demand_input_name = f"{slc.commodity}_demand" + + slc.techs_to_commodities = slc_config["tech_to_commodity"] + + slc.multi_commodity_system = ( + True if len({e[-1] for e in slc.techs_to_commodities}) > 1 else False + ) + return slc + + +# Test methods in _post_setup_multi_commodity +# _find_converter_techs(include_feedstock_sources=True) +# _find_demand_tech_group() +# _find_group_for_non_input_techs +# _make_conversion_factor_recipes() + + +@pytest.mark.unit +def test_find_converter_techs_nh3_system(subtests): + # Test methods in _post_setup_multi_commodity + # _find_converter_techs(include_feedstock_sources=True) + # _find_demand_tech_group() + example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" + plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + slc = make_and_setup_slc_baseclass(plant_config, tech_config) + + # Test _find_converter_techs() + converters, converter_upstreams = slc._find_converter_techs(include_feedstock_sources=True) + + expected_converters = { + ("nitrogen", "haber_bosch", "ammonia"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia"), + ("electricity", "electrolyzer", "hydrogen"), + } + + expected_converter_upstreams = { + ("electricity", "electrolyzer"): {"solar", "battery", "wind"}, + ("hydrogen", "haber_bosch"): {"electrolyzer", "h2_storage"}, + ("electricity", "haber_bosch"): {"electricity_feedstock"}, + ("nitrogen", "haber_bosch"): {"n2_feedstock"}, + } + + with subtests.test("converters"): + assert converters == expected_converters + with subtests.test("converter_upstreams"): + assert converter_upstreams == expected_converter_upstreams + + # Test _find_demand_tech_group() + non_converter_input_techs_in_group, demand_group = slc._find_demand_tech_group( + converters, converter_upstreams + ) + + with subtests.test("non main techs in demand group"): + assert non_converter_input_techs_in_group == ["nh3_storage"] + + expected_demand_group = {"ammonia-5": {"nh3_combiner", "haber_bosch", "nh3_storage"}} + with subtests.test("demand_group"): + assert demand_group == expected_demand_group + + +@pytest.mark.unit +def test_multi_commodity_post_setup_nh3_system(subtests): + # Test methods in _post_setup_multi_commodity + # _find_converter_techs(include_feedstock_sources=True) + # _find_demand_tech_group() + # _find_group_for_non_input_techs + # _make_conversion_factor_recipes() + example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" + plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + slc_config = make_slc_topology(plant_config, tech_config) + + prob = om.Problem() + + feedstock_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] + feedstock_subsystem_names = [] + for fi, feedstock_tech in enumerate(feedstock_techs): + feedstock_commodity = [ + e[-1] for e in slc_config["tech_to_commodity"] if e[0] == feedstock_tech + ] + feedstock_comp = prob.model.add_subsystem(f"IVC{fi}", om.Group()) + feedstock_comp.add_subsystem( + "feedstock", + om.IndepVarComp( + name=f"{feedstock_tech}_{feedstock_commodity[0]}_out", + val=np.full(plant_config["plant"]["simulation"]["n_timesteps"], 1e9), + units="MMBtu/h", + ), + ) + + feedstock_subsystem_names.append( + f"IVC{fi}.feedstock.{feedstock_tech}_{feedstock_commodity[0]}_out" + ) + + slc = SystemLevelControlBase( + plant_config=plant_config, + tech_config=tech_config, + driver_config={}, + slc_topology=slc_config, + ) + prob.model.add_subsystem("slc", slc) + + for feedstock_name in feedstock_subsystem_names: + connection_destination = feedstock_name.split(".")[-1] + prob.model.connect(feedstock_name, f"slc.{connection_destination}") + + prob.setup() + + # Check converters + expected_converters = { + ("nitrogen", "haber_bosch", "ammonia"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia"), + ("electricity", "electrolyzer", "hydrogen"), + } + + converters = prob.model.slc.converters + + with subtests.test("converters"): + assert converters == expected_converters + + # Check converter_upstreams + expected_converter_upstreams = { + ("electricity", "electrolyzer"): {"solar", "battery", "wind"}, + ("hydrogen", "haber_bosch"): {"electrolyzer", "h2_storage"}, + ("electricity", "haber_bosch"): {"electricity_feedstock"}, + ("nitrogen", "haber_bosch"): {"n2_feedstock"}, + ("ammonia", "nh3_load_demand"): {"haber_bosch", "nh3_storage"}, + } + + converter_upstreams = prob.model.slc.converter_upstreams + with subtests.test("converter upstreams"): + assert converter_upstreams == expected_converter_upstreams + + # Check simple_graph + simple_graph = prob.model.slc.simple_graph + edges = list(simple_graph.edges(data="commodity")) + expected_edges = [ + ("electricity-0", "hydrogen-1", "electricity"), + ("hydrogen-1", "ammonia-5", "hydrogen"), + ("ammonia-5", "nh3_load_demand", "ammonia"), + ("nitrogen-3", "ammonia-5", "nitrogen"), + ("electricity-2", "ammonia-5", "electricity"), + ] + + with subtests.test("simple_graph edges"): + # assert not bool(set(edges) ^ set(expected_edges)) + assert set(edges) == set(expected_edges) + + # Check grouped_techs + grouped_techs = prob.model.slc.grouped_techs + expected_groups = [ + {"solar", "battery", "wind"}, + {"electrolyzer", "h2_storage"}, + {"electricity_feedstock"}, + {"n2_feedstock"}, + {"nh3_combiner", "haber_bosch", "nh3_storage"}, + ] + failed_groups = [] + for group, techs_in_group in grouped_techs.items(): + if not any(g == techs_in_group for g in expected_groups): + failed_groups.append(group) + with subtests.test("Grouped technologies is correct"): + assert len(failed_groups) == 0 + + # Check conversion_recipes + conversion_recipes_list = prob.model.slc.conversion_recipes + conversion_recipes = {} + for k, v in conversion_recipes_list.items(): + v_as_set = [set(vi) for vi in v] + conversion_recipes[k] = v_as_set + demand_group_general = [ + ("ammonia", "nh3_storage", "ammonia"), + ("ammonia", "nh3_combiner", "ammonia"), + ] + + n2_nh3_recipe = [("nitrogen", "haber_bosch", "ammonia"), *demand_group_general] + with subtests.test("Nitrogen to Ammonia Recipe"): + assert conversion_recipes[("ammonia", "nitrogen", "ammonia-5")] == [set(n2_nh3_recipe)] + + electricity_nh3_recipe = [("electricity", "haber_bosch", "ammonia"), *demand_group_general] + with subtests.test("Electricity to Ammonia Recipe"): + assert conversion_recipes[("ammonia", "electricity", "ammonia-5")] == [ + set(electricity_nh3_recipe) + ] + + h2_nh3_recipe = [("hydrogen", "haber_bosch", "ammonia"), *demand_group_general] + with subtests.test("Hydrogen to Ammonia Recipe"): + assert conversion_recipes[("ammonia", "hydrogen", "ammonia-5")] == [set(h2_nh3_recipe)] + + h2_elec_subrecipe = { + ("hydrogen", "h2_storage", "hydrogen"), + ("electricity", "electrolyzer", "hydrogen"), + } + h2_elec_recipe = [set(h2_nh3_recipe), h2_elec_subrecipe] + with subtests.test("Electricity for Hydrogen Recipe"): + assert conversion_recipes[("hydrogen", "electricity", "hydrogen-1")] == h2_elec_recipe + + with subtests.test("4 recipes"): + assert len(conversion_recipes) == 4 + + # Check non_converter_conversion_factor_keys + non_converter_keys = prob.model.slc.non_converter_conversion_factor_keys + non_converter_techs = [k[1] for k in non_converter_keys] + expected_non_converter_techs = [ + "nh3_storage", + "battery", + "n2_feedstock", + "wind", + "electricity_feedstock", + "solar", + "h2_storage", + "elec_combiner", + "combiner", + "h2_combiner", + ] + with subtests.test("Non converter techs"): + assert set(non_converter_techs) == set(expected_non_converter_techs) + with subtests.test("wind key"): + assert ("electricity", "wind", "electricity") in non_converter_keys + with subtests.test("n2_feedstock key"): + assert ("nitrogen", "n2_feedstock", "nitrogen") in non_converter_keys + with subtests.test("h2_combiner key"): + assert ("hydrogen", "h2_combiner", "hydrogen") in non_converter_keys + with subtests.test("nh3_storage key"): + assert ("ammonia", "nh3_storage", "ammonia") in non_converter_keys + + converter_tech_names = prob.model.slc.converter_tech_names + with subtests.test("Converter tech names"): + assert converter_tech_names == {"haber_bosch", "electrolyzer"} + + +# Test methods used by Demand Following +# `get_converter_capacity_conversion_ratio` +# `get_converter_conversion_ratio` +# `_get_conversion_from_recipe` +# `_get_techs_to_demand_from_recipe` + + +@pytest.mark.unit +def test_multi_commodity_conversion_factor_nh3_system(subtests): + # Test methods available in SLC baseclass that are not used directly within SLC baseclass + + # --- Same setup as ``test_multi_commodity_post_setup_nh3_system`` --- + + example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" + plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + slc_config = make_slc_topology(plant_config, tech_config) + + prob = om.Problem() + + feedstock_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] + feedstock_subsystem_names = [] + for fi, feedstock_tech in enumerate(feedstock_techs): + feedstock_commodity = [ + e[-1] for e in slc_config["tech_to_commodity"] if e[0] == feedstock_tech + ] + feedstock_comp = prob.model.add_subsystem(f"IVC{fi}", om.Group()) + feedstock_comp.add_subsystem( + "feedstock", + om.IndepVarComp( + name=f"{feedstock_tech}_{feedstock_commodity[0]}_out", + val=np.full(plant_config["plant"]["simulation"]["n_timesteps"], 1e9), + units="MMBtu/h", + ), + ) + + feedstock_subsystem_names.append( + f"IVC{fi}.feedstock.{feedstock_tech}_{feedstock_commodity[0]}_out" + ) + + slc = SystemLevelControlBase( + plant_config=plant_config, + tech_config=tech_config, + driver_config={}, + slc_topology=slc_config, + ) + prob.model.add_subsystem("slc", slc) + + for feedstock_name in feedstock_subsystem_names: + connection_destination = feedstock_name.split(".")[-1] + prob.model.connect(feedstock_name, f"slc.{connection_destination}") + + prob.setup() + # --------------------------- End of setup --------------------------- + h2_storage_profile = np.tile( + np.concatenate([np.arange(-5.0, 6.0, 1), np.arange(6.0, -5, -1)]), 399 + )[:8760] + fake_inputs = { + "wind_rated_electricity_production": np.array([30.0]), + "wind_electricity_out": np.full(8760, 15.0), + "solar_rated_electricity_production": np.array([25.0]), + "solar_electricity_out": np.full(8760, 20.0), + "battery_rated_electricity_production": np.array([16.0]), + "battery_electricity_out": np.zeros(8760), + "electrolyzer_rated_hydrogen_production": np.array([71.0]), + "electrolyzer_hydrogen_out": np.full(8760, 39.0), + "h2_storage_rated_hydrogen_production": np.array([14.0]), + "h2_storage_hydrogen_out": h2_storage_profile, + "haber_bosch_rated_ammonia_production": np.array([50.0]), + "haber_bosch_ammonia_out": np.full(8760, 40), + "nh3_storage_rated_ammonia_production": np.array([4.0]), + "nh3_storage_ammonia_out": np.tile(np.array([-1, 1]), 4380), + "n2_feedstock_nitrogen_out": np.full(8760, 2.5), + "electricity_feedstock_electricity_out": np.full(8760, 13.0), + } + + # Test `get_converter_capacity_conversion_ratio` and `get_converter_conversion_ratio` + # Electricity to hydrogen + elec_per_h2_ratio = prob.model.slc.get_converter_conversion_ratio( + fake_inputs, "electricity", "hydrogen", "electrolyzer", ["battery", "wind", "solar"] + ) + elec_per_h2_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + fake_inputs, "electricity", "hydrogen", "electrolyzer", ["battery", "wind", "solar"] + ) + elec_capac = 30.0 + 25.0 + 16.0 + elec_gen = 15.0 + 20.0 + with subtests.test("Electricity/Hydrogen conversion ratio"): + assert pytest.approx(elec_gen / 39.0, rel=1e-6) == elec_per_h2_ratio.mean() + with subtests.test("Electricity/Hydrogen capacity ratio"): + assert pytest.approx(elec_capac / 71.0, rel=1e-6) == elec_per_h2_capac_ratio + + # Hydrogen to ammonia + h2_per_nh3_ratio = prob.model.slc.get_converter_conversion_ratio( + fake_inputs, "hydrogen", "ammonia", "haber_bosch", ["electrolyzer", "h2_storage"] + ) + h2_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + fake_inputs, "hydrogen", "ammonia", "haber_bosch", ["electrolyzer", "h2_storage"] + ) + h2_capac = 71.0 + 14.0 + h2_gen = h2_storage_profile + np.full(8760, 39.0) + with subtests.test("Hydrogen/Ammonia conversion ratio"): + assert pytest.approx((h2_gen / 40).mean(), rel=1e-6) == h2_per_nh3_ratio.mean() + with subtests.test("Hydrogen/Ammonia capacity ratio"): + assert pytest.approx(h2_capac / 50.0, rel=1e-6) == h2_per_nh3_capac_ratio + + # Nitrogen to ammonia + n2_per_nh3_ratio = prob.model.slc.get_converter_conversion_ratio( + fake_inputs, "nitrogen", "ammonia", "haber_bosch", ["n2_feedstock"] + ) + n2_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + fake_inputs, "nitrogen", "ammonia", "haber_bosch", ["n2_feedstock"] + ) + with subtests.test("Nitrogen/Ammonia conversion ratio"): + assert pytest.approx(2.5 / 40, rel=1e-6) == n2_per_nh3_ratio.mean() + with subtests.test("Nitrogen/Ammonia capacity ratio"): + assert pytest.approx(2.5 / 50.0, rel=1e-6) == n2_per_nh3_capac_ratio + + # Electricity to ammonia + elec_per_nh3_ratio = prob.model.slc.get_converter_conversion_ratio( + fake_inputs, "electricity", "ammonia", "haber_bosch", ["electricity_feedstock"] + ) + elec_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + fake_inputs, "electricity", "ammonia", "haber_bosch", ["electricity_feedstock"] + ) + with subtests.test("Electricity/Ammonia conversion ratio"): + assert pytest.approx(13.0 / 40, rel=1e-6) == elec_per_nh3_ratio.mean() + with subtests.test("Electricity/Ammonia capacity ratio"): + assert pytest.approx(13.0 / 50.0, rel=1e-6) == elec_per_nh3_capac_ratio + + # Test `_get_conversion_from_recipe` and `_get_techs_to_demand_from_recipe` + conversion_factors = { + ("electricity", "electrolyzer", "hydrogen"): elec_gen / 39.0, + ("hydrogen", "haber_bosch", "ammonia"): (h2_gen / 40).mean(), + ("nitrogen", "haber_bosch", "ammonia"): 2.5 / 40, + ("electricity", "haber_bosch", "ammonia"): 13.0 / 40, + } + non_converter_keys = prob.model.slc.non_converter_conversion_factor_keys + non_converter_factor = 1.0 + non_converter_conversion_factors = dict( + zip(non_converter_keys, [non_converter_factor] * len(non_converter_keys)) + ) + all_conversion_factors = conversion_factors | non_converter_conversion_factors + + conversion_recipes = prob.model.slc.conversion_recipes + conversion_recipes[("ammonia", "nitrogen", "ammonia-5")] + conversion_recipes[("hydrogen", "electricity", "hydrogen-1")] + + # Nitrogen/Ammonia + n2_recipe_name = ("ammonia", "nitrogen", "ammonia-5") + with subtests.test("Nitrogen/Ammonia Conversion Factor"): + conversion_factor = prob.model.slc._get_conversion_from_recipe( + all_conversion_factors, conversion_recipes[n2_recipe_name] + ) + assert pytest.approx(2.5 / 40.0, rel=1e-6) == conversion_factor + with subtests.test("Nitrogen/Ammonia Techs"): + techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(n2_recipe_name) + assert ["n2_feedstock"] == techs_to_demand + + # Electricity/Ammonia + elec_recipe_name = ("ammonia", "electricity", "ammonia-5") + with subtests.test("Electricity/Ammonia Conversion Factor"): + conversion_factor = prob.model.slc._get_conversion_from_recipe( + all_conversion_factors, conversion_recipes[elec_recipe_name] + ) + assert pytest.approx(13.0 / 40.0, rel=1e-6) == conversion_factor + with subtests.test("Electricity/Ammonia Techs"): + techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(elec_recipe_name) + assert ["electricity_feedstock"] == techs_to_demand + + # Hydrogen/Ammonia + h2_recipe_name = ("ammonia", "hydrogen", "ammonia-5") + with subtests.test("Hydrogen/Ammonia Conversion Factor"): + conversion_factor = prob.model.slc._get_conversion_from_recipe( + all_conversion_factors, conversion_recipes[h2_recipe_name] + ) + assert pytest.approx((h2_gen / 40).mean(), rel=1e-6) == conversion_factor + + with subtests.test("Hydrogen/Ammonia Techs"): + techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(h2_recipe_name) + expected_techs = ["h2_storage", "electrolyzer"] + assert set(expected_techs) == set(techs_to_demand) + + # Electricity/Hydrogen/Ammonia + eh2_recipe_name = ("hydrogen", "electricity", "hydrogen-1") + with subtests.test("Electricity/Hydrogen/Ammonia Conversion Factor"): + conversion_factor = prob.model.slc._get_conversion_from_recipe( + all_conversion_factors, conversion_recipes[eh2_recipe_name] + ) + expected_conversion_factor = (h2_gen / 40).mean() * (elec_gen / 39.0) + assert pytest.approx(expected_conversion_factor, rel=1e-6) == conversion_factor + with subtests.test("Electricity/Hydrogen/Ammonia Techs"): + techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(eh2_recipe_name) + expected_techs = ["battery", "wind", "solar"] + assert set(expected_techs) == set(techs_to_demand) + + +@pytest.mark.unit +def test_slc_baseclass_complex_multicommodity_no_storage(subtests): + # TODO: finish this test? + # h2i = object.__new__(H2IntegrateModel) + # h2i.slc = True + + example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" + plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + driver_config = load_driver_yaml(example_folder / "driver_config.yaml") + + config_input = { + "plant_config": plant_config, + "technology_config": tech_config, + "driver_config": driver_config, + } + h2i = H2IntegrateModel(config_input) + + h2i.setup() + + slc = h2i.prob.model.plant.system_level_controller + + # Check converters + # Check converter_upstreams + # Check simple_graph + + # + # Check the grouped techs + expected_groups = [ + {"solar", "battery", "wind"}, + {"electrolyzer", "h2_storage"}, + {"electricity_feedstock"}, + {"n2_feedstock"}, + {"nh3_combiner", "haber_bosch", "nh3_storage"}, + ] + grouped_techs = slc.__getattribute__("grouped_techs") + failed_groups = [] + for group, techs_in_group in grouped_techs.items(): + if not any(g == techs_in_group for g in expected_groups): + failed_groups.append(group) + with subtests.test("Grouped technologies is correct"): + assert len(failed_groups) == 0 From 0c261d450d4c83518ed30e8955d04a05fd4eff31 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:11:27 -0600 Subject: [PATCH 34/69] updated new example to use pysam wind and updated test values for new example --- .../complex_multi_commodity/tech_config.yaml | 27 +++++++++++------- .../tech_config_v2.yaml | 17 +++++------ .../system_level/test/test_slc_examples.py | 28 +++++++++++-------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml index 207fb12bb..9c26c5058 100644 --- a/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/tech_config.yaml @@ -3,21 +3,28 @@ description: This hybrid plant produces ammonia technologies: wind: performance_model: - model: FlorisWindPlantPerformanceModel + model: PYSAMWindPlantPerformanceModel cost_model: model: ATBWindPlantCostModel model_inputs: performance_parameters: num_turbines: 148 # number of turbines in the farm - hub_height: 115.0 # turbine hub-height - operational_losses: 10.49 # percentage of non-wake losses - floris_wake_config: !include "floris_v4_default_template.yaml" #floris wake model file - floris_turbine_config: !include "floris_turbine_NREL_6MW_170.yaml" #turbine model file formatted for floris - resource_data_averaging_method: average #"weighted_average", "average" or "nearest" - operation_model: cosine-loss # turbine operation model - default_turbulence_intensity: 0.06 - enable_caching: true # whether to use cached results - cache_dir: cache # directory to save or load cached data + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: !include pysam_options_6MW.yaml + + # hub_height: 115.0 # turbine hub-height + # operational_losses: 10.49 # percentage of non-wake losses + # floris_wake_config: !include "floris_v4_default_template.yaml" #floris wake model file + # floris_turbine_config: !include "floris_turbine_NREL_6MW_170.yaml" #turbine model file formatted for floris + # resource_data_averaging_method: average #"weighted_average", "average" or "nearest" + # operation_model: cosine-loss # turbine operation model + # default_turbulence_intensity: 0.06 + # enable_caching: true # whether to use cached results + # cache_dir: cache # directory to save or load cached data layout: layout_mode: basicgrid layout_options: diff --git a/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml b/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml index 7251331f3..f985503c0 100644 --- a/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml +++ b/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml @@ -3,21 +3,18 @@ description: This hybrid plant produces ammonia technologies: wind: performance_model: - model: FlorisWindPlantPerformanceModel + model: PYSAMWindPlantPerformanceModel cost_model: model: ATBWindPlantCostModel model_inputs: performance_parameters: num_turbines: 148 # number of turbines in the farm - hub_height: 115.0 # turbine hub-height - operational_losses: 10.49 # percentage of non-wake losses - floris_wake_config: !include "floris_v4_default_template.yaml" #floris wake model file - floris_turbine_config: !include "floris_turbine_NREL_6MW_170.yaml" #turbine model file formatted for floris - resource_data_averaging_method: average #"weighted_average", "average" or "nearest" - operation_model: cosine-loss # turbine operation model - default_turbulence_intensity: 0.06 - enable_caching: true # whether to use cached results - cache_dir: cache # directory to save or load cached data + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: !include pysam_options_6MW.yaml layout: layout_mode: basicgrid layout_options: diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 33d4b2717..d0665c7fd 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -188,7 +188,7 @@ def test_slc_yes_hydrogen(subtests, temp_copy_of_example): with subtests.test("LCOH"): assert ( pytest.approx( - model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg"), rel=1e-6 + model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg")[0], rel=1e-6 ) == 14.878096642042243 ) @@ -415,31 +415,31 @@ def test_slc_complex_multi_commodity_v1(subtests): with subtests.test("LCOH"): assert ( - pytest.approx(3.8867863862476097, rel=1e-6) + pytest.approx(4.064419131023322, rel=1e-6) == h2i.model.get_val("finance_subgroup_h2.LCOH", units="USD/kg")[0] ) with subtests.test("LCOA - Produced"): assert ( - pytest.approx(1.2607467064967108, rel=1e-6) + pytest.approx(1.306352207437524, rel=1e-6) == h2i.model.get_val("finance_subgroup_nh3_produced.LCOA", units="USD/kg")[0] ) with subtests.test("LCOA - Delivered"): assert ( - pytest.approx(1.360845569863152, rel=1e-6) + pytest.approx(1.404495041524232, rel=1e-6) == h2i.model.get_val("finance_subgroup_nh3_delivered.LCOA", units="USD/kg")[0] ) with subtests.test("Unmet Ammonia Demand"): assert ( - pytest.approx(92862.44227354404, rel=1e-6) + pytest.approx(102882.4504724315, rel=1e-6) == h2i.model.get_val("nh3_load_demand.unmet_ammonia_demand_out", units="t/h").sum() ) with subtests.test("Ammonia Demand Capacity Factor"): assert ( - pytest.approx(77.68258710060003, rel=1e-6) + pytest.approx(75.27450203676736, rel=1e-6) == h2i.model.get_val("nh3_load_demand.capacity_factor", units="percent")[0] ) @@ -455,32 +455,36 @@ def test_slc_complex_multi_commodity_v2(subtests): h2i.run() with subtests.test("LCOH"): - assert pytest.approx(3.8867863862476097, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(4.064419131023322, rel=1e-6) == h2i.model.get_val( "finance_subgroup_h2.LCOH", units="USD/kg" ) with subtests.test("LCOA - Produced"): - assert pytest.approx(1.2607467064967108, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(1.306352207437524, rel=1e-6) == h2i.model.get_val( "finance_subgroup_nh3_produced.LCOA", units="USD/kg" ) with subtests.test("LCOA - Available"): - assert pytest.approx(1.2625680481267114, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(1.3082392786020187, rel=1e-6) == h2i.model.get_val( "finance_subgroup_ammonia_available.LCOA", units="USD/kg" ) with subtests.test("LCOA - Delivered"): - assert pytest.approx(1.3628115196257977, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(1.4065238834234126, rel=1e-6) == h2i.model.get_val( "finance_subgroup_nh3_delivered.LCOA", units="USD/kg" ) with subtests.test("Unmet Ammonia Demand"): assert ( - pytest.approx(92862.44227354404, rel=1e-6) + pytest.approx(102882.4504724315, rel=1e-6) == h2i.model.get_val("nh3_load_demand.unmet_ammonia_demand_out", units="t/h").sum() ) with subtests.test("Ammonia Demand Capacity Factor"): - assert pytest.approx(77.68258710060003, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(75.27450203676736, rel=1e-6) == h2i.model.get_val( "nh3_load_demand.capacity_factor", units="percent" ) + + # TODO: update logic to dispatch storage + with subtests.test("Ammonia Storage Command"): + assert np.all(h2i.model.get_val("nh3_storage.ammonia_command_value") == 0.0) From 4f47406caec799cf964317910020601c261876e5 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:37:47 -0600 Subject: [PATCH 35/69] updated logic in h2i to only give info about the upstream techs to the SLC --- .../system_level/system_level_control_base.py | 8 +++++++- .../system_level/test/test_slc_examples.py | 2 +- h2integrate/core/h2integrate_model.py | 11 ++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 82f715612..0b5cfaec6 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1068,6 +1068,8 @@ def dict_values_to_flat_list(self, dictionary): return flat_list def _check_demand_tech_group_connections(self, converter_tech_names, missing_input_techs): + # NOTE: sometimes these warnings happen because of the dependency on the + # tech connection order successful = True commodities_for_missing_techs = { tech: self._get_commodity_for_tech(tech) for tech in list(missing_input_techs) @@ -1119,7 +1121,11 @@ def _check_demand_tech_group_connections(self, converter_tech_names, missing_inp m0_upstream = nx.has_path(self.technology_graph, m0, m1) m1_upstream = nx.has_path(self.technology_graph, m1, m0) if not m0_upstream or m1_upstream: - warnings.warn("these technologies arent connected", UserWarning, stacklevel=3) + warnings.warn( + f"The technologies {m0} and {m1} aren't connected", + UserWarning, + stacklevel=3, + ) successful = False return successful diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index d0665c7fd..cde3042a9 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -190,7 +190,7 @@ def test_slc_yes_hydrogen(subtests, temp_copy_of_example): pytest.approx( model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg")[0], rel=1e-6 ) - == 14.878096642042243 + == 14.46645200483752 ) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 580739255..fcdb5b31c 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -581,9 +581,14 @@ def _classify_slc_technologies(self): upstream_tech_graph = self.create_technology_graph(upstream_interconnections) slc_topology["technology_graph"] = upstream_tech_graph + upstream_tech_control_classifiers = { + k: v + for k, v in self.tech_control_classifiers.items() + if k in upstream_controllable_techs + } # Check if storage models have a controller storage_tech_to_control = {} - for tech, classifier in self.tech_control_classifiers.items(): + for tech, classifier in upstream_tech_control_classifiers.items(): if classifier == "storage": control_model = ( self.technology_config["technologies"][tech] @@ -608,7 +613,7 @@ def _classify_slc_technologies(self): tech_to_commodity = { (e[0], e[-1]) for e in sources_to_commodities - if self.tech_control_classifiers[e[0]] in control_classifiers_to_connect + if upstream_tech_control_classifiers[e[0]] in control_classifiers_to_connect } slc_topology["tech_to_commodity"] = tech_to_commodity @@ -617,7 +622,7 @@ def _classify_slc_technologies(self): slc_topology["demand_commodity"] = all_params["commodity"] slc_topology["demand_commodity_rate_units"] = all_params.get("commodity_rate_units", None) - slc_topology["tech_control_classifiers"] = self.tech_control_classifiers + slc_topology["tech_control_classifiers"] = upstream_tech_control_classifiers return slc_topology From 74c79268815c2044287aa90a6a8e9c517a5d9503 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:02:28 -0600 Subject: [PATCH 36/69] added some docstrings to slc baseclass methods --- .../system_level/system_level_control_base.py | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 0b5cfaec6..e64972fb5 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1006,6 +1006,19 @@ def _find_converter_techs(self, include_feedstock_sources=True): def get_converter_capacity_conversion_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors ): + """Get capacity ratio of ``in_cmod/out_cmod`` for technology ``converter_tech`` + + Args: + inputs (dict): OpenMDAO inputs + in_cmod (str): commodity input to the ``converter_tech`` + out_cmod (str): commodity output from the ``converter_tech`` + converter_tech (str): name of the converter technologies + tech_ancestors (list[str] | set[str] | tuple[str]): upstream technologies + that produce ``in_cmod`` to the ``converter_tech`` + + Returns: + float | np.ndarray: capacity ratio of `in_cmod/out_cmod`. + """ rated_name_fmt = "{tech}_rated_{commod}_production" feedstock_name_fmt = "{tech}_{commod}_out" in_names = [rated_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] @@ -1038,27 +1051,28 @@ def get_converter_conversion_ratio( converter_tech (str): name of the converter technologies tech_ancestors (list[str] | set[str] | tuple[str]): upstream technologies that produce ``in_cmod`` to the ``converter_tech`` - return_avg (bool): if True, return the average conversion ratio over the timesries. - Otherwise, return the mean. Defaults to True. Returns: - float | np.ndarray: conversion ratio of `in_cmod/out_cmod`. If return_avg is True, - then returns a scalar, otherwise returns an array + np.ndarray: conversion ratio of `in_cmod/out_cmod`. """ - # TODO: update so that return_avg is not an input and thats done externally input_name_fmt = "{tech}_{commod}_out" in_names = [input_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] - # used_inputs = [n for n in in_names if n in inputs] total_in_cmod = [inputs[n] for n in in_names if n in inputs] total_input = np.array(total_in_cmod).sum(axis=0) total_output = inputs[input_name_fmt.format(tech=converter_tech, commod=out_cmod)] - # Check if the converter produced any `out_cmod` - # if total_output.sum() > 0: - # conversion_factor = np.nan_to_num(total_input / np.abs(total_output)) + conversion_factor = total_input / np.abs(total_output) - return conversion_factor # conversion_factor.mean() if return_avg else conversion_factor + return conversion_factor def dict_values_to_flat_list(self, dictionary): + """Aggregate all the values in a dictionary to a flattened list + + Args: + dictionary (dict): dictionary with values as either a list or set + + Returns: + list: flattened list of all the values in ``dictionary`` + """ flat_list = [] for v in dictionary.values(): if isinstance(v, set): @@ -1130,6 +1144,27 @@ def _check_demand_tech_group_connections(self, converter_tech_names, missing_inp return successful def _find_demand_tech_group(self, converters, converter_upstreams): + """Find the technologies that are connected to the demand converter + and the demand technology that produce the demanded commodity + + Args: + converters (tuple[str, str, str]): Set of tuples formatted as + ``(input_commodity, tech_name, output_commodity)`` tuples. + converter_upstreams (dict[tuple[str,str], set[str]]): Keys are set of + ``(input_commodity, tech_name)`` and the values are a set of + upstream technologies that output the `input_commodity` to `tech_name`. + + Returns: + 2-element tuple containing: + + - **non_converter_input_techs_in_group** (list[str]): List of + non-converter technologies that are connected to the demand technology + and produce the demanded commodity. Only includes technologies in + `self.input_techs` + - **demand_group** (dict[str, set[str]]): Key is the name of the demand group + and values are a set of all the technologies that are connected to the + demand technology and produce the demanded commodity. + """ found_input_techs = self.dict_values_to_flat_list(converter_upstreams) missing_input_techs = set(self.input_techs) - set(found_input_techs) @@ -1208,6 +1243,17 @@ def get_group_for_tech(tech_name): return conversion_factor_keys, techs_to_groups def _make_conversion_factor_recipes(self): + """Make recipes to for compounding conversion factor calculations. + + Returns: + dict[tuple(str,str,str), list[list[tuple]]]: recipes to calculate the + conversion ratio from the demand commodity to all upstream subsystems. + Keys are the recipe name, formatted as tuples of + `(output_commodity, input_commodity, converter_tech_group)`. + Values are embedded lists. Each list defines the technologies in a + step of the conversion. Each element of a list is a tuple formatted as + `(input_commodity, technology, output_commodity)` + """ if not self.multi_commodity_system: return {} @@ -1267,6 +1313,7 @@ def _make_conversion_factor_recipes(self): return compounding_conversion_factor_recipes def _get_techs_for_conversion(self, input_cmod, tech): + # TODO: remove this - I think its unused tech_to_demand = [ s for s in list(self.simple_graph.predecessors(tech)) @@ -1279,7 +1326,7 @@ def _get_techs_for_conversion(self, input_cmod, tech): return tech_to_demand[0] def _get_techs_to_demand_from_recipe(self, recipe_name): - output_cmod, input_cmod, tech_group = recipe_name + _, input_cmod, tech_group = recipe_name techs_to_demand = [ s for s in list(self.simple_graph.predecessors(tech_group)) From 57d16dbb174a069160c738d92b151c4847114d46 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:53:21 -0600 Subject: [PATCH 37/69] split new example into two folders --- .../run_complex_multicommod.py | 20 ---------------- .../nh3_with_storage/driver_config.yaml | 4 ++++ .../plant_config.yaml} | 0 .../nh3_with_storage/run_nh3_with_storage.py | 16 +++++++++++++ .../tech_config.yaml} | 0 .../top_level_config.yaml} | 0 .../system_level/test/test_slc_baseclass.py | 24 +++++++++---------- .../system_level/test/test_slc_examples.py | 6 ++--- 8 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 examples/35_system_level_control/nh3_with_storage/driver_config.yaml rename examples/35_system_level_control/{complex_multi_commodity/plant_config_v2.yaml => nh3_with_storage/plant_config.yaml} (100%) create mode 100644 examples/35_system_level_control/nh3_with_storage/run_nh3_with_storage.py rename examples/35_system_level_control/{complex_multi_commodity/tech_config_v2.yaml => nh3_with_storage/tech_config.yaml} (100%) rename examples/35_system_level_control/{complex_multi_commodity/top_level_config_v2.yaml => nh3_with_storage/top_level_config.yaml} (100%) diff --git a/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py index ad4489c86..af19119d8 100644 --- a/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py +++ b/examples/35_system_level_control/complex_multi_commodity/run_complex_multicommod.py @@ -8,29 +8,9 @@ ################################## # Create an H2I model with a fixed electricity load demand -# h2i = H2IntegrateModel("top_level_config.yaml") - -print("Starting V2 ...") -h2i = H2IntegrateModel("top_level_config_v2.yaml") - -h2i.setup() - -# Run the model -h2i.run() - -print("Ran V2 successfully!") - - -print("Starting V1 ...") h2i = H2IntegrateModel("top_level_config.yaml") h2i.setup() # Run the model h2i.run() - -print("Ran V1 successfully!") -# Post-process the results -# h2i.post_process() - -# TODO: make even more complex by adding in an ammonia storage and combiner that goes to the demand tech diff --git a/examples/35_system_level_control/nh3_with_storage/driver_config.yaml b/examples/35_system_level_control/nh3_with_storage/driver_config.yaml new file mode 100644 index 000000000..e6b823fec --- /dev/null +++ b/examples/35_system_level_control/nh3_with_storage/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a hybrid plant to match the first example in H2Integrate +general: + folder_output: outputs diff --git a/examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml b/examples/35_system_level_control/nh3_with_storage/plant_config.yaml similarity index 100% rename from examples/35_system_level_control/complex_multi_commodity/plant_config_v2.yaml rename to examples/35_system_level_control/nh3_with_storage/plant_config.yaml diff --git a/examples/35_system_level_control/nh3_with_storage/run_nh3_with_storage.py b/examples/35_system_level_control/nh3_with_storage/run_nh3_with_storage.py new file mode 100644 index 000000000..eb0e4940d --- /dev/null +++ b/examples/35_system_level_control/nh3_with_storage/run_nh3_with_storage.py @@ -0,0 +1,16 @@ +import os + +from h2integrate import EXAMPLE_DIR +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +os.chdir(EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage") + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("top_level_config.yaml") + +h2i.setup() + +# Run the model +h2i.run() diff --git a/examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml b/examples/35_system_level_control/nh3_with_storage/tech_config.yaml similarity index 100% rename from examples/35_system_level_control/complex_multi_commodity/tech_config_v2.yaml rename to examples/35_system_level_control/nh3_with_storage/tech_config.yaml diff --git a/examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml b/examples/35_system_level_control/nh3_with_storage/top_level_config.yaml similarity index 100% rename from examples/35_system_level_control/complex_multi_commodity/top_level_config_v2.yaml rename to examples/35_system_level_control/nh3_with_storage/top_level_config.yaml diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index 2fd5e2064..d8cad36a1 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -97,9 +97,9 @@ def test_find_converter_techs_nh3_system(subtests): # Test methods in _post_setup_multi_commodity # _find_converter_techs(include_feedstock_sources=True) # _find_demand_tech_group() - example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" - plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") - tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config.yaml") slc = make_and_setup_slc_baseclass(plant_config, tech_config) # Test _find_converter_techs() @@ -144,9 +144,9 @@ def test_multi_commodity_post_setup_nh3_system(subtests): # _find_demand_tech_group() # _find_group_for_non_input_techs # _make_conversion_factor_recipes() - example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" - plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") - tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config.yaml") slc_config = make_slc_topology(plant_config, tech_config) prob = om.Problem() @@ -324,9 +324,9 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): # --- Same setup as ``test_multi_commodity_post_setup_nh3_system`` --- - example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" - plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") - tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config.yaml") slc_config = make_slc_topology(plant_config, tech_config) prob = om.Problem() @@ -515,9 +515,9 @@ def test_slc_baseclass_complex_multicommodity_no_storage(subtests): # h2i = object.__new__(H2IntegrateModel) # h2i.slc = True - example_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" - plant_config = load_plant_yaml(example_folder / "plant_config_v2.yaml") - tech_config = load_tech_yaml(example_folder / "tech_config_v2.yaml") + example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config.yaml") driver_config = load_driver_yaml(example_folder / "driver_config.yaml") config_input = { diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index c3e12255b..c4d56bc70 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -450,10 +450,10 @@ def test_slc_complex_multi_commodity_v1(subtests): @pytest.mark.integration -def test_slc_complex_multi_commodity_v2(subtests): - ex_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" +def test_slc_complex_nh3_with_storage(subtests): + ex_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" os.chdir(ex_folder) - h2i = H2IntegrateModel(ex_folder / "top_level_config_v2.yaml") + h2i = H2IntegrateModel(ex_folder / "top_level_config.yaml") h2i.setup() From 2ad268df21b97aca62959d482cf8b7d0e42a3a33 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:00:21 -0600 Subject: [PATCH 38/69] removed old comments from slc baseclass --- .../system_level/system_level_control_base.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 48270f28c..f2cf6f91c 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -868,8 +868,9 @@ def _post_setup_multi_commodity(self): for vv in list(v): reversed_grouped_techs[vv] = k - # 4. Add conversion factors of 1 for the technologies that are non_input_techs - # Also add these technologies to the reversed_group_techs + # 4. Track the technologies that are non_input_techs so we know + # they don't have a converison factor. Also add these technologies + # to the reversed_group_techs # Get the nodes of the technology graph that aren't a controllable technology # Also add these technologies to the reversed_group_techs @@ -894,9 +895,6 @@ def _post_setup_multi_commodity(self): if s != d: simple_graph.add_edge(s, d, commodity=c) - # ESG TODO: check that simple_graph has the expected edges - # ---> I think its somewhat working now - self.simple_graph = simple_graph self.non_converter_conversion_factor_keys = conversion_factor_keys self.grouped_techs = grouped_techs @@ -906,8 +904,6 @@ def _post_setup_multi_commodity(self): conversion_recipes = self._make_conversion_factor_recipes() - # ESG TODO: check that conversion_recipes is as expected - # ---> I think its somewhat working now self.conversion_recipes = conversion_recipes def get_upstream_techs_for_commodity( From 7e520b8f464ec9e24b73912b0f10adbd5bbfbba6 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:45:29 -0600 Subject: [PATCH 39/69] updated create_technology_graph to allow for multiple commodities connected between two techs --- h2integrate/core/h2integrate_model.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 0f05a80ba..203ea1fa4 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -2182,7 +2182,24 @@ def create_technology_graph(self, tech_interconnections: list | set): source = connection[0] destination = connection[1] if len(connection) == 4: - technology_graph.add_edge(source, destination, commodity=connection[2]) + # Check for existing edge + if technology_graph.has_edge(source, destination): + if ( + connected_cmods := technology_graph.edges[source, destination].get( + "commodity" + ) + ) is not None: + if isinstance(connected_cmods, str): + all_cmods = {connected_cmods, connection[2]} + technology_graph.add_edge(source, destination, commodity=all_cmods) + + else: + connected_cmods.add(connection[2]) + technology_graph.add_edge( + source, destination, commodity=connected_cmods + ) + else: + technology_graph.add_edge(source, destination, commodity=connection[2]) else: technology_graph.add_edge(source, destination) From 36a4dee29d73598b67856d6065498283f47b3dfb Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:46:18 -0600 Subject: [PATCH 40/69] added draft of redone _find_converter_techs --- .../system_level/system_level_control_base.py | 37 +++++++++++++ .../system_level/test/test_slc_baseclass.py | 53 ++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index f2cf6f91c..9d398e05d 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -945,6 +945,43 @@ def get_upstream_techs_for_commodity( # Intersect with controller-managed techs return list(ancestors_with_commodity & input_techs) + def _find_converter_techs_vs(self): + in_flows = dict(self.technology_graph.in_degree) + out_flows = dict(self.technology_graph.out_degree) + + non_converter_techs = [ + k for k in list(self.technology_graph.nodes) if in_flows[k] < 1 or out_flows[k] < 1 + ] + likely_converter_techs = ( + set(self.technology_graph.nodes) - set(non_converter_techs) - set(self.storage_techs) + ) & set(self.input_techs) + + (set(self.input_techs) | set(self.feedstock_comps)) - likely_converter_techs + + {(e[0], e[-1]) for e in self.technology_graph.edges(data="commodity") if e[-1] is not None} + + converter_info = set() + for converter in list(likely_converter_techs): + # predecessors are upstream and directly connected to the converter + predecessor_techs = set(self.technology_graph.predecessors(converter)) + # succesor techs are directly downstream of the converter + successor_techs = set(self.technology_graph.successors(converter)) + input_commods = { + self.technology_graph.edges[upstream_tech, converter].get("commodity") + for upstream_tech in predecessor_techs + } + output_commods = { + self.technology_graph.edges[converter, downstream_tech].get("commodity") + for downstream_tech in list(successor_techs) + } + + consumed = input_commods - output_commods + produced = output_commods - input_commods + if consumed and produced: + for input_commod in input_commods: + for output_commod in input_commods: + converter_info.add((input_commod, converter, output_commod)) + def _find_converter_techs(self, include_feedstock_sources=True): """Identify technologies that transform one commodity into another. diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index d8cad36a1..eaba703ff 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -11,8 +11,8 @@ def make_tech_classifiers(tech_list): fixed_techs = [] - flexible_techs = ["wind", "solar"] - dispatchable_techs = ["electrolyzer", "haber_bosch", "natural_gas_plant", "grid_buy"] + flexible_techs = ["wind", "solar", "boat", "desalination"] + dispatchable_techs = ["electrolyzer", "haber_bosch", "natural_gas_plant", "grid_buy", "grid"] storage_techs = ["battery", "h2_storage", "nh3_storage"] feedstock_techs = ["ng_feedstock", "n2_feedstock", "electricity_feedstock"] classifiers = {k: "flexible" for k in flexible_techs} @@ -92,6 +92,55 @@ def make_and_setup_slc_baseclass(plant_config, tech_config) -> SystemLevelContro # _make_conversion_factor_recipes() +@pytest.mark.unit +def test_find_converter_techs_fake_system(subtests): + # Test methods in _post_setup_multi_commodity + # _find_converter_techs(include_feedstock_sources=True) + # _find_demand_tech_group() + tech_connections = [ + ["boat", "desalination", "raw_water", ""], + ["desalination", "electrolyzer", "water", ""], + ["wind", "elec_combiner", "electricity", ""], + ["solar", "elec_combiner", "electricity", ""], + ["elec_combiner", "battery", "electricity", ""], + ["battery", "elec_combiner_2", "electricity", ""], + ["elec_combiner", "elec_combiner_2", "electricity", ""], + ["elec_combiner_2", "electrolyzer", "electricity", ""], + # ["desalination", "electrolyzer", "water", ""], + ["electrolyzer", "h2_storage", "hydrogen", ""], + ["electrolyzer", "h2_combiner", "hydrogen", ""], + ["electrolyzer", "haber_bosch", "oxygen", ""], + ["h2_storage", "h2_combiner", "hydrogen", ""], + ["h2_combiner", "haber_bosch", "hydrogen", ""], + ["grid", "haber_bosch", "electricity", ""], + ["n2_feedstock", "haber_bosch", "nitrogen", ""], + ["haber_bosch", "nh3_storage", "ammonia", ""], + ["haber_bosch", "nh3_combiner", "ammonia", ""], + ["nh3_storage", "nh3_combiner", "ammonia", ""], + ["nh3_combiner", "nh3_load_demand", "ammonia", ""], + ] + + example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config.yaml") + + plant_config["technology_interconnections"] = tech_connections + extra_tech_config_keys = { + k[0]: {} for k in tech_connections if k[0] not in tech_config["technologies"] + } + extra_tech_config_keys |= { + k[1]: {} for k in tech_connections if k[1] not in tech_config["technologies"] + } + tech_config_fake = tech_config["technologies"] | extra_tech_config_keys + + slc = make_and_setup_slc_baseclass(plant_config, {"technologies": tech_config_fake}) + + converters, converter_upstreams = slc._find_converter_techs(include_feedstock_sources=True) + + with subtests.test("converters is not right"): + assert True + + @pytest.mark.unit def test_find_converter_techs_nh3_system(subtests): # Test methods in _post_setup_multi_commodity From 495bab0f68a9f15a371da39c6655d4197e229d3c Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:49:16 -0600 Subject: [PATCH 41/69] updated SLC and h2i to handle more complex systems --- .../system_level/system_level_control_base.py | 72 +++++++++++++++---- h2integrate/core/h2integrate_model.py | 21 +++--- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 9d398e05d..1bc604448 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -945,7 +945,35 @@ def get_upstream_techs_for_commodity( # Intersect with controller-managed techs return list(ancestors_with_commodity & input_techs) - def _find_converter_techs_vs(self): + def get_successors_for_tech_with_input_cmod(self, tech, input_commodity): + in_flows = dict(self.technology_graph.in_degree) + if in_flows[tech] < 1: + # Tech does not have any input commodiites + return [] + + successor_techs_with_commod = set() + upstream_techs = set(self.technology_graph.predecessors(tech)) + for upstream_tech in upstream_techs: + produces_cmod = False + if ( + commod := self.technology_graph.edges[upstream_tech, tech].get("commodity") + ) is not None: + if isinstance(commod, str) and commod == input_commodity: + successor_techs_with_commod.add(upstream_tech) + produces_cmod = True + if isinstance(commod, list) and input_commodity in commod: + successor_techs_with_commod.add(upstream_tech) + produces_cmod = True + if in_flows[upstream_tech] > 1 and produces_cmod: + new_techs = self.get_successors_for_tech_with_input_cmod( + upstream_tech, input_commodity + ) + if new_techs: + successor_techs_with_commod |= set(new_techs) + + return list(successor_techs_with_commod) + + def _find_converter_techs_new(self): in_flows = dict(self.technology_graph.in_degree) out_flows = dict(self.technology_graph.out_degree) @@ -956,32 +984,50 @@ def _find_converter_techs_vs(self): set(self.technology_graph.nodes) - set(non_converter_techs) - set(self.storage_techs) ) & set(self.input_techs) - (set(self.input_techs) | set(self.feedstock_comps)) - likely_converter_techs - - {(e[0], e[-1]) for e in self.technology_graph.edges(data="commodity") if e[-1] is not None} - converter_info = set() + converter_upstreams = {} for converter in list(likely_converter_techs): # predecessors are upstream and directly connected to the converter predecessor_techs = set(self.technology_graph.predecessors(converter)) # succesor techs are directly downstream of the converter successor_techs = set(self.technology_graph.successors(converter)) - input_commods = { - self.technology_graph.edges[upstream_tech, converter].get("commodity") - for upstream_tech in predecessor_techs - } - output_commods = { - self.technology_graph.edges[converter, downstream_tech].get("commodity") - for downstream_tech in list(successor_techs) - } + + input_commods = set() + for upstream_tech in predecessor_techs: + if ( + cmod := self.technology_graph.edges[upstream_tech, converter].get("commodity") + ) is not None: + if isinstance(cmod, str): + input_commods.add(cmod) + else: + input_commods |= set(cmod) + + output_commods = set() + for downstream_tech in list(successor_techs): + if ( + cmod := self.technology_graph.edges[converter, downstream_tech].get("commodity") + ) is not None: + if isinstance(cmod, str): + output_commods.add(cmod) + else: + output_commods |= set(cmod) consumed = input_commods - output_commods produced = output_commods - input_commods if consumed and produced: for input_commod in input_commods: + upstream_techs_with_commod = self.get_successors_for_tech_with_input_cmod( + converter, input_commod + ) + converter_upstreams[(input_commod, converter)] = upstream_techs_with_commod for output_commod in input_commods: converter_info.add((input_commod, converter, output_commod)) + # demand_group_techs = self.get_successors_for_tech_with_input_cmod( + # self.demand_tech, self.commodity) + # converter_info.add((self.commodity, self.demand_tech, self.commodity)) + return converter_info, converter_upstreams + def _find_converter_techs(self, include_feedstock_sources=True): """Identify technologies that transform one commodity into another. diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 203ea1fa4..943787987 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -571,9 +571,18 @@ def _classify_slc_technologies(self): sources_to_commodities = { (e[0], e[-1]) for e in self.technology_graph.edges(data="commodity") - if (e[-1] is not None) and (e[0] in upstream_controllable_techs) + if (e[-1] is not None) + and (isinstance(e[-1], str)) + and (e[0] in upstream_controllable_techs) } - + if any(isinstance(e[-1], list) for e in self.technology_graph.edges(data="commodity")): + multi_cmod_edges = [ + e for e in self.technology_graph.edges(data="commodity") if isinstance(e[-1], list) + ] + for edge in multi_cmod_edges: + for cmod in edge[-1]: + new_element = (edge[0], cmod) + sources_to_commodities.add(new_element) # re-make technology interconnections using only technologies # upstream of the demand component upstream_interconnections = [ @@ -2191,13 +2200,9 @@ def create_technology_graph(self, tech_interconnections: list | set): ) is not None: if isinstance(connected_cmods, str): all_cmods = {connected_cmods, connection[2]} - technology_graph.add_edge(source, destination, commodity=all_cmods) - else: - connected_cmods.add(connection[2]) - technology_graph.add_edge( - source, destination, commodity=connected_cmods - ) + all_cmods = {*connected_cmods, connection[2]} + technology_graph.add_edge(source, destination, commodity=list(all_cmods)) else: technology_graph.add_edge(source, destination, commodity=connection[2]) else: From f0f4274d435857b573b0a644bd23bcbde8eeb2ca Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:48:08 -0600 Subject: [PATCH 42/69] fleshed out some simplifications to SLC baseclass but not done yet --- .../system_level/system_level_control_base.py | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 1bc604448..e584c87f4 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -826,6 +826,88 @@ def _feedstock_marginal_cost(self, inputs, marginal_cost_data): return np.full(self.n_timesteps, marginal_cost_scalar) + def _new_post_setup_multi_commodity(self): + if not self.multi_commodity_system: + return + # converter upstreams now has values of lists intead of sets + converters, converter_upstreams = self._find_converter_techs_new() + grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} + alt_grouped_techs = { + (f"{k[0][0]}", f"{i}"): k[1] for i, k in enumerate(converter_upstreams.items()) + } + + demand_group_techs = self.get_successors_for_tech_with_input_cmod( + self.demand_tech, self.commodity + ) + # converter_info.add((self.commodity, self.demand_tech, self.commodity)) + # conversion fator recipes requires simple_graph, converters, demand_tech, grouped_techs + grouped_techs[f"{self.commodity}-{len(converter_upstreams)+1}"] = demand_group_techs + alt_grouped_techs[(self.commodity, f"{len(converter_upstreams)+1}")] = demand_group_techs + reversed_grouped_techs = {} + for k, v in grouped_techs.items(): + for vv in list(v): + if vv in reversed_grouped_techs: + if isinstance(reversed_grouped_techs[vv], str): + reversed_grouped_techs[vv] = [reversed_grouped_techs[vv], k] + else: + reversed_grouped_techs[vv] = reversed_grouped_techs[vv] + [k] + else: + reversed_grouped_techs[vv] = k + + def get_group_for_tech(tech_name): + groups = {k for k, v in grouped_techs.items() if tech_name in v} + return list(groups) + + def get_group_for_tech_commodity(tech_name, output_cmod): + possible_converter_grp = [k for k in converter_upstreams if k[0] == output_cmod] + if not possible_converter_grp and output_cmod == self.commodity: + groups = get_group_for_tech(tech_name) + return groups + if possible_converter_grp: + possible_groups = [] + for grp in possible_converter_grp: + if tech_name in converter_upstreams[grp]: + possible_groups += [ + f"{k[0]}-{k[1]}" + for k, v in alt_grouped_techs.items() + if k[0] == output_cmod and tech_name in v + ] + + return possible_groups + warnings.warn("Whats up", UserWarning, stacklevel=3) + + simple_graph = nx.DiGraph() + for e in list(self.technology_graph.edges(data="commodity")): + s0, d0, c = e + + s = reversed_grouped_techs.get(s0, s0) + d = reversed_grouped_techs.get(d0, d0) + + if isinstance(s, str) and isinstance(d, str) and isinstance(c, str): + if s != d: + simple_graph.add_edge(s, d, commodity=c) + elif isinstance(s, str) and isinstance(d, list) and isinstance(c, str): + for di in d: + if s != di: + simple_graph.add_edge(s, di, commodity=c) + elif isinstance(s, list) and isinstance(d, str) and isinstance(c, str): + for si in s: + if si != d: + simple_graph.add_edge(si, d, commodity=c) + elif isinstance(s, list) and isinstance(d, str) and isinstance(c, list): + for ci in c: + group_name = get_group_for_tech_commodity(s0, ci) + simple_graph.add_edge(group_name[0], d, commodity=ci) + # TODO: add error if group_name is greater than 0 + else: + raise ValueError("have not accounted for this design yet") + + self.converters = converters + self.grouped_techs = grouped_techs + self.simple_graph = simple_graph + conversion_recipes = self._make_conversion_factor_recipes() + self.conversion_recipes = conversion_recipes + def _post_setup_multi_commodity(self): if not self.multi_commodity_system: return @@ -1023,9 +1105,6 @@ def _find_converter_techs_new(self): for output_commod in input_commods: converter_info.add((input_commod, converter, output_commod)) - # demand_group_techs = self.get_successors_for_tech_with_input_cmod( - # self.demand_tech, self.commodity) - # converter_info.add((self.commodity, self.demand_tech, self.commodity)) return converter_info, converter_upstreams def _find_converter_techs(self, include_feedstock_sources=True): From 077b55010f3118b14f309a4ee5ae59a4f44ff3e4 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:11:09 -0600 Subject: [PATCH 43/69] minor fix --- .../system_level/system_level_control_base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index e584c87f4..b31d94e3c 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -835,14 +835,18 @@ def _new_post_setup_multi_commodity(self): alt_grouped_techs = { (f"{k[0][0]}", f"{i}"): k[1] for i, k in enumerate(converter_upstreams.items()) } + {k[1] for k in converters} demand_group_techs = self.get_successors_for_tech_with_input_cmod( self.demand_tech, self.commodity ) + # converter_info.add((self.commodity, self.demand_tech, self.commodity)) # conversion fator recipes requires simple_graph, converters, demand_tech, grouped_techs grouped_techs[f"{self.commodity}-{len(converter_upstreams)+1}"] = demand_group_techs alt_grouped_techs[(self.commodity, f"{len(converter_upstreams)+1}")] = demand_group_techs + + # last_converter = [k for k in demand_group_techs if k in converter_techs] reversed_grouped_techs = {} for k, v in grouped_techs.items(): for vv in list(v): @@ -1102,7 +1106,7 @@ def _find_converter_techs_new(self): converter, input_commod ) converter_upstreams[(input_commod, converter)] = upstream_techs_with_commod - for output_commod in input_commods: + for output_commod in output_commods: converter_info.add((input_commod, converter, output_commod)) return converter_info, converter_upstreams From ecc7a214ef22af106f344ecb57181043f015b94f Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:21:10 -0600 Subject: [PATCH 44/69] refactored much of _post_setup_multi_commodity_new --- .../system_level/system_level_control_base.py | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index b31d94e3c..11d273bd8 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -835,7 +835,6 @@ def _new_post_setup_multi_commodity(self): alt_grouped_techs = { (f"{k[0][0]}", f"{i}"): k[1] for i, k in enumerate(converter_upstreams.items()) } - {k[1] for k in converters} demand_group_techs = self.get_successors_for_tech_with_input_cmod( self.demand_tech, self.commodity @@ -851,22 +850,18 @@ def _new_post_setup_multi_commodity(self): for k, v in grouped_techs.items(): for vv in list(v): if vv in reversed_grouped_techs: - if isinstance(reversed_grouped_techs[vv], str): - reversed_grouped_techs[vv] = [reversed_grouped_techs[vv], k] - else: - reversed_grouped_techs[vv] = reversed_grouped_techs[vv] + [k] + # if isinstance(reversed_grouped_techs[vv], str): + # reversed_grouped_techs[vv] = [reversed_grouped_techs[vv], k] + # else: + reversed_grouped_techs[vv] = reversed_grouped_techs[vv] + [k] else: - reversed_grouped_techs[vv] = k - - def get_group_for_tech(tech_name): - groups = {k for k, v in grouped_techs.items() if tech_name in v} - return list(groups) + reversed_grouped_techs[vv] = [k] def get_group_for_tech_commodity(tech_name, output_cmod): possible_converter_grp = [k for k in converter_upstreams if k[0] == output_cmod] if not possible_converter_grp and output_cmod == self.commodity: - groups = get_group_for_tech(tech_name) - return groups + groups = {f"{k[0]}-{k[1]}" for k, v in alt_grouped_techs.items() if tech_name in v} + return list(groups) if possible_converter_grp: possible_groups = [] for grp in possible_converter_grp: @@ -884,27 +879,40 @@ def get_group_for_tech_commodity(tech_name, output_cmod): for e in list(self.technology_graph.edges(data="commodity")): s0, d0, c = e - s = reversed_grouped_techs.get(s0, s0) - d = reversed_grouped_techs.get(d0, d0) + s = reversed_grouped_techs.get(s0, [s0]) + d = reversed_grouped_techs.get(d0, [d0]) - if isinstance(s, str) and isinstance(d, str) and isinstance(c, str): - if s != d: - simple_graph.add_edge(s, d, commodity=c) - elif isinstance(s, str) and isinstance(d, list) and isinstance(c, str): - for di in d: - if s != di: - simple_graph.add_edge(s, di, commodity=c) - elif isinstance(s, list) and isinstance(d, str) and isinstance(c, str): + if isinstance(c, str): for si in s: - if si != d: - simple_graph.add_edge(si, d, commodity=c) - elif isinstance(s, list) and isinstance(d, str) and isinstance(c, list): + for di in d: + if si != di: + simple_graph.add_edge(si, di, commodity=c) + else: + if len(d) > 1: + raise ValueError("have not accounted for this design yet") for ci in c: group_name = get_group_for_tech_commodity(s0, ci) - simple_graph.add_edge(group_name[0], d, commodity=ci) - # TODO: add error if group_name is greater than 0 - else: - raise ValueError("have not accounted for this design yet") + if len(group_name) != 1: + raise ValueError("have not accounted for this design yet") + simple_graph.add_edge(group_name[0], d[0], commodity=ci) + # if isinstance(s, str) and isinstance(d, str) and isinstance(c, str): + # if s != d: + # simple_graph.add_edge(s, d, commodity=c) + # elif isinstance(s, str) and isinstance(d, list) and isinstance(c, str): + # for di in d: + # if s != di: + # simple_graph.add_edge(s, di, commodity=c) + # elif isinstance(s, list) and isinstance(d, str) and isinstance(c, str): + # for si in s: + # if si != d: + # simple_graph.add_edge(si, d, commodity=c) + # elif isinstance(s, list) and isinstance(d, str) and isinstance(c, list): + # for ci in c: + # group_name = get_group_for_tech_commodity(s0, ci) + # simple_graph.add_edge(group_name[0], d, commodity=ci) + # # TODO: add error if group_name is greater than 0 + # else: + # raise ValueError("have not accounted for this design yet") self.converters = converters self.grouped_techs = grouped_techs From 3997586ce35a6989db43c62fdb63f8ed1b03e948 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:56:22 -0600 Subject: [PATCH 45/69] removed old functions in SLC baseclass --- .../system_level/system_level_control_base.py | 475 ++---------------- 1 file changed, 37 insertions(+), 438 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 11d273bd8..33c767331 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -565,6 +565,8 @@ def _dispatch_storage(self, storage_tech, remaining_demand, commodity, inputs, o # Storage tech has its own sub-controller: emit a combined demand # signal (always positive) equal to the commodity flowing into # storage from upstream techs plus any remaining demand. + # TODO: possibly replace self.get_upstream_techs_for_commodity with + # get_successors_for_tech_with_input_cmod upstream_techs = self.get_upstream_techs_for_commodity(storage_tech, commodity) commodity_into_storage = np.zeros(self.n_timesteps) for tech_name in upstream_techs: @@ -826,11 +828,11 @@ def _feedstock_marginal_cost(self, inputs, marginal_cost_data): return np.full(self.n_timesteps, marginal_cost_scalar) - def _new_post_setup_multi_commodity(self): + def _post_setup_multi_commodity(self): if not self.multi_commodity_system: return # converter upstreams now has values of lists intead of sets - converters, converter_upstreams = self._find_converter_techs_new() + converters, converter_upstreams = self._find_converter_techs() grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} alt_grouped_techs = { (f"{k[0][0]}", f"{i}"): k[1] for i, k in enumerate(converter_upstreams.items()) @@ -873,7 +875,11 @@ def get_group_for_tech_commodity(tech_name, output_cmod): ] return possible_groups - warnings.warn("Whats up", UserWarning, stacklevel=3) + warnings.warn( + f"Couldn't find group for {tech_name} producing {output_cmod}", + UserWarning, + stacklevel=3, + ) simple_graph = nx.DiGraph() for e in list(self.technology_graph.edges(data="commodity")): @@ -895,110 +901,20 @@ def get_group_for_tech_commodity(tech_name, output_cmod): if len(group_name) != 1: raise ValueError("have not accounted for this design yet") simple_graph.add_edge(group_name[0], d[0], commodity=ci) - # if isinstance(s, str) and isinstance(d, str) and isinstance(c, str): - # if s != d: - # simple_graph.add_edge(s, d, commodity=c) - # elif isinstance(s, str) and isinstance(d, list) and isinstance(c, str): - # for di in d: - # if s != di: - # simple_graph.add_edge(s, di, commodity=c) - # elif isinstance(s, list) and isinstance(d, str) and isinstance(c, str): - # for si in s: - # if si != d: - # simple_graph.add_edge(si, d, commodity=c) - # elif isinstance(s, list) and isinstance(d, str) and isinstance(c, list): - # for ci in c: - # group_name = get_group_for_tech_commodity(s0, ci) - # simple_graph.add_edge(group_name[0], d, commodity=ci) - # # TODO: add error if group_name is greater than 0 - # else: - # raise ValueError("have not accounted for this design yet") + non_converter_keys = set() + for converter_info, upstream_techs in converter_upstreams.items(): + input_cmod, _ = converter_info + non_converter_keys |= {(input_cmod, t, input_cmod) for t in upstream_techs} + + self.converter_upstreams = converter_upstreams self.converters = converters self.grouped_techs = grouped_techs self.simple_graph = simple_graph + self.converter_tech_names = {c[1] for c in converters} conversion_recipes = self._make_conversion_factor_recipes() self.conversion_recipes = conversion_recipes - - def _post_setup_multi_commodity(self): - if not self.multi_commodity_system: - return - converters, converter_upstreams = self._find_converter_techs(include_feedstock_sources=True) - # Group together technologies that are connected to a converter - # I.e., group together an electrolyzer an hydrogen storage, - # name this group as the shared commodity with a unique number - grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} - - # 3. Add in a conversion factor of 1 for all non-converter technologies - converter_tech_names = {v[1] for v in list(converters)} - - conversion_factor_keys = [ - (tc[1], tc[0], tc[1]) - for tc in self.techs_to_commodities - if tc[0] not in converter_tech_names - ] - # missing_input_techs = set(self.input_techs) - set(reversed_grouped_techs.keys()) - - # NOTE: maybe only run below if theres a missing_input_tech - non_converter_input_techs_in_group, demand_group = self._find_demand_tech_group( - converters, converter_upstreams - ) - grouped_techs.update(demand_group) - - conversion_factor_keys += [ - (self.commodity, k, self.commodity) for k in non_converter_input_techs_in_group - ] - - # Add demand component to converter_upstreams - demand_group_techs = list(demand_group.values())[0] - converter_upstreams[(self.commodity, self.demand_tech)] = ( - set(demand_group_techs) & self.input_techs - ) - - # 2. Make a dictionary for future-use that has keys of the technology names and - # the group they belong to - reversed_grouped_techs = {} - for k, v in grouped_techs.items(): - for vv in list(v): - reversed_grouped_techs[vv] = k - - # 4. Track the technologies that are non_input_techs so we know - # they don't have a converison factor. Also add these technologies - # to the reversed_group_techs - - # Get the nodes of the technology graph that aren't a controllable technology - # Also add these technologies to the reversed_group_techs - - non_input_techs_conversion_factor_keys, techs_to_groups = ( - self._find_group_for_non_input_techs(grouped_techs) - ) - # Add conversion factors of 1 for the technologies that are non_input_techs - conversion_factor_keys += non_input_techs_conversion_factor_keys - reversed_grouped_techs.update( - techs_to_groups - ) # unsure why we're not updating grouped_techs - - # 5. Make the edges of the grouped technologies - simple_graph = nx.DiGraph() - for e in list(self.technology_graph.edges(data="commodity")): - s0, d0, c = e - - s = reversed_grouped_techs.get(s0, s0) - d = reversed_grouped_techs.get(d0, d0) - - if s != d: - simple_graph.add_edge(s, d, commodity=c) - - self.simple_graph = simple_graph - self.non_converter_conversion_factor_keys = conversion_factor_keys - self.grouped_techs = grouped_techs - self.converters = converters - self.converter_upstreams = converter_upstreams - self.converter_tech_names = converter_tech_names - - conversion_recipes = self._make_conversion_factor_recipes() - - self.conversion_recipes = conversion_recipes + self.non_converter_conversion_factor_keys = non_converter_keys def get_upstream_techs_for_commodity( self, tech_name: str, commodity: str, include_feedstock_sources=True @@ -1025,6 +941,7 @@ def get_upstream_techs_for_commodity( else: input_techs = set(self.input_techs) + # TODO: refactor to call get_successors_for_tech_with_input_cmod # All graph ancestors of tech_name (any depth) ancestors = nx.ancestors(self.technology_graph, tech_name) @@ -1067,7 +984,21 @@ def get_successors_for_tech_with_input_cmod(self, tech, input_commodity): return list(successor_techs_with_commod) - def _find_converter_techs_new(self): + def _find_converter_techs(self): + """Identify technologies that transform one commodity into another. + + A "converter" is a tech whose output commodities differ from the commodities + produced by its upstream ancestors (e.g. an electrolyzer: electricity → hydrogen). + + Returns: + 2-element tuple containing: + + - **converters** (tuple[str, str, str]): Set of tuples formatted as + ``(input_commodity, tech_name, output_commodity)`` tuples. + - **upstreams** (dict[tuple[str,str], list[str]]): Keys are set of + ``(input_commodity, tech_name)`` and the values are a set of + upstream technologies that output the `input_commodity` to `tech_name`. + """ in_flows = dict(self.technology_graph.in_degree) out_flows = dict(self.technology_graph.out_degree) @@ -1105,10 +1036,13 @@ def _find_converter_techs_new(self): output_commods.add(cmod) else: output_commods |= set(cmod) - + # A converter has commodities that appear only on one side: + # upstream-only commodities are consumed, output-only are produced. consumed = input_commods - output_commods produced = output_commods - input_commods if consumed and produced: + # If both sides have unique commodities, this tech is a converter + for input_commod in input_commods: upstream_techs_with_commod = self.get_successors_for_tech_with_input_cmod( converter, input_commod @@ -1119,150 +1053,6 @@ def _find_converter_techs_new(self): return converter_info, converter_upstreams - def _find_converter_techs(self, include_feedstock_sources=True): - """Identify technologies that transform one commodity into another. - - A "converter" is a tech whose output commodities differ from the commodities - produced by its upstream ancestors (e.g. an electrolyzer: electricity → hydrogen). - - Args: - include_feedstock_sources (bool, optional): If True, include feedstock techs - in the set of candidate technologies. Defaults to True. - - Returns: - 2-element tuple containing: - - - **converters** (tuple[str, str, str]): Set of tuples formatted as - ``(input_commodity, tech_name, output_commodity)`` tuples. - - **upstreams** (dict[tuple[str,str], set[str]]): Keys are set of - ``(input_commodity, tech_name)`` and the values are a set of - upstream technologies that output the `input_commodity` to `tech_name`. - """ - # TODO: add an input thats `include_demand_component` - - if include_feedstock_sources: - input_techs = self.input_techs | set(self.feedstock_comps) - else: - input_techs = self.input_techs.copy() - - # Single-commodity systems have no special handling by definition - if not self.multi_commodity_system: - return - - # ``converter_techs`` is a set of ``(input_commodity, tech_name, output_commodity)`` - # tuples for each detected conversion - converter_techs = set() - # ``converter_order`` is a dictionary defining the directional order of the converters - # the keys are integers, lower numbers mean it comes first. - # Values are the format of entries in ``converter_techs`` - converter_order = {} - # ``converter_ancestors`` is a dictionary with the same keys as ``converter_order`` - # and the values as upstream technologies that produce ``input_commodity`` and are - # connected to the converter tech (does not include converters in upstream technologies) - converter_ancestors = {} - node_order = list(self.technology_graph.nodes()) - edges = list(self.technology_graph.edges(data="commodity")) - ii = 0 - - # NOTE: using tracked_ancestors would make this code very senstive to the order of - # tech connections - # I.e., would get different results if feedstocks connected to haber_bosch first - # Track the most recently discovered converter so we can scope - # upstream searches for chained converters (A→B→C where B and C - # both convert). Without this, C would see A's commodity as upstream - # input even though B already consumed it. - last_converter = None - - for source_tech, _, _ in edges: - if source_tech not in input_techs: - continue - - # Get the commodities produced by this tech (the "output" side of the conversion) - output_commodities = set(self._get_commodity_for_tech(source_tech)) - - # Find controlled ancestors of this tech - all_ancestors = nx.ancestors(self.technology_graph, source_tech) & input_techs - - if last_converter is not None: - # Only consider ancestors that appear after the last converter - # in topological order, preventing double-counting across - # chained converters. - converter_idx = node_order.index(last_converter) - nodes_after_converter = set(node_order[converter_idx + 1 :]) - ancestors = all_ancestors & nodes_after_converter - - else: - ancestors = all_ancestors - - # Keep only ancestors actually connected (reachable) to this tech - connected_ancestors = [ - t for t in ancestors if nx.has_path(self.technology_graph, t, source_tech) - ] - - # Gather all commodities produced by connected ancestors - input_commodities = set() - for ancestor in connected_ancestors: - input_commodities.update(self._get_commodity_for_tech(ancestor)) - - # A converter has commodities that appear only on one side: - # upstream-only commodities are consumed, output-only are produced. - consumed = input_commodities - output_commodities - produced = output_commodities - input_commodities - - # If both sides have unique commodities, this tech is a converter - if consumed and produced and (source_tech not in self.storage_techs): - for in_comm in consumed: - for out_comm in produced: - # if in_comm != out_comm: - converter_techs.add((in_comm, source_tech, out_comm)) - converter_order[ii] = (in_comm, source_tech, out_comm) - converter_ancestors[ii] = [ - e[0] - for e in self.techs_to_commodities - if e[1] == in_comm and e[0] in connected_ancestors - ] - - ii += 1 - last_converter = source_tech - - if len(converter_techs) < len(converter_order): - # remove duplicate converter orders - rev_converter_order = {v: k for k, v in converter_order.items()} - # re-reverse it - converter_order = {v: k for k, v in rev_converter_order.items()} - # remove duplicate converter orders - # re-reverse it - converter_ancestors = {k: converter_ancestors[k] for k in list(converter_order.keys())} - - # Make sure we iterate through the converters in the right order - converter_cnt = list(converter_order.keys()) - converter_cnt.sort() - previous_converters = set() # track previous converters - # ``upstreams`` is similar to ``converter_ancestors`` but has keys as a tuple formatted as - # ``(input_commodity, tech_name)``. The values are a set of the technologies upstream of - # ``tech_name`` that output ``input_commodity`` that is input to ``tech_name``. - # Key difference from ``converter_ancestors`` is that this includes upstream converter techs - upstreams = {} # upstreams is similar to - # NOTE: unsure how the below logic will work with splitters - for converter_ii in converter_cnt: - input_cmod, tech, output_cmod = converter_order[converter_ii] - if input_cmod == output_cmod: - continue - # Get all the upstream technologies that produce a specific commodity - upstream1 = self.get_upstream_techs_for_commodity( - tech, input_cmod, include_feedstock_sources=True - ) - # Combined the upstream techs with all the previous converters - upstream_converter = set(upstream1) & previous_converters - # Remove any of the previous converters that arent connected to this converter - upstreams[(input_cmod, tech)] = set(upstream1) & ( - upstream_converter | set(converter_ancestors[converter_ii]) - ) - previous_converters.add(tech) - # return converter_techs, converter_order, converter_ancestors, upstreams - # return converter_order, upstreams - return converter_techs, upstreams - def get_converter_capacity_conversion_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors ): @@ -1324,184 +1114,6 @@ def get_converter_conversion_ratio( conversion_factor = total_input / np.abs(total_output) return conversion_factor - def dict_values_to_flat_list(self, dictionary): - """Aggregate all the values in a dictionary to a flattened list - - Args: - dictionary (dict): dictionary with values as either a list or set - - Returns: - list: flattened list of all the values in ``dictionary`` - """ - flat_list = [] - for v in dictionary.values(): - if isinstance(v, set): - v = list(v) - - flat_list.extend(v) - return flat_list - - def _check_demand_tech_group_connections(self, converter_tech_names, missing_input_techs): - # NOTE: sometimes these warnings happen because of the dependency on the - # tech connection order - successful = True - commodities_for_missing_techs = { - tech: self._get_commodity_for_tech(tech) for tech in list(missing_input_techs) - } - commodities_out = self.dict_values_to_flat_list(commodities_for_missing_techs) - - if self.commodity not in list(commodities_out): - warnings.warn( - "none of the demand commodities are made by missing techs", - UserWarning, - stacklevel=3, - ) - successful = False - - missing_tech_downstreams = [ - list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs - ] - missing_tech_downstreams_shared = {self.demand_tech} - other_converters = converter_tech_names - missing_input_techs - - for downstream in missing_tech_downstreams: - missing_tech_downstreams_shared = missing_tech_downstreams_shared & set(downstream) - # TODO: check that no other converters are inbetween - if set(downstream) & other_converters: - warnings.warn( - "theres an extra converter between the missing techs and the demand", - UserWarning, - stacklevel=3, - ) - successful = False - if not missing_tech_downstreams_shared or len(missing_tech_downstreams_shared) > 1: - warnings.warn("something unexpected happened", UserWarning, stacklevel=3) - successful = False - - missing_converter_tech = missing_input_techs & converter_tech_names - if len(missing_converter_tech) > 1: - warnings.warn( - "unsure how code will work with multiple converters connected to demand", - UserWarning, - stacklevel=3, - ) - successful = False - if not missing_converter_tech: - warnings.warn("should have a converter before demand ...", UserWarning, stacklevel=3) - successful = False - - for m0 in list(missing_converter_tech): - for m1 in list(missing_input_techs - missing_converter_tech): - m0_upstream = nx.has_path(self.technology_graph, m0, m1) - m1_upstream = nx.has_path(self.technology_graph, m1, m0) - if not m0_upstream or m1_upstream: - warnings.warn( - f"The technologies {m0} and {m1} aren't connected", - UserWarning, - stacklevel=3, - ) - successful = False - return successful - - def _find_demand_tech_group(self, converters, converter_upstreams): - """Find the technologies that are connected to the demand converter - and the demand technology that produce the demanded commodity - - Args: - converters (tuple[str, str, str]): Set of tuples formatted as - ``(input_commodity, tech_name, output_commodity)`` tuples. - converter_upstreams (dict[tuple[str,str], set[str]]): Keys are set of - ``(input_commodity, tech_name)`` and the values are a set of - upstream technologies that output the `input_commodity` to `tech_name`. - - Returns: - 2-element tuple containing: - - - **non_converter_input_techs_in_group** (list[str]): List of - non-converter technologies that are connected to the demand technology - and produce the demanded commodity. Only includes technologies in - `self.input_techs` - - **demand_group** (dict[str, set[str]]): Key is the name of the demand group - and values are a set of all the technologies that are connected to the - demand technology and produce the demanded commodity. - """ - found_input_techs = self.dict_values_to_flat_list(converter_upstreams) - missing_input_techs = set(self.input_techs) - set(found_input_techs) - - converter_tech_names = {v[1] for v in list(converters)} - - successful = self._check_demand_tech_group_connections( - converter_tech_names, missing_input_techs - ) - if not successful: - msg = "A bug may exist. Please refer to earlier warnings" - warnings.warn(msg, UserWarning, stacklevel=3) - - input_comps = {self.demand_tech} - missing_input_techs - missing_techs_to_downstreams = { - tech: list(nx.descendants(self.technology_graph, tech)) for tech in missing_input_techs - } - missing_tech_downstreams = self.dict_values_to_flat_list(missing_techs_to_downstreams) - non_input_components = set(missing_tech_downstreams) - input_comps - - unique_number = int(len(converter_upstreams) + 1) - group_name = f"{self.commodity}-{int(unique_number)}" - - missing_converter_tech = missing_input_techs & converter_tech_names - - # all techs in group, include non-controllable ones (like combiners) - all_techs_in_group = list(non_input_components | missing_input_techs) - # input techs in group except for the converter - non_converter_input_techs_in_group = list(missing_input_techs - missing_converter_tech) - demand_group = {group_name: set(all_techs_in_group)} - - return non_converter_input_techs_in_group, demand_group - - def _find_group_for_non_input_techs(self, grouped_techs): - # Get the nodes of the technology graph that aren't a controllable technology - def get_group_for_tech(tech_name): - group = [grp for grp, techs in grouped_techs.items() if tech_name in techs] - if len(group) == 0: - return None - # msg = f"Cannot find simplified group for technology {tech_name}" - # raise ValueError(msg) - return group[0] - - techs_to_groups = {} - conversion_factor_keys = [] - - non_input_techs = ( - set(self.technology_graph.nodes) - set(self.input_techs) - {self.demand_tech} - ) - # Also add these technologies to the reversed_group_techs - - for non_t in list(non_input_techs): - up_techs = set(self.technology_graph.predecessors(non_t)) - non_input_techs - down_techs = set(self.technology_graph.successors(non_t)) - non_input_techs - - tech_group = get_group_for_tech(non_t) - commod = None - if up_techs and (tech_group is None): - for t in list(up_techs): - commod = self.technology_graph.edges[t, non_t].get("commodity", None) - if commod is not None: - # Add these technologies to the reversed_group_techs - techs_to_groups[non_t] = get_group_for_tech(t) - break - - if down_techs and (commod is None) and (tech_group is None): - for t in list(down_techs): - commod = self.technology_graph.edges[non_t, t].get("commodity", None) - if commod is not None: - # Add these technologies to the reversed_group_techs - techs_to_groups[non_t] = get_group_for_tech(t) - # techs_to_groups[t] = get_group_for_tech(t) - break - # Add conversion factors of 1 for the technologies that are non_input_techs - if tech_group is None: - conversion_factor_keys.append((commod, non_t, commod)) - return conversion_factor_keys, techs_to_groups - def _make_conversion_factor_recipes(self): """Make recipes to for compounding conversion factor calculations. @@ -1572,19 +1184,6 @@ def _make_conversion_factor_recipes(self): return compounding_conversion_factor_recipes - def _get_techs_for_conversion(self, input_cmod, tech): - # TODO: remove this - I think its unused - tech_to_demand = [ - s - for s in list(self.simple_graph.predecessors(tech)) - if self.simple_graph.edges[s, tech].get("commodity", "") == input_cmod - ] - if len(tech_to_demand) != 1: - raise ValueError("Unexpected situation!") - if tech_to_demand[0] in self.grouped_techs: - return list(self.grouped_techs[tech_to_demand[0]]) - return tech_to_demand[0] - def _get_techs_to_demand_from_recipe(self, recipe_name): _, input_cmod, tech_group = recipe_name techs_to_demand = [ From c765d0bfb6102684a645bb5e1d653aac2cf43f2b Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:38:14 -0600 Subject: [PATCH 46/69] updated test_slc_baseclass --- .../system_level/system_level_control_base.py | 14 +- .../system_level/test/test_slc_baseclass.py | 210 ++++++++++-------- 2 files changed, 128 insertions(+), 96 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 33c767331..632025245 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -903,15 +903,25 @@ def get_group_for_tech_commodity(tech_name, output_cmod): simple_graph.add_edge(group_name[0], d[0], commodity=ci) non_converter_keys = set() + converter_tech_names = {c[1] for c in converters} + for converter_info, upstream_techs in converter_upstreams.items(): input_cmod, _ = converter_info - non_converter_keys |= {(input_cmod, t, input_cmod) for t in upstream_techs} + non_converter_keys |= { + (input_cmod, t, input_cmod) for t in upstream_techs if t not in converter_tech_names + } + + non_converter_keys |= { + (self.commodity, t, self.commodity) + for t in demand_group_techs + if t not in converter_tech_names + } self.converter_upstreams = converter_upstreams self.converters = converters self.grouped_techs = grouped_techs self.simple_graph = simple_graph - self.converter_tech_names = {c[1] for c in converters} + self.converter_tech_names = converter_tech_names conversion_recipes = self._make_conversion_factor_recipes() self.conversion_recipes = conversion_recipes self.non_converter_conversion_factor_keys = non_converter_keys diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index eaba703ff..79c1aecc2 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -3,7 +3,7 @@ import openmdao.api as om from h2integrate import EXAMPLE_DIR, H2IntegrateModel -from h2integrate.core.inputs.validation import load_tech_yaml, load_plant_yaml, load_driver_yaml +from h2integrate.core.inputs.validation import load_tech_yaml, load_plant_yaml from h2integrate.control.control_strategies.system_level.system_level_control_base import ( SystemLevelControlBase, ) @@ -86,7 +86,7 @@ def make_and_setup_slc_baseclass(plant_config, tech_config) -> SystemLevelContro # Test methods in _post_setup_multi_commodity -# _find_converter_techs(include_feedstock_sources=True) +# _find_converter_techs() # _find_demand_tech_group() # _find_group_for_non_input_techs # _make_conversion_factor_recipes() @@ -95,7 +95,7 @@ def make_and_setup_slc_baseclass(plant_config, tech_config) -> SystemLevelContro @pytest.mark.unit def test_find_converter_techs_fake_system(subtests): # Test methods in _post_setup_multi_commodity - # _find_converter_techs(include_feedstock_sources=True) + # _find_converter_techs() # _find_demand_tech_group() tech_connections = [ ["boat", "desalination", "raw_water", ""], @@ -135,7 +135,7 @@ def test_find_converter_techs_fake_system(subtests): slc = make_and_setup_slc_baseclass(plant_config, {"technologies": tech_config_fake}) - converters, converter_upstreams = slc._find_converter_techs(include_feedstock_sources=True) + converters, converter_upstreams = slc._find_converter_techs() with subtests.test("converters is not right"): assert True @@ -144,15 +144,14 @@ def test_find_converter_techs_fake_system(subtests): @pytest.mark.unit def test_find_converter_techs_nh3_system(subtests): # Test methods in _post_setup_multi_commodity - # _find_converter_techs(include_feedstock_sources=True) - # _find_demand_tech_group() + # _find_converter_techs() example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" plant_config = load_plant_yaml(example_folder / "plant_config.yaml") tech_config = load_tech_yaml(example_folder / "tech_config.yaml") slc = make_and_setup_slc_baseclass(plant_config, tech_config) # Test _find_converter_techs() - converters, converter_upstreams = slc._find_converter_techs(include_feedstock_sources=True) + converters, converter_upstreams = slc._find_converter_techs() expected_converters = { ("nitrogen", "haber_bosch", "ammonia"), @@ -171,27 +170,29 @@ def test_find_converter_techs_nh3_system(subtests): with subtests.test("converters"): assert converters == expected_converters with subtests.test("converter_upstreams"): - assert converter_upstreams == expected_converter_upstreams + for k, v in expected_converter_upstreams.items(): + input_tech_upstreams = set(converter_upstreams.get(k)) & ( + set(slc.input_techs) | set(slc.feedstock_comps) + ) + assert input_tech_upstreams == v # Test _find_demand_tech_group() - non_converter_input_techs_in_group, demand_group = slc._find_demand_tech_group( - converters, converter_upstreams - ) + # non_converter_input_techs_in_group, demand_group = slc._find_demand_tech_group( + # converters, converter_upstreams + # ) - with subtests.test("non main techs in demand group"): - assert non_converter_input_techs_in_group == ["nh3_storage"] + # with subtests.test("non main techs in demand group"): + # assert non_converter_input_techs_in_group == ["nh3_storage"] - expected_demand_group = {"ammonia-5": {"nh3_combiner", "haber_bosch", "nh3_storage"}} - with subtests.test("demand_group"): - assert demand_group == expected_demand_group + # expected_demand_group = {"ammonia-5": {"nh3_combiner", "haber_bosch", "nh3_storage"}} + # with subtests.test("demand_group"): + # assert demand_group == expected_demand_group @pytest.mark.unit def test_multi_commodity_post_setup_nh3_system(subtests): # Test methods in _post_setup_multi_commodity - # _find_converter_techs(include_feedstock_sources=True) - # _find_demand_tech_group() - # _find_group_for_non_input_techs + # _find_converter_techs() # _make_conversion_factor_recipes() example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" plant_config = load_plant_yaml(example_folder / "plant_config.yaml") @@ -255,40 +256,45 @@ def test_multi_commodity_post_setup_nh3_system(subtests): ("hydrogen", "haber_bosch"): {"electrolyzer", "h2_storage"}, ("electricity", "haber_bosch"): {"electricity_feedstock"}, ("nitrogen", "haber_bosch"): {"n2_feedstock"}, - ("ammonia", "nh3_load_demand"): {"haber_bosch", "nh3_storage"}, + # ("ammonia", "nh3_load_demand"): {"haber_bosch", "nh3_storage"}, } converter_upstreams = prob.model.slc.converter_upstreams - with subtests.test("converter upstreams"): - assert converter_upstreams == expected_converter_upstreams + # with subtests.test("converter upstreams"): + for k, v in expected_converter_upstreams.items(): + with subtests.test(f"converter upstreams {k}"): + input_tech_upstreams = set(converter_upstreams.get(k)) & ( + set(slc.input_techs) | set(slc.feedstock_comps) + ) + assert input_tech_upstreams == v # Check simple_graph - simple_graph = prob.model.slc.simple_graph - edges = list(simple_graph.edges(data="commodity")) - expected_edges = [ - ("electricity-0", "hydrogen-1", "electricity"), - ("hydrogen-1", "ammonia-5", "hydrogen"), - ("ammonia-5", "nh3_load_demand", "ammonia"), - ("nitrogen-3", "ammonia-5", "nitrogen"), - ("electricity-2", "ammonia-5", "electricity"), - ] - - with subtests.test("simple_graph edges"): - # assert not bool(set(edges) ^ set(expected_edges)) - assert set(edges) == set(expected_edges) + # simple_graph = prob.model.slc.simple_graph + # edges = list(simple_graph.edges(data="commodity")) + # expected_edges = [ + # ("electricity-0", "hydrogen-2", "electricity"), + # ("hydrogen-2", "ammonia-5", "hydrogen"), + # ("ammonia-5", "nh3_load_demand", "ammonia"), + # ("nitrogen-1", "ammonia-5", "nitrogen"), + # ("electricity-3", "ammonia-5", "electricity"), + # ] + + # with subtests.test("simple_graph edges"): + # # assert not bool(set(edges) ^ set(expected_edges)) + # assert set(edges) == set(expected_edges) # Check grouped_techs grouped_techs = prob.model.slc.grouped_techs expected_groups = [ - {"solar", "battery", "wind"}, - {"electrolyzer", "h2_storage"}, + {"solar", "battery", "wind", "combiner", "elec_combiner"}, + {"electrolyzer", "h2_storage", "h2_combiner"}, {"electricity_feedstock"}, {"n2_feedstock"}, {"nh3_combiner", "haber_bosch", "nh3_storage"}, ] failed_groups = [] for group, techs_in_group in grouped_techs.items(): - if not any(g == techs_in_group for g in expected_groups): + if not any(g == set(techs_in_group) for g in expected_groups): failed_groups.append(group) with subtests.test("Grouped technologies is correct"): assert len(failed_groups) == 0 @@ -305,26 +311,35 @@ def test_multi_commodity_post_setup_nh3_system(subtests): ] n2_nh3_recipe = [("nitrogen", "haber_bosch", "ammonia"), *demand_group_general] + n2_nh3_recipe_name = [k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "nitrogen"] with subtests.test("Nitrogen to Ammonia Recipe"): - assert conversion_recipes[("ammonia", "nitrogen", "ammonia-5")] == [set(n2_nh3_recipe)] + assert conversion_recipes[n2_nh3_recipe_name[0]] == [set(n2_nh3_recipe)] electricity_nh3_recipe = [("electricity", "haber_bosch", "ammonia"), *demand_group_general] + electricity_nh3_recipe_name = [ + k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "electricity" + ] with subtests.test("Electricity to Ammonia Recipe"): - assert conversion_recipes[("ammonia", "electricity", "ammonia-5")] == [ - set(electricity_nh3_recipe) - ] + assert conversion_recipes[electricity_nh3_recipe_name[0]] == [set(electricity_nh3_recipe)] h2_nh3_recipe = [("hydrogen", "haber_bosch", "ammonia"), *demand_group_general] + h2_nh3_recipe_name = [k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "hydrogen"] + with subtests.test("Hydrogen to Ammonia Recipe"): - assert conversion_recipes[("ammonia", "hydrogen", "ammonia-5")] == [set(h2_nh3_recipe)] + assert conversion_recipes[h2_nh3_recipe_name[0]] == [set(h2_nh3_recipe)] h2_elec_subrecipe = { ("hydrogen", "h2_storage", "hydrogen"), ("electricity", "electrolyzer", "hydrogen"), + ("hydrogen", "h2_combiner", "hydrogen"), } h2_elec_recipe = [set(h2_nh3_recipe), h2_elec_subrecipe] + + h2_elec_recipe_name = [ + k for k in conversion_recipes if k[0] == "hydrogen" and k[1] == "electricity" + ] with subtests.test("Electricity for Hydrogen Recipe"): - assert conversion_recipes[("hydrogen", "electricity", "hydrogen-1")] == h2_elec_recipe + assert conversion_recipes[h2_elec_recipe_name[0]] == h2_elec_recipe with subtests.test("4 recipes"): assert len(conversion_recipes) == 4 @@ -343,6 +358,7 @@ def test_multi_commodity_post_setup_nh3_system(subtests): "elec_combiner", "combiner", "h2_combiner", + "nh3_combiner", ] with subtests.test("Non converter techs"): assert set(non_converter_techs) == set(expected_non_converter_techs) @@ -506,11 +522,10 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): all_conversion_factors = conversion_factors | non_converter_conversion_factors conversion_recipes = prob.model.slc.conversion_recipes - conversion_recipes[("ammonia", "nitrogen", "ammonia-5")] - conversion_recipes[("hydrogen", "electricity", "hydrogen-1")] # Nitrogen/Ammonia - n2_recipe_name = ("ammonia", "nitrogen", "ammonia-5") + n2_recipe_name = [k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "nitrogen"][0] + # n2_recipe_name = ("ammonia", "nitrogen", "ammonia-5") with subtests.test("Nitrogen/Ammonia Conversion Factor"): conversion_factor = prob.model.slc._get_conversion_from_recipe( all_conversion_factors, conversion_recipes[n2_recipe_name] @@ -521,7 +536,10 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): assert ["n2_feedstock"] == techs_to_demand # Electricity/Ammonia - elec_recipe_name = ("ammonia", "electricity", "ammonia-5") + elec_recipe_name = [ + k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "electricity" + ][0] + # elec_recipe_name = ("ammonia", "electricity", "ammonia-5") with subtests.test("Electricity/Ammonia Conversion Factor"): conversion_factor = prob.model.slc._get_conversion_from_recipe( all_conversion_factors, conversion_recipes[elec_recipe_name] @@ -532,7 +550,8 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): assert ["electricity_feedstock"] == techs_to_demand # Hydrogen/Ammonia - h2_recipe_name = ("ammonia", "hydrogen", "ammonia-5") + h2_recipe_name = [k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "hydrogen"][0] + # h2_recipe_name = ("ammonia", "hydrogen", "ammonia-5") with subtests.test("Hydrogen/Ammonia Conversion Factor"): conversion_factor = prob.model.slc._get_conversion_from_recipe( all_conversion_factors, conversion_recipes[h2_recipe_name] @@ -541,11 +560,14 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): with subtests.test("Hydrogen/Ammonia Techs"): techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(h2_recipe_name) - expected_techs = ["h2_storage", "electrolyzer"] + expected_techs = ["h2_storage", "electrolyzer", "h2_combiner"] assert set(expected_techs) == set(techs_to_demand) # Electricity/Hydrogen/Ammonia - eh2_recipe_name = ("hydrogen", "electricity", "hydrogen-1") + eh2_recipe_name = [ + k for k in conversion_recipes if k[0] == "hydrogen" and k[1] == "electricity" + ][0] + # eh2_recipe_name = ("hydrogen", "electricity", "hydrogen-1") with subtests.test("Electricity/Hydrogen/Ammonia Conversion Factor"): conversion_factor = prob.model.slc._get_conversion_from_recipe( all_conversion_factors, conversion_recipes[eh2_recipe_name] @@ -554,49 +576,49 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): assert pytest.approx(expected_conversion_factor, rel=1e-6) == conversion_factor with subtests.test("Electricity/Hydrogen/Ammonia Techs"): techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(eh2_recipe_name) - expected_techs = ["battery", "wind", "solar"] + expected_techs = ["battery", "wind", "solar", "combiner", "elec_combiner"] assert set(expected_techs) == set(techs_to_demand) -@pytest.mark.unit -def test_slc_baseclass_complex_multicommodity_no_storage(subtests): - # TODO: finish this test? - # h2i = object.__new__(H2IntegrateModel) - # h2i.slc = True - - example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" - plant_config = load_plant_yaml(example_folder / "plant_config.yaml") - tech_config = load_tech_yaml(example_folder / "tech_config.yaml") - driver_config = load_driver_yaml(example_folder / "driver_config.yaml") - - config_input = { - "plant_config": plant_config, - "technology_config": tech_config, - "driver_config": driver_config, - } - h2i = H2IntegrateModel(config_input) - - h2i.setup() - - slc = h2i.prob.model.plant.system_level_controller - - # Check converters - # Check converter_upstreams - # Check simple_graph - - # - # Check the grouped techs - expected_groups = [ - {"solar", "battery", "wind"}, - {"electrolyzer", "h2_storage"}, - {"electricity_feedstock"}, - {"n2_feedstock"}, - {"nh3_combiner", "haber_bosch", "nh3_storage"}, - ] - grouped_techs = slc.__getattribute__("grouped_techs") - failed_groups = [] - for group, techs_in_group in grouped_techs.items(): - if not any(g == techs_in_group for g in expected_groups): - failed_groups.append(group) - with subtests.test("Grouped technologies is correct"): - assert len(failed_groups) == 0 +# @pytest.mark.unit +# def test_slc_baseclass_complex_multicommodity_no_storage(subtests): +# # TODO: finish this test? +# # h2i = object.__new__(H2IntegrateModel) +# # h2i.slc = True + +# example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" +# plant_config = load_plant_yaml(example_folder / "plant_config.yaml") +# tech_config = load_tech_yaml(example_folder / "tech_config.yaml") +# driver_config = load_driver_yaml(example_folder / "driver_config.yaml") + +# config_input = { +# "plant_config": plant_config, +# "technology_config": tech_config, +# "driver_config": driver_config, +# } +# h2i = H2IntegrateModel(config_input) + +# h2i.setup() + +# slc = h2i.prob.model.plant.system_level_controller + +# # Check converters +# # Check converter_upstreams +# # Check simple_graph + +# # +# # Check the grouped techs +# expected_groups = [ +# {"solar", "battery", "wind"}, +# {"electrolyzer", "h2_storage"}, +# {"electricity_feedstock"}, +# {"n2_feedstock"}, +# {"nh3_combiner", "haber_bosch", "nh3_storage"}, +# ] +# grouped_techs = slc.__getattribute__("grouped_techs") +# failed_groups = [] +# for group, techs_in_group in grouped_techs.items(): +# if not any(g == techs_in_group for g in expected_groups): +# failed_groups.append(group) +# with subtests.test("Grouped technologies is correct"): +# assert len(failed_groups) == 0 From 6dffb44e0a3dd67e93d5f4d8cf5c79d04ca2fbb5 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:53:53 -0600 Subject: [PATCH 47/69] fixed demand following so unit tests pass --- .../system_level/demand_following_control.py | 5 ++++- .../system_level/system_level_control_base.py | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 1f1a4132a..ef3d5ac9b 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -44,9 +44,12 @@ def setup(self): super().setup() self.config = DemandFollowingControlConfig.from_dict( - self.options["plant_config"]["system_level_control"].get("control_parameters", {}) + self.options["plant_config"]["system_level_control"].get("control_parameters", {}), + strict=False, ) + self.tech_demands_set = [] + def get_setpoints_for_commodity_subset( self, inputs, outputs, commodity, commodity_demand, tech_subset: list | set | None = None ): diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 632025245..5174178f1 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -833,20 +833,20 @@ def _post_setup_multi_commodity(self): return # converter upstreams now has values of lists intead of sets converters, converter_upstreams = self._find_converter_techs() - grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} - alt_grouped_techs = { - (f"{k[0][0]}", f"{i}"): k[1] for i, k in enumerate(converter_upstreams.items()) - } demand_group_techs = self.get_successors_for_tech_with_input_cmod( self.demand_tech, self.commodity ) + converter_upstreams[(self.commodity, self.demand_tech)] = demand_group_techs # converter_info.add((self.commodity, self.demand_tech, self.commodity)) # conversion fator recipes requires simple_graph, converters, demand_tech, grouped_techs - grouped_techs[f"{self.commodity}-{len(converter_upstreams)+1}"] = demand_group_techs - alt_grouped_techs[(self.commodity, f"{len(converter_upstreams)+1}")] = demand_group_techs - + # grouped_techs[f"{self.commodity}-{len(converter_upstreams)+1}"] = demand_group_techs + # alt_grouped_techs[(self.commodity, f"{len(converter_upstreams)+1}")] = demand_group_techs + grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} + alt_grouped_techs = { + (f"{k[0][0]}", f"{i}"): k[1] for i, k in enumerate(converter_upstreams.items()) + } # last_converter = [k for k in demand_group_techs if k in converter_techs] reversed_grouped_techs = {} for k, v in grouped_techs.items(): From 69e700e0314a7e47da0b956b12e27ddc05426576 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:57:43 -0600 Subject: [PATCH 48/69] added more subtests to the new example tests --- .../system_level/test/test_slc_examples.py | 170 ++++++++++++++++-- 1 file changed, 154 insertions(+), 16 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index c4d56bc70..ed42c9853 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -1,9 +1,6 @@ -import os - import numpy as np import pytest -from h2integrate import EXAMPLE_DIR from h2integrate.core.h2integrate_model import H2IntegrateModel @@ -409,9 +406,13 @@ def test_slc_upstream_demand(subtests, temp_copy_of_example): @pytest.mark.integration -def test_slc_complex_multi_commodity_v1(subtests): - ex_folder = EXAMPLE_DIR / "35_system_level_control" / "complex_multi_commodity" - os.chdir(ex_folder) +@pytest.mark.parametrize( + "example_folder,resource_example_folder", + [("35_system_level_control/complex_multi_commodity", None)], +) +def test_slc_complex_multi_commodity_v1(subtests, temp_copy_of_example): + ex_folder = temp_copy_of_example + h2i = H2IntegrateModel(ex_folder / "top_level_config.yaml") h2i.setup() @@ -448,11 +449,75 @@ def test_slc_complex_multi_commodity_v1(subtests): == h2i.model.get_val("nh3_load_demand.capacity_factor", units="percent")[0] ) + with subtests.test("Wind electricity set point (flexible tech)"): + wind_set_point = h2i.model.get_val( + "system_level_controller.wind_electricity_set_point", units="kW" + ) + wind_capacity = h2i.model.get_val( + "system_level_controller.wind_rated_electricity_production", units="kW" + ) + assert np.all(wind_set_point == wind_capacity) + with subtests.test("Solar electricity set point (flexible tech)"): + solar_set_point = h2i.model.get_val( + "system_level_controller.solar_electricity_set_point", units="kW" + ) + solar_capacity = h2i.model.get_val( + "system_level_controller.solar_rated_electricity_production", units="kW" + ) + assert np.all(solar_set_point == solar_capacity) + with subtests.test("Battery electricity set point (storage)"): + np.testing.assert_allclose( + h2i.model.get_val("system_level_controller.battery_electricity_set_point", units="kW"), + np.full(8760, 1119087.470593775), + rtol=1e-6, + atol=1e-6, + ) + with subtests.test("Electrolyzer hydrogen set point (dispatchable)"): + pem_set_point = h2i.model.get_val( + "system_level_controller.electrolyzer_hydrogen_set_point", units="kg/h" + ) + np.testing.assert_allclose( + pem_set_point, np.full(8760, 11744.704419585461), rtol=1e-6, atol=1e-6 + ) + with subtests.test("H2 Storage hydrogen set point (storage)"): + h2s_set_point_max = h2i.model.get_val( + "system_level_controller.h2_storage_hydrogen_set_point", units="kg/h" + ).max() + h2s_set_point_min = h2i.model.get_val( + "system_level_controller.h2_storage_hydrogen_set_point", units="kg/h" + ).min() + assert pytest.approx(11744.704419585461, rel=1e-6) == h2s_set_point_min + assert pytest.approx(23489.408839170923, rel=1e-6) == h2s_set_point_max + + with subtests.test("Haber Bosch: ammonia set point"): + assert np.all( + h2i.model.get_val("system_level_controller.haber_bosch_ammonia_set_point", units="kg/h") + == 47499.84 + ) + with subtests.test("Haber Bosch: hydrogen consumption"): + assert ( + pytest.approx(67359635.13205291, rel=1e-6) + == h2i.model.get_val("haber_bosch.hydrogen_consumed", units="kg/h").sum() + ) + with subtests.test("Haber Bosch: nitrogen consumption"): + assert ( + pytest.approx(312006087.72971725, rel=1e-6) + == h2i.model.get_val("haber_bosch.nitrogen_consumed", units="kg/h").sum() + ) + with subtests.test("Haber Bosch: electricity consumption"): + assert ( + pytest.approx(178149.21787066793, rel=1e-6) + == h2i.model.get_val("haber_bosch.electricity_consumed", units="MW").sum() + ) + @pytest.mark.integration -def test_slc_complex_nh3_with_storage(subtests): - ex_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" - os.chdir(ex_folder) +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("35_system_level_control/nh3_with_storage", None)] +) +def test_slc_complex_nh3_with_storage(subtests, temp_copy_of_example): + ex_folder = temp_copy_of_example + h2i = H2IntegrateModel(ex_folder / "top_level_config.yaml") h2i.setup() @@ -460,36 +525,109 @@ def test_slc_complex_nh3_with_storage(subtests): h2i.run() with subtests.test("LCOH"): - assert pytest.approx(4.064419131023322, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(3.8867863862476097, rel=1e-6) == h2i.model.get_val( "finance_subgroup_h2.LCOH", units="USD/kg" ) with subtests.test("LCOA - Produced"): - assert pytest.approx(1.306352207437524, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(1.2054326039224676, rel=1e-6) == h2i.model.get_val( "finance_subgroup_nh3_produced.LCOA", units="USD/kg" ) with subtests.test("LCOA - Available"): - assert pytest.approx(1.3082392786020187, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(1.207172852022115, rel=1e-6) == h2i.model.get_val( "finance_subgroup_ammonia_available.LCOA", units="USD/kg" ) with subtests.test("LCOA - Delivered"): - assert pytest.approx(1.4065238834234126, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(1.3022897725787497, rel=1e-6) == h2i.model.get_val( "finance_subgroup_nh3_delivered.LCOA", units="USD/kg" ) with subtests.test("Unmet Ammonia Demand"): assert ( - pytest.approx(102882.4504724315, rel=1e-6) + pytest.approx(79364.20686173553, rel=1e-6) == h2i.model.get_val("nh3_load_demand.unmet_ammonia_demand_out", units="t/h").sum() ) with subtests.test("Ammonia Demand Capacity Factor"): - assert pytest.approx(75.27450203676736, rel=1e-6) == h2i.model.get_val( + assert pytest.approx(81.00662271288984, rel=1e-6) == h2i.model.get_val( "nh3_load_demand.capacity_factor", units="percent" ) - # TODO: update logic to dispatch storage + with subtests.test("Wind electricity set point (flexible tech)"): + wind_set_point = h2i.model.get_val( + "system_level_controller.wind_electricity_set_point", units="kW" + ) + wind_capacity = h2i.model.get_val( + "system_level_controller.wind_rated_electricity_production", units="kW" + ) + assert np.all(wind_set_point == wind_capacity) + with subtests.test("Solar electricity set point (flexible tech)"): + solar_set_point = h2i.model.get_val( + "system_level_controller.solar_electricity_set_point", units="kW" + ) + solar_capacity = h2i.model.get_val( + "system_level_controller.solar_rated_electricity_production", units="kW" + ) + assert np.all(solar_set_point == solar_capacity) + with subtests.test("Battery electricity set point (storage)"): + np.testing.assert_allclose( + h2i.model.get_val("system_level_controller.battery_electricity_set_point", units="kW"), + np.full(8760, 1119087.470593775), + rtol=1e-6, + atol=1e-6, + ) + with subtests.test("Electrolyzer hydrogen set point (dispatchable)"): + pem_set_point = h2i.model.get_val( + "system_level_controller.electrolyzer_hydrogen_set_point", units="kg/h" + ) + np.testing.assert_allclose( + pem_set_point, np.full(8760, 11744.704419585461), rtol=1e-6, atol=1e-6 + ) + with subtests.test("H2 Storage hydrogen set point (storage)"): + h2s_set_point_max = h2i.model.get_val( + "system_level_controller.h2_storage_hydrogen_set_point", units="kg/h" + ).max() + h2s_set_point_min = h2i.model.get_val( + "system_level_controller.h2_storage_hydrogen_set_point", units="kg/h" + ).min() + assert pytest.approx(11744.704419585461, rel=1e-6) == h2s_set_point_min + assert pytest.approx(23489.408839170923, rel=1e-6) == h2s_set_point_max + + with subtests.test("Haber Bosch: ammonia set point"): + assert np.all( + h2i.model.get_val("system_level_controller.haber_bosch_ammonia_set_point", units="kg/h") + == 47700.0 + ) + with subtests.test("Haber Bosch: hydrogen consumption"): + assert ( + pytest.approx(73265794.3391077, rel=1e-6) + == h2i.model.get_val("haber_bosch.hydrogen_consumed", units="kg/h").sum() + ) + with subtests.test("Haber Bosch: nitrogen consumption"): + assert ( + pytest.approx(339363089.0568392, rel=1e-6) + == h2i.model.get_val("haber_bosch.nitrogen_consumed", units="kg/h").sum() + ) + with subtests.test("Haber Bosch: electricity consumption"): + assert ( + pytest.approx(182579.15194510925, rel=1e-6) + == h2i.model.get_val("haber_bosch.electricity_consumed", units="MW").sum() + ) + + with subtests.test("NH3 storage ammonia set point (storage)"): + nh3_set_point_max = h2i.model.get_val( + "system_level_controller.nh3_storage_ammonia_set_point", units="kg/h" + ).max() + nh3_set_point_min = h2i.model.get_val( + "system_level_controller.nh3_storage_ammonia_set_point", units="kg/h" + ).min() + assert pytest.approx(100700.0, rel=1e-6) == nh3_set_point_max + assert pytest.approx(47700.0, rel=1e-6) == nh3_set_point_min + + with subtests.test("NH3 storage: ammonia out"): + assert np.all(h2i.model.get_val("nh3_storage.ammonia_out", units="kg/h") == 0.0) + with subtests.test("Ammonia Storage Command"): assert np.all(h2i.model.get_val("nh3_storage.ammonia_command_value") == 0.0) From 52735984514b84a7e2dcf50bda1f1d7aa0e7b298 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:18:41 -0600 Subject: [PATCH 49/69] added a few doc strings --- .../system_level/system_level_control_base.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 5174178f1..29abbf35e 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1005,7 +1005,7 @@ def _find_converter_techs(self): - **converters** (tuple[str, str, str]): Set of tuples formatted as ``(input_commodity, tech_name, output_commodity)`` tuples. - - **upstreams** (dict[tuple[str,str], list[str]]): Keys are set of + - **converter_upstreams** (dict[tuple[str,str], list[str]]): Keys are set of ``(input_commodity, tech_name)`` and the values are a set of upstream technologies that output the `input_commodity` to `tech_name`. """ @@ -1195,6 +1195,20 @@ def _make_conversion_factor_recipes(self): return compounding_conversion_factor_recipes def _get_techs_to_demand_from_recipe(self, recipe_name): + """Get a list of technologies that are in a subsystem that + outputs ``input_commodity`` to the ``tech_group_name``. + + Args: + recipe_name (tuple[str,str,str]): name of recipe formatted as a tuple of + ``(input_commodity, output_commodity, tech_group_name)`` + + Raises: + ValueError: there are multiple techs + + Returns: + list[str]: list of technologies that output the ``input_commodity`` + and are connected upstream of ``tech_group_name`` + """ _, input_cmod, tech_group = recipe_name techs_to_demand = [ s @@ -1210,7 +1224,19 @@ def _get_techs_to_demand_from_recipe(self, recipe_name): return techs_in_group def _get_conversion_from_recipe(self, conversion_factors, recipe): - # Get comp + """Get the conversion factor from a recipe. + + Args: + conversion_factors (dict): dictionary with keys of 3 element tuples + formatted as ``(input_commodity, tech, output_commodity)``. + Values are an array or float of the conversion factor + ``input_commodity/output_commodity`` + recipe (list[list[tuples]]): embedded list of conversions, + a value from from the `conversion_recipes` attribute. + + Returns: + float | np.ndarray: conversion factor created from the recipe. + """ path_conversion = 1.0 for path in recipe: From 67ac420ac414d27aaeb78a257d0cc6201f03086f Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:06:10 -0600 Subject: [PATCH 50/69] moved attributes set in _post_setup_multi_commodity to be hosted as attributes in another class --- .../system_level/demand_following_control.py | 11 +-- .../system_level/system_level_control_base.py | 71 ++++++++++++++----- .../system_level/test/test_slc_baseclass.py | 16 ++--- 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index ef3d5ac9b..c2c4025a7 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -164,7 +164,7 @@ def compute(self, inputs, outputs): return converter_conversion_factors = self.get_conversion_factors( - self.converters, self.converter_upstreams, inputs + self.rename_me_config.converters, self.rename_me_config.converter_upstreams, inputs ) conversion_factor_of_1 = ( @@ -173,15 +173,16 @@ def compute(self, inputs, outputs): non_converter_conversion_factors = dict( zip( - self.non_converter_conversion_factor_keys, - [conversion_factor_of_1] * len(self.non_converter_conversion_factor_keys), + self.rename_me_config.non_converter_conversion_factor_keys, + [conversion_factor_of_1] + * len(self.rename_me_config.non_converter_conversion_factor_keys), ) ) conversion_factors = non_converter_conversion_factors | converter_conversion_factors self.tech_demands_set = [] - demand_techs = self.converter_upstreams[(self.commodity, self.demand_tech)] + demand_techs = self.rename_me_config.converter_upstreams[(self.commodity, self.demand_tech)] outputs = self.get_setpoints_for_commodity_subset( inputs, @@ -192,7 +193,7 @@ def compute(self, inputs, outputs): ) conversion_factors_tracker = {} - for recipe_name, recipe in self.conversion_recipes.items(): + for recipe_name, recipe in self.rename_me_config.conversion_recipes.items(): commodity_to_demand = recipe_name[1] techs_to_demand = self._get_techs_to_demand_from_recipe(recipe_name) conversion_factor = self._get_conversion_from_recipe(conversion_factors, recipe) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 29abbf35e..2e07a6fc5 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -84,6 +84,26 @@ def _get_buy_price_default_and_shape(tech_config, tech_name, n_timesteps, plant_ return 0.0, n_timesteps +class ChangeNameAttributeClass: + def __init__( + self, + converter_upstreams, + converters, + grouped_techs, + simple_graph, + converter_tech_names, + conversion_recipes, + non_converter_keys, + ): + self.converter_upstreams = converter_upstreams + self.converters = converters + self.grouped_techs = grouped_techs + self.simple_graph = simple_graph + self.converter_tech_names = converter_tech_names + self.conversion_recipes = conversion_recipes + self.non_converter_conversion_factor_keys = non_converter_keys + + class SystemLevelControlBase(om.ExplicitComponent): """Base class for system-level controllers. @@ -917,14 +937,26 @@ def get_group_for_tech_commodity(tech_name, output_cmod): if t not in converter_tech_names } - self.converter_upstreams = converter_upstreams - self.converters = converters - self.grouped_techs = grouped_techs - self.simple_graph = simple_graph - self.converter_tech_names = converter_tech_names - conversion_recipes = self._make_conversion_factor_recipes() - self.conversion_recipes = conversion_recipes - self.non_converter_conversion_factor_keys = non_converter_keys + # self.converter_upstreams = converter_upstreams + # self.converters = converters + # self.grouped_techs = grouped_techs + # self.simple_graph = simple_graph + # self.converter_tech_names = converter_tech_names + conversion_recipes = self._make_conversion_factor_recipes( + converters, simple_graph, grouped_techs + ) + # self.conversion_recipes = conversion_recipes + # self.non_converter_conversion_factor_keys = non_converter_keys + + self.rename_me_config = ChangeNameAttributeClass( + converter_upstreams, + converters, + grouped_techs, + simple_graph, + converter_tech_names, + conversion_recipes, + non_converter_keys, + ) def get_upstream_techs_for_commodity( self, tech_name: str, commodity: str, include_feedstock_sources=True @@ -1124,7 +1156,7 @@ def get_converter_conversion_ratio( conversion_factor = total_input / np.abs(total_output) return conversion_factor - def _make_conversion_factor_recipes(self): + def _make_conversion_factor_recipes(self, converters, simple_graph, grouped_techs): """Make recipes to for compounding conversion factor calculations. Returns: @@ -1139,23 +1171,23 @@ def _make_conversion_factor_recipes(self): if not self.multi_commodity_system: return {} - converter_tech_names = {v[1] for v in list(self.converters)} + converter_tech_names = {v[1] for v in list(converters)} # 6. Get the compounding conversion factors - in_degs = dict(self.simple_graph.in_degree) + in_degs = dict(simple_graph.in_degree) starting_techs = {k for k, v in in_degs.items() if v == 0} compounding_conversion_factor_recipes = {} for starting_tech in list(starting_techs): - paths = list(nx.all_simple_paths(self.simple_graph, starting_tech, self.demand_tech)) + paths = list(nx.all_simple_paths(simple_graph, starting_tech, self.demand_tech)) if len(paths) > 1: warnings.warn("There should only be one path", UserWarning, stacklevel=3) path = paths[0] reverse_path = path[::-1] commodity_conversions = [ - self.simple_graph.edges[p0, p1].get("commodity", None) + simple_graph.edges[p0, p1].get("commodity", None) for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) ] commodity_nodes = list(itertools.pairwise(commodity_conversions)) @@ -1174,8 +1206,8 @@ def _make_conversion_factor_recipes(self): for edge in commodity_edges: # in_cmod is demand of next tech out_cmod, in_cmod, tech = edge - if tech in self.grouped_techs: - techs_in_group = list(self.grouped_techs[tech]) + if tech in grouped_techs: + techs_in_group = list(grouped_techs[tech]) recipe = [] for t in techs_in_group: @@ -1212,13 +1244,14 @@ def _get_techs_to_demand_from_recipe(self, recipe_name): _, input_cmod, tech_group = recipe_name techs_to_demand = [ s - for s in list(self.simple_graph.predecessors(tech_group)) - if self.simple_graph.edges[s, tech_group].get("commodity", "") == input_cmod + for s in list(self.rename_me_config.simple_graph.predecessors(tech_group)) + if self.rename_me_config.simple_graph.edges[s, tech_group].get("commodity", "") + == input_cmod ] if len(techs_to_demand) != 1: raise ValueError("Unexpected situation!") - if techs_to_demand[0] in self.grouped_techs: - techs_in_group = list(self.grouped_techs[techs_to_demand[0]]) + if techs_to_demand[0] in self.rename_me_config.grouped_techs: + techs_in_group = list(self.rename_me_config.grouped_techs[techs_to_demand[0]]) else: techs_in_group = techs_to_demand[0] return techs_in_group diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index 79c1aecc2..27c992c4f 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -245,7 +245,7 @@ def test_multi_commodity_post_setup_nh3_system(subtests): ("electricity", "electrolyzer", "hydrogen"), } - converters = prob.model.slc.converters + converters = prob.model.slc.rename_me_config.converters with subtests.test("converters"): assert converters == expected_converters @@ -259,7 +259,7 @@ def test_multi_commodity_post_setup_nh3_system(subtests): # ("ammonia", "nh3_load_demand"): {"haber_bosch", "nh3_storage"}, } - converter_upstreams = prob.model.slc.converter_upstreams + converter_upstreams = prob.model.slc.rename_me_config.converter_upstreams # with subtests.test("converter upstreams"): for k, v in expected_converter_upstreams.items(): with subtests.test(f"converter upstreams {k}"): @@ -284,7 +284,7 @@ def test_multi_commodity_post_setup_nh3_system(subtests): # assert set(edges) == set(expected_edges) # Check grouped_techs - grouped_techs = prob.model.slc.grouped_techs + grouped_techs = prob.model.slc.rename_me_config.grouped_techs expected_groups = [ {"solar", "battery", "wind", "combiner", "elec_combiner"}, {"electrolyzer", "h2_storage", "h2_combiner"}, @@ -300,7 +300,7 @@ def test_multi_commodity_post_setup_nh3_system(subtests): assert len(failed_groups) == 0 # Check conversion_recipes - conversion_recipes_list = prob.model.slc.conversion_recipes + conversion_recipes_list = prob.model.slc.rename_me_config.conversion_recipes conversion_recipes = {} for k, v in conversion_recipes_list.items(): v_as_set = [set(vi) for vi in v] @@ -345,7 +345,7 @@ def test_multi_commodity_post_setup_nh3_system(subtests): assert len(conversion_recipes) == 4 # Check non_converter_conversion_factor_keys - non_converter_keys = prob.model.slc.non_converter_conversion_factor_keys + non_converter_keys = prob.model.slc.rename_me_config.non_converter_conversion_factor_keys non_converter_techs = [k[1] for k in non_converter_keys] expected_non_converter_techs = [ "nh3_storage", @@ -371,7 +371,7 @@ def test_multi_commodity_post_setup_nh3_system(subtests): with subtests.test("nh3_storage key"): assert ("ammonia", "nh3_storage", "ammonia") in non_converter_keys - converter_tech_names = prob.model.slc.converter_tech_names + converter_tech_names = prob.model.slc.rename_me_config.converter_tech_names with subtests.test("Converter tech names"): assert converter_tech_names == {"haber_bosch", "electrolyzer"} @@ -514,14 +514,14 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): ("nitrogen", "haber_bosch", "ammonia"): 2.5 / 40, ("electricity", "haber_bosch", "ammonia"): 13.0 / 40, } - non_converter_keys = prob.model.slc.non_converter_conversion_factor_keys + non_converter_keys = prob.model.slc.rename_me_config.non_converter_conversion_factor_keys non_converter_factor = 1.0 non_converter_conversion_factors = dict( zip(non_converter_keys, [non_converter_factor] * len(non_converter_keys)) ) all_conversion_factors = conversion_factors | non_converter_conversion_factors - conversion_recipes = prob.model.slc.conversion_recipes + conversion_recipes = prob.model.slc.rename_me_config.conversion_recipes # Nitrogen/Ammonia n2_recipe_name = [k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "nitrogen"][0] From 08f8557a841e18ee511891e4a6406c86ba5de37e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:17:31 -0600 Subject: [PATCH 51/69] added subtests for more complex system --- .../system_level/test/test_slc_baseclass.py | 87 ++++++++----------- 1 file changed, 36 insertions(+), 51 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index 27c992c4f..bab48bf02 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -11,10 +11,10 @@ def make_tech_classifiers(tech_list): fixed_techs = [] - flexible_techs = ["wind", "solar", "boat", "desalination"] + flexible_techs = ["wind", "solar", "desalination"] dispatchable_techs = ["electrolyzer", "haber_bosch", "natural_gas_plant", "grid_buy", "grid"] storage_techs = ["battery", "h2_storage", "nh3_storage"] - feedstock_techs = ["ng_feedstock", "n2_feedstock", "electricity_feedstock"] + feedstock_techs = ["ng_feedstock", "n2_feedstock", "electricity_feedstock", "ocean"] classifiers = {k: "flexible" for k in flexible_techs} classifiers |= {k: "dispatchable" for k in dispatchable_techs} classifiers |= {k: "storage" for k in storage_techs} @@ -98,18 +98,18 @@ def test_find_converter_techs_fake_system(subtests): # _find_converter_techs() # _find_demand_tech_group() tech_connections = [ - ["boat", "desalination", "raw_water", ""], - ["desalination", "electrolyzer", "water", ""], + ["ocean", "desalination", "salt_water", ""], + ["desalination", "electrolyzer", "fresh_water", ""], ["wind", "elec_combiner", "electricity", ""], ["solar", "elec_combiner", "electricity", ""], ["elec_combiner", "battery", "electricity", ""], ["battery", "elec_combiner_2", "electricity", ""], ["elec_combiner", "elec_combiner_2", "electricity", ""], ["elec_combiner_2", "electrolyzer", "electricity", ""], - # ["desalination", "electrolyzer", "water", ""], ["electrolyzer", "h2_storage", "hydrogen", ""], ["electrolyzer", "h2_combiner", "hydrogen", ""], ["electrolyzer", "haber_bosch", "oxygen", ""], + ["electrolyzer", "haber_bosch", "heat", ""], ["h2_storage", "h2_combiner", "hydrogen", ""], ["h2_combiner", "haber_bosch", "hydrogen", ""], ["grid", "haber_bosch", "electricity", ""], @@ -136,9 +136,38 @@ def test_find_converter_techs_fake_system(subtests): slc = make_and_setup_slc_baseclass(plant_config, {"technologies": tech_config_fake}) converters, converter_upstreams = slc._find_converter_techs() + pem_output_cmod = ["hydrogen", "heat", "oxygen"] + electrolyzer_conversions = [("electricity", "electrolyzer", c) for c in pem_output_cmod] + electrolyzer_conversions += [("fresh_water", "electrolyzer", c) for c in pem_output_cmod] + with subtests.test("Converter elements for electrolyzer"): + assert all(k in converters for k in electrolyzer_conversions) - with subtests.test("converters is not right"): - assert True + hb_conversions = [(c, "haber_bosch", "ammonia") for c in pem_output_cmod] + with subtests.test("Converter elements for haber_bosch"): + assert all(k in converters for k in hb_conversions) + + expected_converter_upstreams = { + ("salt_water", "desalination"): ["ocean"], + ("hydrogen", "haber_bosch"): ["h2_combiner", "electrolyzer", "h2_storage"], + ("heat", "haber_bosch"): ["electrolyzer"], + ("oxygen", "haber_bosch"): ["electrolyzer"], + ("nitrogen", "haber_bosch"): ["n2_feedstock"], + ("electricity", "haber_bosch"): ["grid"], + ("fresh_water", "electrolyzer"): ["desalination"], + ("electricity", "electrolyzer"): [ + "elec_combiner", + "elec_combiner_2", + "battery", + "solar", + "wind", + ], + } + + mismatched_upstreams = [ + k for k, v in expected_converter_upstreams.items() if set(converter_upstreams[k]) != set(v) + ] + with subtests.test("Converter upstreams"): + assert len(mismatched_upstreams) == 0 @pytest.mark.unit @@ -578,47 +607,3 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(eh2_recipe_name) expected_techs = ["battery", "wind", "solar", "combiner", "elec_combiner"] assert set(expected_techs) == set(techs_to_demand) - - -# @pytest.mark.unit -# def test_slc_baseclass_complex_multicommodity_no_storage(subtests): -# # TODO: finish this test? -# # h2i = object.__new__(H2IntegrateModel) -# # h2i.slc = True - -# example_folder = EXAMPLE_DIR / "35_system_level_control" / "nh3_with_storage" -# plant_config = load_plant_yaml(example_folder / "plant_config.yaml") -# tech_config = load_tech_yaml(example_folder / "tech_config.yaml") -# driver_config = load_driver_yaml(example_folder / "driver_config.yaml") - -# config_input = { -# "plant_config": plant_config, -# "technology_config": tech_config, -# "driver_config": driver_config, -# } -# h2i = H2IntegrateModel(config_input) - -# h2i.setup() - -# slc = h2i.prob.model.plant.system_level_controller - -# # Check converters -# # Check converter_upstreams -# # Check simple_graph - -# # -# # Check the grouped techs -# expected_groups = [ -# {"solar", "battery", "wind"}, -# {"electrolyzer", "h2_storage"}, -# {"electricity_feedstock"}, -# {"n2_feedstock"}, -# {"nh3_combiner", "haber_bosch", "nh3_storage"}, -# ] -# grouped_techs = slc.__getattribute__("grouped_techs") -# failed_groups = [] -# for group, techs_in_group in grouped_techs.items(): -# if not any(g == techs_in_group for g in expected_groups): -# failed_groups.append(group) -# with subtests.test("Grouped technologies is correct"): -# assert len(failed_groups) == 0 From c4d284829dcaa680334c18cc4aba3215689ce84b Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:18:25 -0600 Subject: [PATCH 52/69] added subtests for more complex system --- .../control_strategies/system_level/test/test_slc_baseclass.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index bab48bf02..e6dc2b5cf 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -87,8 +87,6 @@ def make_and_setup_slc_baseclass(plant_config, tech_config) -> SystemLevelContro # Test methods in _post_setup_multi_commodity # _find_converter_techs() -# _find_demand_tech_group() -# _find_group_for_non_input_techs # _make_conversion_factor_recipes() @@ -96,7 +94,6 @@ def make_and_setup_slc_baseclass(plant_config, tech_config) -> SystemLevelContro def test_find_converter_techs_fake_system(subtests): # Test methods in _post_setup_multi_commodity # _find_converter_techs() - # _find_demand_tech_group() tech_connections = [ ["ocean", "desalination", "salt_water", ""], ["desalination", "electrolyzer", "fresh_water", ""], From 65d1274f9f98e125fbd5aa29b587e68eccb34a0d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:54:15 -0600 Subject: [PATCH 53/69] worked on adding big doc string to ChangeNameAttributeClass --- .../system_level/system_level_control_base.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 2e07a6fc5..a7b4c04f5 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -85,6 +85,8 @@ def _get_buy_price_default_and_shape(tech_config, tech_name, n_timesteps, plant_ class ChangeNameAttributeClass: + """heterogeneous commodity hybrid system""" + def __init__( self, converter_upstreams, @@ -95,6 +97,104 @@ def __init__( conversion_recipes, non_converter_keys, ): + """_summary_ + + Attributes: + converter_upstreams (dict): _description_ + converters (set[tuple]): _description_ + grouped_techs (dict): _description_ + simple_graph (nx.DiGraph): _description_ + converter_tech_names (set[str]): _description_ + conversion_recipes (dict): _description_ + non_converter_keys (set[tuple]): _description_ + + Examples: + Below highlights what these attributes look like if we have the following system: + + >>> technology_interconnections = [ + ... ["wind", "elec_combiner", "electricity", "cable"], + ... ["solar", "elec_combiner", "electricity", "cable"], + ... ["elec_combiner", "electrolyzer", "electricity", "cable"], + ... ["electrolyzer", "haber_bosch", "hydrogen", "pipe"], + ... ["electricity_feedstock", "haber_bosch", "electricity", "cable"], + ... ["haber_bosch", "nh3_storage", "ammonia", "pipe"], + ... ["haber_bosch", "nh3_combiner", "ammonia", "pipe"], + ... ["nh3_storage", "nh3_combiner", "ammonia", "pipe"], + ... ["nh3_combiner", "nh3_load_demand", "ammonia", "pipe"], + ... ] + + >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) + { + ("electricity", "electrolyzer", "hydrogen"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia") + } + + >>> converter_upstreams # keys formatted as ("input_commodity", "tech") + { + ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], + ("electricity", "haber_bosch"): ["electricity_feedstock"], + ("hydrogen", "haber_bosch"): ["electrolyzer"], + ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] + } + + >>> converter_tech_names # set of strings + {"electrolyzer", "haber_bosch"} + + >>> non_converter_keys # formatted as (output_commodity, tech, output_commodity) + { + ("ammonia", "nh3_combiner", "ammonia"), + ("ammonia", "nh3_storage", "ammonia"), + ("electricity", "elec_combiner", "electricity"), + ("electricity", "wind", "electricity"), + ("electricity", "solar", "electricity"), + ("electricity", "electricity_feedstock", "electricity"), + } + >>> grouped_techs + { + "electricity-0": ["solar", "wind", "elec_combiner"], + "electricity-1": ["electricity_feedstock"], + "hydrogen-2": ["electrolyzer"], + "ammonia-3": ["nh3_combiner", "nh3_storage", "haber_bosch"] + } + >>> list(conversion_recipes.keys(()) + [ + ('ammonia', 'electricity', 'ammonia-3'), + ('ammonia', 'hydrogen', 'ammonia-3'), + ('hydrogen', 'electricity', 'hydrogen-2') + ] + >>> conversion_recipes[("ammonia", "electricity", "ammonia-3")] + [ + [ + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('electricity', 'haber_bosch', 'ammonia') + ] + ] + >>> conversion_recipes[("ammonia", "hydrogen", "ammonia-3")] + [ + [ + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('hydrogen', 'haber_bosch', 'ammonia') + ] + ] + >>> conversion_recipes[("hydrogen", "electricity", "hydrogen-2")] + [ + [ + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('hydrogen', 'haber_bosch', 'ammonia') + ], + [ + ('electricity', 'electrolyzer', 'hydrogen'), + ('hydrogen', 'h2_combiner', 'hydrogen'), + ('hydrogen', 'h2_storage', 'hydrogen') + ] + ] + + + """ self.converter_upstreams = converter_upstreams self.converters = converters self.grouped_techs = grouped_techs From 3aa8a6ee3462af528824040c34abcc7846bf9354 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:04:38 -0600 Subject: [PATCH 54/69] minor updates to docstrings but not done yet --- .../system_level/system_level_control_base.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index a7b4c04f5..4e2a00738 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -97,7 +97,7 @@ def __init__( conversion_recipes, non_converter_keys, ): - """_summary_ + """heterogeneous commodity hybrid system Attributes: converter_upstreams (dict): _description_ @@ -193,7 +193,6 @@ def __init__( ] ] - """ self.converter_upstreams = converter_upstreams self.converters = converters @@ -1140,6 +1139,23 @@ def _find_converter_techs(self): - **converter_upstreams** (dict[tuple[str,str], list[str]]): Keys are set of ``(input_commodity, tech_name)`` and the values are a set of upstream technologies that output the `input_commodity` to `tech_name`. + + Examples: + >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) + { + ("electricity", "electrolyzer", "hydrogen"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia") + } + + >>> converter_upstreams # keys formatted as (input_commodity, tech) + { + ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], + ("electricity", "haber_bosch"): ["electricity_feedstock"], + ("hydrogen", "haber_bosch"): ["electrolyzer"], + ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] + } + """ in_flows = dict(self.technology_graph.in_degree) out_flows = dict(self.technology_graph.out_degree) @@ -1259,6 +1275,11 @@ def get_converter_conversion_ratio( def _make_conversion_factor_recipes(self, converters, simple_graph, grouped_techs): """Make recipes to for compounding conversion factor calculations. + Args: + converters (set[tuple]): + simple_graph (nx.DiGraph): + grouped_techs (dict): + Returns: dict[tuple(str,str,str), list[list[tuple]]]: recipes to calculate the conversion ratio from the demand commodity to all upstream subsystems. From d8eec082f34dc782f8932b0b225b2cc4f9410a6d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:47:06 -0600 Subject: [PATCH 55/69] updated doc strings in SLC baseclass --- .../system_level/system_level_control_base.py | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 4e2a00738..980c394d3 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1098,6 +1098,18 @@ def get_upstream_techs_for_commodity( return list(ancestors_with_commodity & input_techs) def get_successors_for_tech_with_input_cmod(self, tech, input_commodity): + """Find technologies upstream of ``tech`` that produce ``input_commodity`` + for ``tech``. + + Args: + tech (str): Technology whose upstream suppliers are sought. + commodity (str): Commodity of interest that is an input commodity to ``tech`` + (e.g. ``"electricity"``). + + Returns: + list[str]: Controller-managed technologies upstream of ``tech`` + that produce ``commodity``. + """ in_flows = dict(self.technology_graph.in_degree) if in_flows[tech] < 1: # Tech does not have any input commodiites @@ -1134,27 +1146,31 @@ def _find_converter_techs(self): Returns: 2-element tuple containing: - - **converters** (tuple[str, str, str]): Set of tuples formatted as - ``(input_commodity, tech_name, output_commodity)`` tuples. - - **converter_upstreams** (dict[tuple[str,str], list[str]]): Keys are set of - ``(input_commodity, tech_name)`` and the values are a set of - upstream technologies that output the `input_commodity` to `tech_name`. + - **converters** *(set[tuple])*: Set of tuples formatted as + ``(input_commodity, tech_name, output_commodity)`` tuples. An + example of this variable is shown below: - Examples: - >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) - { - ("electricity", "electrolyzer", "hydrogen"), - ("electricity", "haber_bosch", "ammonia"), - ("hydrogen", "haber_bosch", "ammonia") - } + >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) + { + # (input_commodity, tech_name, output_commodity) + ("electricity", "electrolyzer", "hydrogen"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia") + } - >>> converter_upstreams # keys formatted as (input_commodity, tech) - { - ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], - ("electricity", "haber_bosch"): ["electricity_feedstock"], - ("hydrogen", "haber_bosch"): ["electrolyzer"], - ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] - } + - **converter_upstreams** *(dict[tuple[str,str], list[str]])*: Keys are set of + ``(input_commodity, tech_name)`` and the values are a set of + upstream technologies that output the `input_commodity` to `tech_name`. An + example of this variable is shown below: + + >>> converter_upstreams # keys formatted as (input_commodity, tech) + { + # (input_commodity, tech) : [techs that provide input_commodity to tech] + ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], + ("electricity", "haber_bosch"): ["electricity_feedstock"], + ("hydrogen", "haber_bosch"): ["electrolyzer"], + ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] + } """ in_flows = dict(self.technology_graph.in_degree) From bd9acd3ade833dc397e2177b8327f055177c21c2 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:18:38 -0600 Subject: [PATCH 56/69] fixed making simple graph for changes in PR 823 --- .../system_level/system_level_control_base.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index b344e14cc..69b62605e 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1007,11 +1007,11 @@ def get_group_for_tech_commodity(tech_name, output_cmod): s = reversed_grouped_techs.get(s0, [s0]) d = reversed_grouped_techs.get(d0, [d0]) - if isinstance(c, str): + if len(c) == 1: for si in s: for di in d: if si != di: - simple_graph.add_edge(si, di, commodity=c) + simple_graph.add_edge(si, di, commodity=c[0]) else: if len(d) > 1: raise ValueError("have not accounted for this design yet") @@ -1019,7 +1019,14 @@ def get_group_for_tech_commodity(tech_name, output_cmod): group_name = get_group_for_tech_commodity(s0, ci) if len(group_name) != 1: raise ValueError("have not accounted for this design yet") - simple_graph.add_edge(group_name[0], d[0], commodity=ci) + if group_name[0] != d[0]: + if not simple_graph.has_edge(group_name[0], d[0]): + # edge doesnt exist + simple_graph.add_edge(group_name[0], d[0], commodity=ci) + else: + if simple_graph.edges[group_name[0], d[0]].get("commodity") != ci: + simple_graph.add_edge(group_name[0], d[0], commodity=ci) + raise ValueError("this shouldn't happen") non_converter_keys = set() converter_tech_names = {c[1] for c in converters} From a8fe9e33f9304dfae5d2f330e665dc5a7fc0dafc Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:38:40 -0600 Subject: [PATCH 57/69] updated how slc topology and technology graph are built in test_slc_controllers.py --- .../system_level/test/test_slc_controllers.py | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py index cfd46a14c..442b1a44b 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py @@ -2,9 +2,9 @@ import numpy as np import pytest -import networkx as nx import openmdao.api as om +from h2integrate import H2IntegrateModel from h2integrate.control.control_strategies.system_level.demand_following_control import ( DemandFollowingControl, ) @@ -38,14 +38,8 @@ def _build_plant_config( def _build_technology_graph(technology_interconnections): - technology_graph = nx.DiGraph() - for connection in technology_interconnections: - source = connection[0] - destination = connection[1] - if len(connection) == 4: - technology_graph.add_edge(source, destination, commodity=connection[2]) - else: - technology_graph.add_edge(source, destination) + model = object.__new__(H2IntegrateModel) + technology_graph = model.create_technology_graph(technology_interconnections) return technology_graph @@ -68,13 +62,38 @@ def _build_slc_topology( demand_commodity_rate_units: str = "kW", storage_techs_with_control: list = [], ): - sources_to_commodities = { - (e[0], e[-1]) for e in technology_graph.edges(data="commodity") if e[-1] is not None + model = object.__new__(H2IntegrateModel) + model.technology_graph = technology_graph + model.tech_control_classifiers = tech_control_classifiers + model.technology_config = { + "technologies": { + demand_tech: { + "performance_model": {"model": "DemandComponent"}, + "model_inputs": { + "performance_parameters": { + "commodity": demand_commodity, + "commodity_rate_units": demand_commodity_rate_units, + } + }, + } + } } - - tech_to_commodities = { - (e[0], e[-1]) for e in sources_to_commodities if e[0] in tech_control_classifiers + tech_graph_edges = technology_graph.edges(data="commodity") + # make a mock-up of the technology interconnections, this is intended for use with only systems + # where one commodity is passed from each source tech to each destination tech directly + tech_interconnections = [[e[0], e[1], e[2][0], "transport"] for e in tech_graph_edges] + model.plant_config = { + "system_level_control": {"demand_component": demand_tech}, + "technology_interconnections": tech_interconnections, } + slc_topo = model._classify_slc_technologies() + # sources_to_commodities = { + # (e[0], e[-1]) for e in technology_graph.edges(data="commodity") if e[-1] is not None + # } + + # tech_to_commodities = { + # (e[0], e[-1]) for e in sources_to_commodities if e[0] in tech_control_classifiers + # } storage_techs = [k for k, v in tech_control_classifiers.items() if v == "storage"] storage_techs_to_control = { @@ -85,7 +104,7 @@ def _build_slc_topology( "demand_commodity": demand_commodity, "demand_commodity_rate_units": demand_commodity_rate_units, "demand_tech": demand_tech, - "tech_to_commodity": tech_to_commodities, + "tech_to_commodity": slc_topo["tech_to_commodity"], "storage_techs_to_control": storage_techs_to_control, "technology_graph": technology_graph, "tech_control_classifiers": tech_control_classifiers, From 414c38b6c677e718f6cc480c98ffcd4b99564454 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:46:05 -0600 Subject: [PATCH 58/69] fixed _build_slc_topology in test_slc_controllers.py --- .../system_level/test/test_slc_controllers.py | 40 ++++--------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py index 442b1a44b..545dd259f 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py @@ -62,38 +62,14 @@ def _build_slc_topology( demand_commodity_rate_units: str = "kW", storage_techs_with_control: list = [], ): - model = object.__new__(H2IntegrateModel) - model.technology_graph = technology_graph - model.tech_control_classifiers = tech_control_classifiers - model.technology_config = { - "technologies": { - demand_tech: { - "performance_model": {"model": "DemandComponent"}, - "model_inputs": { - "performance_parameters": { - "commodity": demand_commodity, - "commodity_rate_units": demand_commodity_rate_units, - } - }, - } - } - } - tech_graph_edges = technology_graph.edges(data="commodity") - # make a mock-up of the technology interconnections, this is intended for use with only systems - # where one commodity is passed from each source tech to each destination tech directly - tech_interconnections = [[e[0], e[1], e[2][0], "transport"] for e in tech_graph_edges] - model.plant_config = { - "system_level_control": {"demand_component": demand_tech}, - "technology_interconnections": tech_interconnections, - } - slc_topo = model._classify_slc_technologies() - # sources_to_commodities = { - # (e[0], e[-1]) for e in technology_graph.edges(data="commodity") if e[-1] is not None - # } + sources_to_commodities = set() + for source, _, commodities in technology_graph.edges(data="commodity"): + if commodities is not None: + sources_to_commodities.update((source, commodity) for commodity in commodities) - # tech_to_commodities = { - # (e[0], e[-1]) for e in sources_to_commodities if e[0] in tech_control_classifiers - # } + tech_to_commodities = { + (e[0], e[-1]) for e in sources_to_commodities if e[0] in tech_control_classifiers + } storage_techs = [k for k, v in tech_control_classifiers.items() if v == "storage"] storage_techs_to_control = { @@ -104,7 +80,7 @@ def _build_slc_topology( "demand_commodity": demand_commodity, "demand_commodity_rate_units": demand_commodity_rate_units, "demand_tech": demand_tech, - "tech_to_commodity": slc_topo["tech_to_commodity"], + "tech_to_commodity": tech_to_commodities, "storage_techs_to_control": storage_techs_to_control, "technology_graph": technology_graph, "tech_control_classifiers": tech_control_classifiers, From bd9c3fbfe985440936807e8e2459c5194e2fbf4b Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:15:44 -0600 Subject: [PATCH 59/69] updated post_setup_multi_commodity to not have embedded method --- .../system_level/system_level_control_base.py | 74 +++++++++---------- .../system_level/test/test_slc_baseclass.py | 7 ++ 2 files changed, 43 insertions(+), 38 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 69b62605e..d1ee320e9 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -962,43 +962,35 @@ def _post_setup_multi_commodity(self): # conversion fator recipes requires simple_graph, converters, demand_tech, grouped_techs # grouped_techs[f"{self.commodity}-{len(converter_upstreams)+1}"] = demand_group_techs # alt_grouped_techs[(self.commodity, f"{len(converter_upstreams)+1}")] = demand_group_techs - grouped_techs = {f"{k[0][0]}-{i}": k[1] for i, k in enumerate(converter_upstreams.items())} - alt_grouped_techs = { - (f"{k[0][0]}", f"{i}"): k[1] for i, k in enumerate(converter_upstreams.items()) - } - # last_converter = [k for k in demand_group_techs if k in converter_techs] + + grouped_techs = {} + groups_to_commodities = {} + + for i, k in enumerate(converter_upstreams.items()): + grouped_techs[f"{k[0][0]}-{i}"] = k[1] + groups_to_commodities[f"{k[0][0]}-{i}"] = k[0][0] + reversed_grouped_techs = {} - for k, v in grouped_techs.items(): - for vv in list(v): - if vv in reversed_grouped_techs: - # if isinstance(reversed_grouped_techs[vv], str): - # reversed_grouped_techs[vv] = [reversed_grouped_techs[vv], k] - # else: - reversed_grouped_techs[vv] = reversed_grouped_techs[vv] + [k] + reversed_commodity_groups = {} + for group_name, techs_in_group in grouped_techs.items(): + group_commodity = groups_to_commodities[group_name] + + for tech_name in list(techs_in_group): + if (tech_name, group_commodity) in reversed_commodity_groups: + msg = ( + f"The tech/commodity pair {tech_name}/{group_commodity} " + "should not be duplicated" + ) + raise ValueError(msg) + + reversed_commodity_groups[(tech_name, group_commodity)] = group_name + + if tech_name in reversed_grouped_techs: + reversed_grouped_techs[tech_name] = reversed_grouped_techs[tech_name] + [ + group_name + ] else: - reversed_grouped_techs[vv] = [k] - - def get_group_for_tech_commodity(tech_name, output_cmod): - possible_converter_grp = [k for k in converter_upstreams if k[0] == output_cmod] - if not possible_converter_grp and output_cmod == self.commodity: - groups = {f"{k[0]}-{k[1]}" for k, v in alt_grouped_techs.items() if tech_name in v} - return list(groups) - if possible_converter_grp: - possible_groups = [] - for grp in possible_converter_grp: - if tech_name in converter_upstreams[grp]: - possible_groups += [ - f"{k[0]}-{k[1]}" - for k, v in alt_grouped_techs.items() - if k[0] == output_cmod and tech_name in v - ] - - return possible_groups - warnings.warn( - f"Couldn't find group for {tech_name} producing {output_cmod}", - UserWarning, - stacklevel=3, - ) + reversed_grouped_techs[tech_name] = [group_name] simple_graph = nx.DiGraph() for e in list(self.technology_graph.edges(data="commodity")): @@ -1016,9 +1008,15 @@ def get_group_for_tech_commodity(tech_name, output_cmod): if len(d) > 1: raise ValueError("have not accounted for this design yet") for ci in c: - group_name = get_group_for_tech_commodity(s0, ci) - if len(group_name) != 1: - raise ValueError("have not accounted for this design yet") + if (s0, ci) not in reversed_commodity_groups: + raise ValueError( + f"The technology/commodity pair {s0}/{ci} is not in a group" + ) + group_name = reversed_commodity_groups[ + (s0, ci) + ] # get_group_for_tech_commodity(s0, ci) + # if len(group_name) != 1: + # raise ValueError("have not accounted for this design yet") if group_name[0] != d[0]: if not simple_graph.has_edge(group_name[0], d[0]): # edge doesnt exist diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index e6dc2b5cf..a228ebedd 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -166,6 +166,13 @@ def test_find_converter_techs_fake_system(subtests): with subtests.test("Converter upstreams"): assert len(mismatched_upstreams) == 0 + slc._post_setup_multi_commodity() + + # slc.rename_me_config.grouped_techs + # slc.rename_me_config.conversion_recipes + with subtests.test("Add in subbtests for conversion recipes"): + assert True + @pytest.mark.unit def test_find_converter_techs_nh3_system(subtests): From 59a1af1df4981d8afb627e2155388bd42e69a6ff Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:20:36 -0600 Subject: [PATCH 60/69] hopefully simplified some code while making it more robust to complex systems --- .../system_level/system_level_control_base.py | 199 +++++++++++------- 1 file changed, 124 insertions(+), 75 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index d1ee320e9..435f083be 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -963,12 +963,18 @@ def _post_setup_multi_commodity(self): # grouped_techs[f"{self.commodity}-{len(converter_upstreams)+1}"] = demand_group_techs # alt_grouped_techs[(self.commodity, f"{len(converter_upstreams)+1}")] = demand_group_techs + # dictionary with keys as a group name mapping to a list of technologies in that groups grouped_techs = {} groups_to_commodities = {} + # Not doing this as 1-liners just in case any ordering could change (unlikely) + # for i, (key, value) in enumerate(converter_upstreams.items()): + # grouped_techs[f"{key[0]}-{i}"] = value + # groups_to_commodities[f"{key[0]}-{i}"] = key[0] - for i, k in enumerate(converter_upstreams.items()): - grouped_techs[f"{k[0][0]}-{i}"] = k[1] - groups_to_commodities[f"{k[0][0]}-{i}"] = k[0][0] + for i, ((commodity, _), upstream_commodity_techs) in enumerate(converter_upstreams.items()): + group_name = f"{commodity}-{i}" + grouped_techs[group_name] = upstream_commodity_techs + groups_to_commodities[group_name] = commodity reversed_grouped_techs = {} reversed_commodity_groups = {} @@ -994,37 +1000,30 @@ def _post_setup_multi_commodity(self): simple_graph = nx.DiGraph() for e in list(self.technology_graph.edges(data="commodity")): - s0, d0, c = e - - s = reversed_grouped_techs.get(s0, [s0]) - d = reversed_grouped_techs.get(d0, [d0]) - - if len(c) == 1: - for si in s: - for di in d: - if si != di: - simple_graph.add_edge(si, di, commodity=c[0]) - else: - if len(d) > 1: - raise ValueError("have not accounted for this design yet") - for ci in c: - if (s0, ci) not in reversed_commodity_groups: - raise ValueError( - f"The technology/commodity pair {s0}/{ci} is not in a group" - ) - group_name = reversed_commodity_groups[ - (s0, ci) - ] # get_group_for_tech_commodity(s0, ci) - # if len(group_name) != 1: - # raise ValueError("have not accounted for this design yet") - if group_name[0] != d[0]: - if not simple_graph.has_edge(group_name[0], d[0]): - # edge doesnt exist - simple_graph.add_edge(group_name[0], d[0], commodity=ci) - else: - if simple_graph.edges[group_name[0], d[0]].get("commodity") != ci: - simple_graph.add_edge(group_name[0], d[0], commodity=ci) - raise ValueError("this shouldn't happen") + s0, d0, c = e # source_tech, dest_tech, commodity + if c is None or len(c) == 0: + # skip if no commodity is passed + continue + + for ci in c: + if (s0, ci) not in reversed_commodity_groups: + raise ValueError(f"The technology/commodity pair {s0}/{ci} is not in a group") + destination_groups = reversed_grouped_techs.get( + d0, [d0] + ) # list of groups with tech d0 + source_group = reversed_commodity_groups[(s0, ci)] + for dest_group in destination_groups: + if dest_group == source_group: + # skip if in same group + continue + if not simple_graph.has_edge(source_group, dest_group): + # does not have edge + simple_graph.add_edge(source_group, dest_group, commodity=ci) + + # else: + # [] + # # does have edge, check commodity + # # if simple_graph.edges[source_group, dest_group].get("commodity") != ci: non_converter_keys = set() converter_tech_names = {c[1] for c in converters} @@ -1293,6 +1292,49 @@ def get_converter_conversion_ratio( conversion_factor = total_input / np.abs(total_output) return conversion_factor + def _make_recipe_from_grouped_path( + self, simple_graph, grouped_techs, converter_tech_names, path + ): + compounding_conversion_factor_recipes = {} + + reverse_path = path[::-1] + commodity_conversions = [ + simple_graph.edges[p0, p1].get("commodity", None) + for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) + ] + commodity_nodes = list(itertools.pairwise(commodity_conversions)) + techs = reverse_path[1:] + + commodity_graph = nx.DiGraph() # nodes are commodities + for i, commod_node in enumerate(commodity_nodes): + # ammonia, hydrogen + down_cmod, up_cmod = commod_node + commodity_graph.add_edge(down_cmod, up_cmod, tech=techs[i]) + + commodity_edges = commodity_graph.edges(data="tech") + + path_recipe = [] + + for edge in commodity_edges: + # in_cmod is demand of next tech + out_cmod, in_cmod, tech = edge + if tech in grouped_techs: + techs_in_group = list(grouped_techs[tech]) + + recipe = [] + for t in techs_in_group: + if t in converter_tech_names: + recipe.append((in_cmod, t, out_cmod)) + else: + recipe.append((out_cmod, t, out_cmod)) + # TODO: add check if any other non-converter techs have a non-1 conversion factor + else: + recipe = [(in_cmod, tech, out_cmod)] + + path_recipe.append(recipe) + compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = path_recipe.copy() + return compounding_conversion_factor_recipes + def _make_conversion_factor_recipes(self, converters, simple_graph, grouped_techs): """Make recipes to for compounding conversion factor calculations. @@ -1323,48 +1365,55 @@ def _make_conversion_factor_recipes(self, converters, simple_graph, grouped_tech for starting_tech in list(starting_techs): paths = list(nx.all_simple_paths(simple_graph, starting_tech, self.demand_tech)) - - if len(paths) > 1: - warnings.warn("There should only be one path", UserWarning, stacklevel=3) - path = paths[0] - reverse_path = path[::-1] - commodity_conversions = [ - simple_graph.edges[p0, p1].get("commodity", None) - for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) - ] - commodity_nodes = list(itertools.pairwise(commodity_conversions)) - techs = reverse_path[1:] - - commodity_graph = nx.DiGraph() # nodes are commodities - for i, commod_node in enumerate(commodity_nodes): - # ammonia, hydrogen - down_cmod, up_cmod = commod_node - commodity_graph.add_edge(down_cmod, up_cmod, tech=techs[i]) - - commodity_edges = commodity_graph.edges(data="tech") - - path_recipe = [] - - for edge in commodity_edges: - # in_cmod is demand of next tech - out_cmod, in_cmod, tech = edge - if tech in grouped_techs: - techs_in_group = list(grouped_techs[tech]) - - recipe = [] - for t in techs_in_group: - if t in converter_tech_names: - recipe.append((in_cmod, t, out_cmod)) - else: - recipe.append((out_cmod, t, out_cmod)) - # TODO: add check if any other non-converter techs have a non-1 conversion factor - else: - recipe = [(in_cmod, tech, out_cmod)] - - path_recipe.append(recipe) - compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = ( - path_recipe.copy() + for path in paths: + res = self._make_recipe_from_grouped_path( + simple_graph, grouped_techs, converter_tech_names, path ) + if set(res) & set(compounding_conversion_factor_recipes): + warnings.warn("Duplicate recipes", UserWarning, stacklevel=3) + compounding_conversion_factor_recipes |= res + + # if len(paths) > 1: + # warnings.warn("There should only be one path", UserWarning, stacklevel=3) + # path = paths[0] + # reverse_path = path[::-1] + # commodity_conversions = [ + # simple_graph.edges[p0, p1].get("commodity", None) + # for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) + # ] + # commodity_nodes = list(itertools.pairwise(commodity_conversions)) + # techs = reverse_path[1:] + + # commodity_graph = nx.DiGraph() # nodes are commodities + # for i, commod_node in enumerate(commodity_nodes): + # # ammonia, hydrogen + # down_cmod, up_cmod = commod_node + # commodity_graph.add_edge(down_cmod, up_cmod, tech=techs[i]) + + # commodity_edges = commodity_graph.edges(data="tech") + + # path_recipe = [] + + # for edge in commodity_edges: + # # in_cmod is demand of next tech + # out_cmod, in_cmod, tech = edge + # if tech in grouped_techs: + # techs_in_group = list(grouped_techs[tech]) + + # recipe = [] + # for t in techs_in_group: + # if t in converter_tech_names: + # recipe.append((in_cmod, t, out_cmod)) + # else: + # recipe.append((out_cmod, t, out_cmod)) + # # TODO: add check if any non-converter techs have a non-1 conversion factor + # else: + # recipe = [(in_cmod, tech, out_cmod)] + + # path_recipe.append(recipe) + # compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = ( + # path_recipe.copy() + # ) return compounding_conversion_factor_recipes From d28ad214947877dafba0390693a30943659a95cf Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:49:32 -0600 Subject: [PATCH 61/69] added more subtests and some fixes to slc baseclass - clean up is needed --- .../system_level/system_level_control_base.py | 55 ++++++++++-- .../system_level/test/test_slc_baseclass.py | 90 +++++++++++++++---- 2 files changed, 122 insertions(+), 23 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 435f083be..bbbafbb5a 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1335,7 +1335,9 @@ def _make_recipe_from_grouped_path( compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = path_recipe.copy() return compounding_conversion_factor_recipes - def _make_conversion_factor_recipes(self, converters, simple_graph, grouped_techs): + def _make_conversion_factor_recipes( + self, converters, simple_graph, grouped_techs, use_complex_keys=False + ): """Make recipes to for compounding conversion factor calculations. Args: @@ -1360,18 +1362,49 @@ def _make_conversion_factor_recipes(self, converters, simple_graph, grouped_tech # 6. Get the compounding conversion factors in_degs = dict(simple_graph.in_degree) starting_techs = {k for k, v in in_degs.items() if v == 0} - + needs_complex_keys = False compounding_conversion_factor_recipes = {} - + cnt = 0 for starting_tech in list(starting_techs): paths = list(nx.all_simple_paths(simple_graph, starting_tech, self.demand_tech)) for path in paths: res = self._make_recipe_from_grouped_path( simple_graph, grouped_techs, converter_tech_names, path ) - if set(res) & set(compounding_conversion_factor_recipes): - warnings.warn("Duplicate recipes", UserWarning, stacklevel=3) - compounding_conversion_factor_recipes |= res + + if duplicate_recipe := set(res) & set(compounding_conversion_factor_recipes): + mismatched_recipes = [] + + for recipe_name in list(duplicate_recipe): + recipe_1 = compounding_conversion_factor_recipes[recipe_name] + recipe_2 = res[recipe_name] + if len(recipe_1) != len(recipe_2): + mismatched_recipes.append(recipe_name) + + continue + # have the same length of recipes + for i in range(len(recipe_1)): + if len(recipe_1[i]) != len(recipe_2[i]): + mismatched_recipes.append(recipe_name) + + continue + if set(recipe_1[i]) != set(recipe_2[i]): + mismatched_recipes.append(recipe_name) + + if mismatched_recipes: + needs_complex_keys = True + + # if needs_complex_keys: + # break + + if use_complex_keys: + new_res = { + (k[0], k[1], (cnt + 1, k[2])): v for i, (k, v) in enumerate(res.items()) + } + compounding_conversion_factor_recipes |= new_res + cnt += len(res) + else: + compounding_conversion_factor_recipes |= res # if len(paths) > 1: # warnings.warn("There should only be one path", UserWarning, stacklevel=3) @@ -1414,6 +1447,16 @@ def _make_conversion_factor_recipes(self, converters, simple_graph, grouped_tech # compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = ( # path_recipe.copy() # ) + if needs_complex_keys and use_complex_keys: + warnings.warn( + "Duplicate recipes still exist with complex keys", UserWarning, stacklevel=3 + ) + + if needs_complex_keys and not use_complex_keys: + compounding_conversion_factor_recipes = self._make_conversion_factor_recipes( + converters, simple_graph, grouped_techs, use_complex_keys=True + ) + return compounding_conversion_factor_recipes return compounding_conversion_factor_recipes diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index a228ebedd..6a57a5a79 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -168,10 +168,63 @@ def test_find_converter_techs_fake_system(subtests): slc._post_setup_multi_commodity() - # slc.rename_me_config.grouped_techs - # slc.rename_me_config.conversion_recipes - with subtests.test("Add in subbtests for conversion recipes"): - assert True + with subtests.test("12 distinct conversions"): + assert len(slc.rename_me_config.converters) == len(converters) + assert len(converters) == 12 + + with subtests.test("9 groups"): + assert len(slc.rename_me_config.grouped_techs) == 9 + + with subtests.test("17 conversion recipes"): + assert len(slc.rename_me_config.conversion_recipes) == 17 + + conversion_to_group_name = {} + for input_cmod, tech, output_cmod in list(slc.rename_me_config.converters): + group_name = [ + k[2][1] + for k in slc.rename_me_config.conversion_recipes + if k[0] == output_cmod and k[1] == input_cmod + ] + conversion_to_group_name[(input_cmod, tech, output_cmod)] = list(set(group_name))[0] + + grouped_tech_in_degrees = dict(slc.rename_me_config.simple_graph.in_degree()) + grouped_tech_out_degrees = dict(slc.rename_me_config.simple_graph.out_degree()) + electrolyzer_in_degs = [ + grouped_tech_in_degrees[conversion_to_group_name[con]] for con in electrolyzer_conversions + ] + with subtests.test("electrolyzer in degrees"): + assert all(k == 2 for k in electrolyzer_in_degs) + hb_in_degs = [grouped_tech_in_degrees[conversion_to_group_name[con]] for con in hb_conversions] + with subtests.test("haber bosch in degrees"): + assert all(k == 5 for k in hb_in_degs) + multi_output_grouped_techs = [k for k, v in grouped_tech_out_degrees.items() if v > 1] + electrolyzer_groups = {conversion_to_group_name[con] for con in electrolyzer_conversions} + with subtests.test("3 electrolyzer groups"): + assert len(electrolyzer_groups) == 3 + with subtests.test("out degrees are <1 except for electrolyzer upstream"): + for source_group in multi_output_grouped_techs: + assert all( + slc.rename_me_config.simple_graph.has_edge(source_group, dest_group) + for dest_group in electrolyzer_groups + ) + assert grouped_tech_out_degrees[source_group] == 3 + + recipe_names_long = [ + k + for k in slc.rename_me_config.conversion_recipes + if k[0] == "fresh_water" and k[1] == "salt_water" + ] + r0_partial = [("ammonia", "nh3_storage", "ammonia"), ("ammonia", "nh3_combiner", "ammonia")] + r2 = [("salt_water", "desalination", "fresh_water")] + with subtests.test("desalination recipes"): + for recipe_name in recipe_names_long: + recipe = slc.rename_me_config.conversion_recipes[recipe_name] + + cmod_diff = list(set(recipe[0]) - set(r0_partial)) + cmod = cmod_diff[0][0] + assert cmod in pem_output_cmod + assert ("fresh_water", "electrolyzer", cmod) in recipe[1] + assert recipe[2] == r2 @pytest.mark.unit @@ -302,19 +355,22 @@ def test_multi_commodity_post_setup_nh3_system(subtests): assert input_tech_upstreams == v # Check simple_graph - # simple_graph = prob.model.slc.simple_graph - # edges = list(simple_graph.edges(data="commodity")) - # expected_edges = [ - # ("electricity-0", "hydrogen-2", "electricity"), - # ("hydrogen-2", "ammonia-5", "hydrogen"), - # ("ammonia-5", "nh3_load_demand", "ammonia"), - # ("nitrogen-1", "ammonia-5", "nitrogen"), - # ("electricity-3", "ammonia-5", "electricity"), - # ] - - # with subtests.test("simple_graph edges"): - # # assert not bool(set(edges) ^ set(expected_edges)) - # assert set(edges) == set(expected_edges) + simple_graph = prob.model.slc.rename_me_config.simple_graph + edges = list(simple_graph.edges(data="commodity")) + with subtests.test("number of edges"): + assert len(edges) == 5 + non_numbered_edges = [(k[0].split("-")[0], k[1].split("-")[0], k[2]) for k in edges] + expected_edges = [ + ("electricity", "hydrogen", "electricity"), + ("hydrogen", "ammonia", "hydrogen"), + ("ammonia", "nh3_load_demand", "ammonia"), + ("nitrogen", "ammonia", "nitrogen"), + ("electricity", "ammonia", "electricity"), + ] + + with subtests.test("simple_graph edges"): + # assert not bool(set(edges) ^ set(expected_edges)) + assert set(non_numbered_edges) == set(expected_edges) # Check grouped_techs grouped_techs = prob.model.slc.rename_me_config.grouped_techs From 5d03e9c2286326d0dc86147536633645a1cff26e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:53:45 -0600 Subject: [PATCH 62/69] minor update to method --- .../system_level/system_level_control_base.py | 49 +++---------------- 1 file changed, 6 insertions(+), 43 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index bbbafbb5a..ba75272d5 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1399,54 +1399,13 @@ def _make_conversion_factor_recipes( if use_complex_keys: new_res = { - (k[0], k[1], (cnt + 1, k[2])): v for i, (k, v) in enumerate(res.items()) + (k[0], k[1], (cnt + i, k[2])): v for i, (k, v) in enumerate(res.items()) } compounding_conversion_factor_recipes |= new_res cnt += len(res) else: compounding_conversion_factor_recipes |= res - # if len(paths) > 1: - # warnings.warn("There should only be one path", UserWarning, stacklevel=3) - # path = paths[0] - # reverse_path = path[::-1] - # commodity_conversions = [ - # simple_graph.edges[p0, p1].get("commodity", None) - # for p0, p1 in zip(reverse_path[1:], reverse_path[:-1]) - # ] - # commodity_nodes = list(itertools.pairwise(commodity_conversions)) - # techs = reverse_path[1:] - - # commodity_graph = nx.DiGraph() # nodes are commodities - # for i, commod_node in enumerate(commodity_nodes): - # # ammonia, hydrogen - # down_cmod, up_cmod = commod_node - # commodity_graph.add_edge(down_cmod, up_cmod, tech=techs[i]) - - # commodity_edges = commodity_graph.edges(data="tech") - - # path_recipe = [] - - # for edge in commodity_edges: - # # in_cmod is demand of next tech - # out_cmod, in_cmod, tech = edge - # if tech in grouped_techs: - # techs_in_group = list(grouped_techs[tech]) - - # recipe = [] - # for t in techs_in_group: - # if t in converter_tech_names: - # recipe.append((in_cmod, t, out_cmod)) - # else: - # recipe.append((out_cmod, t, out_cmod)) - # # TODO: add check if any non-converter techs have a non-1 conversion factor - # else: - # recipe = [(in_cmod, tech, out_cmod)] - - # path_recipe.append(recipe) - # compounding_conversion_factor_recipes[(out_cmod, in_cmod, tech)] = ( - # path_recipe.copy() - # ) if needs_complex_keys and use_complex_keys: warnings.warn( "Duplicate recipes still exist with complex keys", UserWarning, stacklevel=3 @@ -1476,6 +1435,9 @@ def _get_techs_to_demand_from_recipe(self, recipe_name): and are connected upstream of ``tech_group_name`` """ _, input_cmod, tech_group = recipe_name + if isinstance(tech_group, tuple): + _, tech_group = tech_group + techs_to_demand = [ s for s in list(self.rename_me_config.simple_graph.predecessors(tech_group)) @@ -1505,7 +1467,8 @@ def _get_conversion_from_recipe(self, conversion_factors, recipe): float | np.ndarray: conversion factor created from the recipe. """ path_conversion = 1.0 - + # TODO: update to handle more complex systems + # (or maybe do it external to this method) for path in recipe: for tech_conversion in path: path_conversion *= conversion_factors.get(tech_conversion, 1.0) From a9a758dac23882f54b37bf0e9de7d3118da6e534 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:25:14 -0600 Subject: [PATCH 63/69] Added more docstrings and worked to clean up some code --- .../system_level/system_level_control_base.py | 125 +++++++++++++----- 1 file changed, 94 insertions(+), 31 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index ba75272d5..62cbebe9c 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -948,6 +948,25 @@ def _feedstock_marginal_cost(self, inputs, marginal_cost_data): return np.full(self.n_timesteps, marginal_cost_scalar) def _post_setup_multi_commodity(self): + """This method creates sets the attribute ``rename_me_config``, which is a + ``ChangeNameAttributeClass`` object. This method is only used in + heterogeneous commodity hybrid system (HCHS). Below is a summary of what this method does: + + 1. Find the converter technologies and the technologies upstream of them. + This is done by calling the method ``_find_converter_techs()``. + 2. Use the ``converter_upstreams`` made in Step 1 to group together technologies + with the same output commodity and the same downstream converter. A single + technology may exist in multiple groups if it has multiple commodities + connected to another component. + 3. Create ``simple_graph`` - a directional graph representation of the + grouped technologies from Step 2. + 4. Create keys for a conversion recipe based on the technologies that + are do not convert one commodity to another. + 5. Create recipes to convert from the demand to the demand for each group + of technologies/commodities, using ``_make_conversion_factor_recipes()`` + + + """ if not self.multi_commodity_system: return # converter upstreams now has values of lists intead of sets @@ -1000,30 +1019,35 @@ def _post_setup_multi_commodity(self): simple_graph = nx.DiGraph() for e in list(self.technology_graph.edges(data="commodity")): - s0, d0, c = e # source_tech, dest_tech, commodity - if c is None or len(c) == 0: + source_tech, dest_tech, commodities = e # source_tech, dest_tech, commodity + if commodities is None or len(commodities) == 0: # skip if no commodity is passed continue - for ci in c: - if (s0, ci) not in reversed_commodity_groups: - raise ValueError(f"The technology/commodity pair {s0}/{ci} is not in a group") - destination_groups = reversed_grouped_techs.get( - d0, [d0] - ) # list of groups with tech d0 - source_group = reversed_commodity_groups[(s0, ci)] + for commod in commodities: + if (source_tech, commod) not in reversed_commodity_groups: + msg = ( + f"The technology/commodity pair {source_tech}/{commod} " "is not in a group" + ) + raise ValueError(msg) + # groups containing ``dest_tech`` + destination_groups = reversed_grouped_techs.get(dest_tech, [dest_tech]) + # group containing ``source_tech`` that output ``commod`` + source_group = reversed_commodity_groups[(source_tech, commod)] for dest_group in destination_groups: if dest_group == source_group: # skip if in same group continue if not simple_graph.has_edge(source_group, dest_group): # does not have edge - simple_graph.add_edge(source_group, dest_group, commodity=ci) + simple_graph.add_edge(source_group, dest_group, commodity=commod) - # else: - # [] - # # does have edge, check commodity - # # if simple_graph.edges[source_group, dest_group].get("commodity") != ci: + else: + msg = ( + f"The edge for ({source_group}, {dest_group}, {commod}) " + "should not already exist." + ) + warnings.warn(msg, UserWarning, stacklevel=3) non_converter_keys = set() converter_tech_names = {c[1] for c in converters} @@ -1040,16 +1064,9 @@ def _post_setup_multi_commodity(self): if t not in converter_tech_names } - # self.converter_upstreams = converter_upstreams - # self.converters = converters - # self.grouped_techs = grouped_techs - # self.simple_graph = simple_graph - # self.converter_tech_names = converter_tech_names conversion_recipes = self._make_conversion_factor_recipes( converters, simple_graph, grouped_techs ) - # self.conversion_recipes = conversion_recipes - # self.non_converter_conversion_factor_keys = non_converter_keys self.rename_me_config = ChangeNameAttributeClass( converter_upstreams, @@ -1341,18 +1358,64 @@ def _make_conversion_factor_recipes( """Make recipes to for compounding conversion factor calculations. Args: - converters (set[tuple]): - simple_graph (nx.DiGraph): - grouped_techs (dict): + converters (set[tuple]): Set of tuples formatted as + ``(input_commodity, tech_name, output_commodity)``. An + example of this variable is shown below: + + >>> converters + { + # (input_commodity, tech_name, output_commodity) + ("electricity", "electrolyzer", "hydrogen"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia") + } + + simple_graph (nx.DiGraph): graph representing the connections + of the technology groups in ``grouped_techs`` + grouped_techs (dict): dictionary with keys as the group name + and values of the technologies within that group. + + >>> grouped_techs + { + "electricity-0": ["solar", "wind", "elec_combiner"], + "electricity-1": ["electricity_feedstock"], + "hydrogen-2": ["electrolyzer"], + "ammonia-3": ["nh3_combiner", "nh3_storage", "haber_bosch"] + } + + use_complex_keys (bool, optional): If True, use key names formatted as + ``(output_commodity, input_commodity, (i, converter_tech_group))``. + Defaults to False. Returns: - dict[tuple(str,str,str), list[list[tuple]]]: recipes to calculate the - conversion ratio from the demand commodity to all upstream subsystems. - Keys are the recipe name, formatted as tuples of - `(output_commodity, input_commodity, converter_tech_group)`. - Values are embedded lists. Each list defines the technologies in a - step of the conversion. Each element of a list is a tuple formatted as - `(input_commodity, technology, output_commodity)` + dict[tuple[str], list[list]]: recipes to calculate the conversion ratio from + the demand commodity to all upstream subsystems. Keys are the recipe name, which + are tuples ``(output_commodity, input_commodity, converter_tech_group)``. + + Values are embedded lists. Each list defines the technologies in a + step of the conversion. Each element of a list is a tuple formatted as + ``(input_commodity, technology, output_commodity)``. An example is shown below. + + >>> conversion_recipes[("hydrogen", "electricity", "hydrogen-2")] + [ + [ + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('hydrogen', 'haber_bosch', 'ammonia') + ], + [ + ('electricity', 'electrolyzer', 'hydrogen'), + ('hydrogen', 'h2_combiner', 'hydrogen'), + ('hydrogen', 'h2_storage', 'hydrogen') + ] + ] + + Note: + For more complext system architecturs, the conversion recipe keys + may be formatted as + ``(output_commodity, input_commodity, (i, converter_tech_group))`` + where ``i`` is a unique number to distinguish the recipe. + """ if not self.multi_commodity_system: return {} From 7cc69c4596297b34f01f0450c8fec9c970e01e69 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:27:41 -0600 Subject: [PATCH 64/69] removed homework.py --- .../system_level/homework.py | 112 ------------------ 1 file changed, 112 deletions(-) delete mode 100644 h2integrate/control/control_strategies/system_level/homework.py diff --git a/h2integrate/control/control_strategies/system_level/homework.py b/h2integrate/control/control_strategies/system_level/homework.py deleted file mode 100644 index c0a32c025..000000000 --- a/h2integrate/control/control_strategies/system_level/homework.py +++ /dev/null @@ -1,112 +0,0 @@ -import networkx as nx - - -# BELOW HERE IS THE INFORMATION YOU HAVE -tech_connections = [ - ["boat", "desalination", "raw_water"], - ["desalination", "electrolyzer", "water"], - ["wind", "elec_combiner", "electricity"], - ["solar", "elec_combiner", "electricity"], - ["elec_combiner", "battery", "electricity"], - ["battery", "elec_combiner_2", "electricity"], - ["elec_combiner", "elec_combiner_2", "electricity"], - ["elec_combiner_2", "electrolyzer", "electricity"], - ["electrolyzer", "h2_storage", "hydrogen"], - ["electrolyzer", "h2_combiner", "hydrogen"], - ["h2_storage", "h2_combiner", "hydrogen"], - ["h2_combiner", "haber_bosch", "hydrogen"], - ["grid", "haber_bosch", "electricity"], - ["haber_bosch", "nh3_demand", "ammonia"], -] - -input_techs = [ - "boat", - "desalination", - "wind", - "solar", - "battery", - "electrolyzer", - "h2_storage", - "haber_bosch", - "grid", -] -demand_tech = "nh3_demand" - -technology_graph = nx.DiGraph() -for connection in tech_connections: - technology_graph.add_edge(connection[0], connection[1], commodity=connection[2]) - -# techs and their output commodities -techs_to_commodities = { - ("wind", "electricity"), - ("solar", "electricity"), - ("battery", "electricity"), - ("electrolyzer", "hydrogen"), - ("h2_storage", "hydrogen"), - ("boat", "raw_water"), - ("desalination", "water"), - ("grid", "electricity"), - ("haber_bosch", "ammonia"), -} - -upstreams = { - # (input_commodity, technology): {upstream techs that make input_commodity} - ("electricity", "haber_bosch"): {"grid"}, - ("hydrogen", "haber_bosch"): {"electrolyzer", "h2_storage"}, - ("electricity", "electrolyzer"): {"solar", "battery", "wind"}, - ("water", "electrolyzer"): {"desalination", "water_storage"}, - ("raw_water", "desalination"): {"boat"}, -} - -hb_e2a = 1.75 -hb_h2a = 2.0 -pem_e2h = 0.5 -pem_w2h = 5.0 -des_rw2w = 1 / 5 -conversion_factors = { - # (input_commodity, converter tech point, output_commodity): conversion factor - ("electricity", "haber_bosch", "ammonia"): hb_e2a, - ("hydrogen", "haber_bosch", "ammonia"): hb_h2a, - ("electricity", "electrolyzer", "hydrogen"): pem_e2h, - ("water", "electrolyzer", "hydrogen"): pem_w2h, - ("raw_water", "desalination", "water"): des_rw2w, -} - -ammonia_demand = 80.0 -converter_technologies = {k[1] for k, v in upstreams.items()} -# ABOVE HERE IS THE INFORMATION YOU HAVE - - -# how to convert the ammonia demand to the demand of other components at each step -# ex: grid_electricity_demand = ammonia_demand*1.75 -# grid demand is ammonia_demand*1.75 -# how do we get the -# 0) electricity demand for grid -# 1) hydrogen demand for the electrolyzer and h2 storage system -# 2) electricity demand for the wind, solar, and battery system -# 3) water demand for the desalination plant -# 4) raw_water demand for the boat - -# --- put attempted solution here --- - -# --- put attempted solution above --- - - -# Below can be used to test your result to see if its been done properly -nh3_dmd = 80.0 -# NOTE: the expected results formatting is a little stupid -expected_results = { - ("electricity", "haber_bosch"): nh3_dmd * hb_e2a, # electricity demand for grid - ("hydrogen", "haber_bosch"): nh3_dmd - * hb_h2a, # hydrogen demand for the electrolyzer and h2 storage system - ("electricity", "electrolyzer"): nh3_dmd - * hb_h2a - * pem_e2h, # electricity demand for the wind, solar, and battery system - ("water", "electrolyzer"): nh3_dmd - * hb_h2a - * pem_w2h, # water demand for the desalination plant - ("raw_water", "desalination"): nh3_dmd - * hb_h2a - * pem_w2h - * des_rw2w, # raw_water demand for the boat -} From de38141133927bd4d0aaddcb61de461292315d9a Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:13:27 -0600 Subject: [PATCH 65/69] updated doc strings for SLC base class --- .../system_level/system_level_control_base.py | 121 +++++++++++------- 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 62cbebe9c..ae1d3b146 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -953,18 +953,21 @@ def _post_setup_multi_commodity(self): heterogeneous commodity hybrid system (HCHS). Below is a summary of what this method does: 1. Find the converter technologies and the technologies upstream of them. - This is done by calling the method ``_find_converter_techs()``. + This is done by calling the method ``_find_converter_techs()``. + 2. Use the ``converter_upstreams`` made in Step 1 to group together technologies - with the same output commodity and the same downstream converter. A single - technology may exist in multiple groups if it has multiple commodities - connected to another component. + with the same output commodity and the same downstream converter. A single + technology may exist in multiple groups if it has multiple commodities + connected to another component. + 3. Create ``simple_graph`` - a directional graph representation of the - grouped technologies from Step 2. + grouped technologies from Step 2. + 4. Create keys for a conversion recipe based on the technologies that - are do not convert one commodity to another. - 5. Create recipes to convert from the demand to the demand for each group - of technologies/commodities, using ``_make_conversion_factor_recipes()`` + are do not convert one commodity to another. + 5. Create recipes to convert from the demand to the demand for each group + of technologies/commodities, using ``_make_conversion_factor_recipes()`` """ if not self.multi_commodity_system: @@ -1129,7 +1132,7 @@ def get_successors_for_tech_with_input_cmod(self, tech, input_commodity): Returns: list[str]: Controller-managed technologies upstream of ``tech`` - that produce ``commodity``. + that produce ``commodity``. """ in_flows = dict(self.technology_graph.in_degree) if in_flows[tech] < 1: @@ -1168,8 +1171,8 @@ def _find_converter_techs(self): 2-element tuple containing: - **converters** *(set[tuple])*: Set of tuples formatted as - ``(input_commodity, tech_name, output_commodity)`` tuples. An - example of this variable is shown below: + ``(input_commodity, tech_name, output_commodity)`` tuples. An + example of this variable is shown below: >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) { @@ -1180,9 +1183,9 @@ def _find_converter_techs(self): } - **converter_upstreams** *(dict[tuple[str,str], list[str]])*: Keys are set of - ``(input_commodity, tech_name)`` and the values are a set of - upstream technologies that output the `input_commodity` to `tech_name`. An - example of this variable is shown below: + ``(input_commodity, tech_name)`` and the values are a set of + upstream technologies that output the `input_commodity` to `tech_name`. An + example of this variable is shown below: >>> converter_upstreams # keys formatted as (input_commodity, tech) { @@ -1262,7 +1265,7 @@ def get_converter_capacity_conversion_ratio( that produce ``in_cmod`` to the ``converter_tech`` Returns: - float | np.ndarray: capacity ratio of `in_cmod/out_cmod`. + float | np.ndarray: capacity ratio of ``in_cmod/out_cmod``. """ rated_name_fmt = "{tech}_rated_{commod}_production" feedstock_name_fmt = "{tech}_{commod}_out" @@ -1298,7 +1301,7 @@ def get_converter_conversion_ratio( that produce ``in_cmod`` to the ``converter_tech`` Returns: - np.ndarray: conversion ratio of `in_cmod/out_cmod`. + np.ndarray: conversion ratio of ``in_cmod/out_cmod``. """ input_name_fmt = "{tech}_{commod}_out" in_names = [input_name_fmt.format(tech=t, commod=in_cmod) for t in list(tech_ancestors)] @@ -1389,32 +1392,32 @@ def _make_conversion_factor_recipes( Returns: dict[tuple[str], list[list]]: recipes to calculate the conversion ratio from - the demand commodity to all upstream subsystems. Keys are the recipe name, which - are tuples ``(output_commodity, input_commodity, converter_tech_group)``. + the demand commodity to all upstream subsystems. Keys are the recipe name, which + are tuples ``(output_commodity, input_commodity, converter_tech_group)``. - Values are embedded lists. Each list defines the technologies in a - step of the conversion. Each element of a list is a tuple formatted as - ``(input_commodity, technology, output_commodity)``. An example is shown below. + Values are embedded lists. Each list defines the technologies in a + step of the conversion. Each element of a list is a tuple formatted as + ``(input_commodity, technology, output_commodity)``. An example is shown below. - >>> conversion_recipes[("hydrogen", "electricity", "hydrogen-2")] + >>> conversion_recipes[("hydrogen", "electricity", "hydrogen-2")] + [ [ - [ - ('ammonia', 'nh3_combiner', 'ammonia'), - ('ammonia', 'nh3_storage', 'ammonia'), - ('hydrogen', 'haber_bosch', 'ammonia') - ], - [ - ('electricity', 'electrolyzer', 'hydrogen'), - ('hydrogen', 'h2_combiner', 'hydrogen'), - ('hydrogen', 'h2_storage', 'hydrogen') - ] + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('hydrogen', 'haber_bosch', 'ammonia') + ], + [ + ('electricity', 'electrolyzer', 'hydrogen'), + ('hydrogen', 'h2_combiner', 'hydrogen'), + ('hydrogen', 'h2_storage', 'hydrogen') ] + ] - Note: - For more complext system architecturs, the conversion recipe keys - may be formatted as - ``(output_commodity, input_commodity, (i, converter_tech_group))`` - where ``i`` is a unique number to distinguish the recipe. + Note: + For more complext system architecturs, the conversion recipe keys + may be formatted as + ``(output_commodity, input_commodity, (i, converter_tech_group))`` + where ``i`` is a unique number to distinguish the recipe. """ if not self.multi_commodity_system: @@ -1487,15 +1490,18 @@ def _get_techs_to_demand_from_recipe(self, recipe_name): outputs ``input_commodity`` to the ``tech_group_name``. Args: - recipe_name (tuple[str,str,str]): name of recipe formatted as a tuple of - ``(input_commodity, output_commodity, tech_group_name)`` + recipe_name (tuple): name of recipe formatted as a tuple of + ``(input_commodity, output_commodity, tech_group_name)`` or + ``(input_commodity, output_commodity, (i,tech_group_name))``. + This should be a key from the dictionary returned from + ``_make_conversion_factor_recipes()``. Raises: ValueError: there are multiple techs Returns: list[str]: list of technologies that output the ``input_commodity`` - and are connected upstream of ``tech_group_name`` + and are connected upstream of ``tech_group_name`` """ _, input_cmod, tech_group = recipe_name if isinstance(tech_group, tuple): @@ -1520,14 +1526,41 @@ def _get_conversion_from_recipe(self, conversion_factors, recipe): Args: conversion_factors (dict): dictionary with keys of 3 element tuples - formatted as ``(input_commodity, tech, output_commodity)``. - Values are an array or float of the conversion factor - ``input_commodity/output_commodity`` + formatted as ``(input_commodity, tech, output_commodity)``. + Values are an array or float of the conversion factor + ``input_commodity/output_commodity``. An example is shown below: + + >>> conversion_factors + { + ('electricity','electrolyzer','hydrogen'): 55.5, + ('water','electrolyzer','hydrogen'): 40.0, + ('electricity','electrolyzer','oxygen'): 60.0 + } + recipe (list[list[tuples]]): embedded list of conversions, - a value from from the `conversion_recipes` attribute. + a value from from the ``conversion_recipes`` attribute. + This should be a value from the dictionary returned from + ``_make_conversion_factor_recipes()``. + + >>> recipe + [ + [ + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('hydrogen', 'haber_bosch', 'ammonia') + ], + [ + ('electricity', 'electrolyzer', 'hydrogen'), + ('hydrogen', 'h2_combiner', 'hydrogen'), + ('hydrogen', 'h2_storage', 'hydrogen') + ] + ] + Returns: float | np.ndarray: conversion factor created from the recipe. + + """ path_conversion = 1.0 # TODO: update to handle more complex systems From aa03967af579235935d80e4a64b15982885ac6c0 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:24:55 -0600 Subject: [PATCH 66/69] renamed some methods --- .../system_level/demand_following_control.py | 6 ++-- .../system_level/system_level_control_base.py | 6 ++-- .../system_level/test/test_slc_baseclass.py | 34 +++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index c2c4025a7..03d6ebde8 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -141,7 +141,7 @@ def get_conversion_factors(self, converters, converter_upstreams, inputs): nan_indices = np.argwhere(~np.isnan(conversion_ratio)).flatten() bad_indices = list(set(inf_indices) | set(nan_indices)) - capacity_ratio = self.get_converter_capacity_conversion_ratio( + capacity_ratio = self.get_converter_capacity_ratio( inputs, input_cmod, output_cmod, @@ -195,8 +195,8 @@ def compute(self, inputs, outputs): conversion_factors_tracker = {} for recipe_name, recipe in self.rename_me_config.conversion_recipes.items(): commodity_to_demand = recipe_name[1] - techs_to_demand = self._get_techs_to_demand_from_recipe(recipe_name) - conversion_factor = self._get_conversion_from_recipe(conversion_factors, recipe) + techs_to_demand = self.get_techs_to_demand_from_recipe(recipe_name) + conversion_factor = self.get_conversion_from_recipe(conversion_factors, recipe) demand = inputs[self.demand_input_name].copy() * conversion_factor outputs = self.get_setpoints_for_commodity_subset( inputs, diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index ae1d3b146..ab0fadb0b 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1251,7 +1251,7 @@ def _find_converter_techs(self): return converter_info, converter_upstreams - def get_converter_capacity_conversion_ratio( + def get_converter_capacity_ratio( self, inputs, in_cmod, out_cmod, converter_tech, tech_ancestors ): """Get capacity ratio of ``in_cmod/out_cmod`` for technology ``converter_tech`` @@ -1485,7 +1485,7 @@ def _make_conversion_factor_recipes( return compounding_conversion_factor_recipes - def _get_techs_to_demand_from_recipe(self, recipe_name): + def get_techs_to_demand_from_recipe(self, recipe_name): """Get a list of technologies that are in a subsystem that outputs ``input_commodity`` to the ``tech_group_name``. @@ -1521,7 +1521,7 @@ def _get_techs_to_demand_from_recipe(self, recipe_name): techs_in_group = techs_to_demand[0] return techs_in_group - def _get_conversion_from_recipe(self, conversion_factors, recipe): + def get_conversion_from_recipe(self, conversion_factors, recipe): """Get the conversion factor from a recipe. Args: diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index 6a57a5a79..c0f0375e5 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -466,10 +466,10 @@ def test_multi_commodity_post_setup_nh3_system(subtests): # Test methods used by Demand Following -# `get_converter_capacity_conversion_ratio` +# `get_converter_capacity_ratio` # `get_converter_conversion_ratio` -# `_get_conversion_from_recipe` -# `_get_techs_to_demand_from_recipe` +# `get_conversion_from_recipe` +# `get_techs_to_demand_from_recipe` @pytest.mark.unit @@ -543,12 +543,12 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): "electricity_feedstock_electricity_out": np.full(8760, 13.0), } - # Test `get_converter_capacity_conversion_ratio` and `get_converter_conversion_ratio` + # Test `get_converter_capacity_ratio` and `get_converter_conversion_ratio` # Electricity to hydrogen elec_per_h2_ratio = prob.model.slc.get_converter_conversion_ratio( fake_inputs, "electricity", "hydrogen", "electrolyzer", ["battery", "wind", "solar"] ) - elec_per_h2_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + elec_per_h2_capac_ratio = prob.model.slc.get_converter_capacity_ratio( fake_inputs, "electricity", "hydrogen", "electrolyzer", ["battery", "wind", "solar"] ) elec_capac = 30.0 + 25.0 + 16.0 @@ -562,7 +562,7 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): h2_per_nh3_ratio = prob.model.slc.get_converter_conversion_ratio( fake_inputs, "hydrogen", "ammonia", "haber_bosch", ["electrolyzer", "h2_storage"] ) - h2_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + h2_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_ratio( fake_inputs, "hydrogen", "ammonia", "haber_bosch", ["electrolyzer", "h2_storage"] ) h2_capac = 71.0 + 14.0 @@ -576,7 +576,7 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): n2_per_nh3_ratio = prob.model.slc.get_converter_conversion_ratio( fake_inputs, "nitrogen", "ammonia", "haber_bosch", ["n2_feedstock"] ) - n2_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + n2_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_ratio( fake_inputs, "nitrogen", "ammonia", "haber_bosch", ["n2_feedstock"] ) with subtests.test("Nitrogen/Ammonia conversion ratio"): @@ -588,7 +588,7 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): elec_per_nh3_ratio = prob.model.slc.get_converter_conversion_ratio( fake_inputs, "electricity", "ammonia", "haber_bosch", ["electricity_feedstock"] ) - elec_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_conversion_ratio( + elec_per_nh3_capac_ratio = prob.model.slc.get_converter_capacity_ratio( fake_inputs, "electricity", "ammonia", "haber_bosch", ["electricity_feedstock"] ) with subtests.test("Electricity/Ammonia conversion ratio"): @@ -596,7 +596,7 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): with subtests.test("Electricity/Ammonia capacity ratio"): assert pytest.approx(13.0 / 50.0, rel=1e-6) == elec_per_nh3_capac_ratio - # Test `_get_conversion_from_recipe` and `_get_techs_to_demand_from_recipe` + # Test `get_conversion_from_recipe` and `get_techs_to_demand_from_recipe` conversion_factors = { ("electricity", "electrolyzer", "hydrogen"): elec_gen / 39.0, ("hydrogen", "haber_bosch", "ammonia"): (h2_gen / 40).mean(), @@ -616,12 +616,12 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): n2_recipe_name = [k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "nitrogen"][0] # n2_recipe_name = ("ammonia", "nitrogen", "ammonia-5") with subtests.test("Nitrogen/Ammonia Conversion Factor"): - conversion_factor = prob.model.slc._get_conversion_from_recipe( + conversion_factor = prob.model.slc.get_conversion_from_recipe( all_conversion_factors, conversion_recipes[n2_recipe_name] ) assert pytest.approx(2.5 / 40.0, rel=1e-6) == conversion_factor with subtests.test("Nitrogen/Ammonia Techs"): - techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(n2_recipe_name) + techs_to_demand = prob.model.slc.get_techs_to_demand_from_recipe(n2_recipe_name) assert ["n2_feedstock"] == techs_to_demand # Electricity/Ammonia @@ -630,25 +630,25 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): ][0] # elec_recipe_name = ("ammonia", "electricity", "ammonia-5") with subtests.test("Electricity/Ammonia Conversion Factor"): - conversion_factor = prob.model.slc._get_conversion_from_recipe( + conversion_factor = prob.model.slc.get_conversion_from_recipe( all_conversion_factors, conversion_recipes[elec_recipe_name] ) assert pytest.approx(13.0 / 40.0, rel=1e-6) == conversion_factor with subtests.test("Electricity/Ammonia Techs"): - techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(elec_recipe_name) + techs_to_demand = prob.model.slc.get_techs_to_demand_from_recipe(elec_recipe_name) assert ["electricity_feedstock"] == techs_to_demand # Hydrogen/Ammonia h2_recipe_name = [k for k in conversion_recipes if k[0] == "ammonia" and k[1] == "hydrogen"][0] # h2_recipe_name = ("ammonia", "hydrogen", "ammonia-5") with subtests.test("Hydrogen/Ammonia Conversion Factor"): - conversion_factor = prob.model.slc._get_conversion_from_recipe( + conversion_factor = prob.model.slc.get_conversion_from_recipe( all_conversion_factors, conversion_recipes[h2_recipe_name] ) assert pytest.approx((h2_gen / 40).mean(), rel=1e-6) == conversion_factor with subtests.test("Hydrogen/Ammonia Techs"): - techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(h2_recipe_name) + techs_to_demand = prob.model.slc.get_techs_to_demand_from_recipe(h2_recipe_name) expected_techs = ["h2_storage", "electrolyzer", "h2_combiner"] assert set(expected_techs) == set(techs_to_demand) @@ -658,12 +658,12 @@ def test_multi_commodity_conversion_factor_nh3_system(subtests): ][0] # eh2_recipe_name = ("hydrogen", "electricity", "hydrogen-1") with subtests.test("Electricity/Hydrogen/Ammonia Conversion Factor"): - conversion_factor = prob.model.slc._get_conversion_from_recipe( + conversion_factor = prob.model.slc.get_conversion_from_recipe( all_conversion_factors, conversion_recipes[eh2_recipe_name] ) expected_conversion_factor = (h2_gen / 40).mean() * (elec_gen / 39.0) assert pytest.approx(expected_conversion_factor, rel=1e-6) == conversion_factor with subtests.test("Electricity/Hydrogen/Ammonia Techs"): - techs_to_demand = prob.model.slc._get_techs_to_demand_from_recipe(eh2_recipe_name) + techs_to_demand = prob.model.slc.get_techs_to_demand_from_recipe(eh2_recipe_name) expected_techs = ["battery", "wind", "solar", "combiner", "elec_combiner"] assert set(expected_techs) == set(techs_to_demand) From a68bbffa236cc98496c98411a489d6921cea75ac Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:21:21 -0600 Subject: [PATCH 67/69] worked on docs --- .../system_level_control_base.md | 10 + .../system_level/system_level_control_base.py | 252 ++++++++++-------- 2 files changed, 148 insertions(+), 114 deletions(-) diff --git a/docs/control/system_level_control/system_level_control_base.md b/docs/control/system_level_control/system_level_control_base.md index 7319cad36..9ae137c93 100644 --- a/docs/control/system_level_control/system_level_control_base.md +++ b/docs/control/system_level_control/system_level_control_base.md @@ -41,3 +41,13 @@ Helper functions for cost-aware controllers. :undoc-members: :show-inheritance: ``` + + +## Heterogeneous Commodity Hybrid System + +```{eval-rst} +.. autoclass:: h2integrate.control.control_strategies.system_level.system_level_control_base.HCHSConfig + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index ab0fadb0b..5f5a3b49a 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -84,116 +84,140 @@ def _get_buy_price_default_and_shape(tech_config, tech_name, n_timesteps, plant_ return 0.0, n_timesteps -class ChangeNameAttributeClass: - """heterogeneous commodity hybrid system""" +class HCHSConfig: + """Configuration class for a Heterogeneous Commodity Hybrid System. + The inputs to this configuration class are made in the + ``_post_setup_multi_commodity()`` method of ``SystemLevelControlBase``. + + Attributes: + converter_upstreams (dict): describes the technologies that provide + each of the input commodities for converter technologies + converters (set[tuple]): set of tuples describing the commodity conversions of + all technologies that convert one commodity into another + grouped_techs (dict): groups of technologies based on the commodities they produce. + This is built from ``converter_upstreams`` + simple_graph (nx.DiGraph): directional graph representation of ``grouped_techs`` + converter_tech_names (set[str]): names of the converter technologies in the system + conversion_recipes (dict): instructions on how to convert the demanded commodity + into a demand profile for each group of technologies + non_converter_conversion_factor_keys (set[tuple]): equivalent of ``converters`` + but for all other technologies in the system that are not in ``converters`` + + Examples: + Below highlights what these attributes look like if we have the following system: + + >>> technology_interconnections = [ + ... ["wind", "elec_combiner", "electricity", "cable"], + ... ["solar", "elec_combiner", "electricity", "cable"], + ... ["elec_combiner", "electrolyzer", "electricity", "cable"], + ... ["electrolyzer", "haber_bosch", "hydrogen", "pipe"], + ... ["electricity_feedstock", "haber_bosch", "electricity", "cable"], + ... ["haber_bosch", "nh3_storage", "ammonia", "pipe"], + ... ["haber_bosch", "nh3_combiner", "ammonia", "pipe"], + ... ["nh3_storage", "nh3_combiner", "ammonia", "pipe"], + ... ["nh3_combiner", "nh3_load_demand", "ammonia", "pipe"], + ... ] + + >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) + { + # (input_commodity, tech_name, output_commodity) + ("electricity", "electrolyzer", "hydrogen"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia") + } - def __init__( - self, - converter_upstreams, - converters, - grouped_techs, - simple_graph, - converter_tech_names, - conversion_recipes, - non_converter_keys, - ): - """heterogeneous commodity hybrid system - - Attributes: - converter_upstreams (dict): _description_ - converters (set[tuple]): _description_ - grouped_techs (dict): _description_ - simple_graph (nx.DiGraph): _description_ - converter_tech_names (set[str]): _description_ - conversion_recipes (dict): _description_ - non_converter_keys (set[tuple]): _description_ - - Examples: - Below highlights what these attributes look like if we have the following system: - - >>> technology_interconnections = [ - ... ["wind", "elec_combiner", "electricity", "cable"], - ... ["solar", "elec_combiner", "electricity", "cable"], - ... ["elec_combiner", "electrolyzer", "electricity", "cable"], - ... ["electrolyzer", "haber_bosch", "hydrogen", "pipe"], - ... ["electricity_feedstock", "haber_bosch", "electricity", "cable"], - ... ["haber_bosch", "nh3_storage", "ammonia", "pipe"], - ... ["haber_bosch", "nh3_combiner", "ammonia", "pipe"], - ... ["nh3_storage", "nh3_combiner", "ammonia", "pipe"], - ... ["nh3_combiner", "nh3_load_demand", "ammonia", "pipe"], - ... ] + >>> converter_upstreams # keys formatted as (input_commodity, tech) + { + # (input_commodity, tech): [upstream technologies providing input_commodity to tech] + ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], + ("electricity", "haber_bosch"): ["electricity_feedstock"], + ("hydrogen", "haber_bosch"): ["electrolyzer"], + ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] + } - >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) - { - ("electricity", "electrolyzer", "hydrogen"), - ("electricity", "haber_bosch", "ammonia"), - ("hydrogen", "haber_bosch", "ammonia") - } + >>> converter_tech_names # set of strings + {"electrolyzer", "haber_bosch"} + + >>> non_converter_conversion_factor_keys + { + # (output_commodity, tech, output_commodity) + ("ammonia", "nh3_combiner", "ammonia"), + ("ammonia", "nh3_storage", "ammonia"), + ("electricity", "elec_combiner", "electricity"), + ("electricity", "wind", "electricity"), + ("electricity", "solar", "electricity"), + ("electricity", "electricity_feedstock", "electricity"), + } - >>> converter_upstreams # keys formatted as ("input_commodity", "tech") - { - ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], - ("electricity", "haber_bosch"): ["electricity_feedstock"], - ("hydrogen", "haber_bosch"): ["electrolyzer"], - ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] - } + >>> grouped_techs + { + # group_name: [technologies in group] + "electricity-0": ["solar", "wind", "elec_combiner"], + "electricity-1": ["electricity_feedstock"], + "hydrogen-2": ["electrolyzer"], + "ammonia-3": ["nh3_combiner", "nh3_storage", "haber_bosch"] + } + + >>> list(conversion_recipes.keys(()) + [ + # (input_commodity, output_commodity, group_name) + ('ammonia', 'electricity', 'ammonia-3'), + ('ammonia', 'hydrogen', 'ammonia-3'), + ('hydrogen', 'electricity', 'hydrogen-2') + ] - >>> converter_tech_names # set of strings - {"electrolyzer", "haber_bosch"} + Recipe to calculate electricity demand for ammonia plant - >>> non_converter_keys # formatted as (output_commodity, tech, output_commodity) - { - ("ammonia", "nh3_combiner", "ammonia"), - ("ammonia", "nh3_storage", "ammonia"), - ("electricity", "elec_combiner", "electricity"), - ("electricity", "wind", "electricity"), - ("electricity", "solar", "electricity"), - ("electricity", "electricity_feedstock", "electricity"), - } - >>> grouped_techs - { - "electricity-0": ["solar", "wind", "elec_combiner"], - "electricity-1": ["electricity_feedstock"], - "hydrogen-2": ["electrolyzer"], - "ammonia-3": ["nh3_combiner", "nh3_storage", "haber_bosch"] - } - >>> list(conversion_recipes.keys(()) + >>> conversion_recipes[("ammonia", "electricity", "ammonia-3")] + [ [ - ('ammonia', 'electricity', 'ammonia-3'), - ('ammonia', 'hydrogen', 'ammonia-3'), - ('hydrogen', 'electricity', 'hydrogen-2') + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('electricity', 'haber_bosch', 'ammonia') ] - >>> conversion_recipes[("ammonia", "electricity", "ammonia-3")] + ] + + Recipe to calculate hydrogen demand for ammonia plant + + >>> conversion_recipes[("ammonia", "hydrogen", "ammonia-3")] + [ [ - [ - ('ammonia', 'nh3_combiner', 'ammonia'), - ('ammonia', 'nh3_storage', 'ammonia'), - ('electricity', 'haber_bosch', 'ammonia') - ] + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('hydrogen', 'haber_bosch', 'ammonia') ] - >>> conversion_recipes[("ammonia", "hydrogen", "ammonia-3")] + ] + + Recipe to calculate electricity demand for hydrogen used in the ammonia plant + + >>> conversion_recipes[("hydrogen", "electricity", "hydrogen-2")] + [ + # recipe of hydrogen to ammonia [ - [ - ('ammonia', 'nh3_combiner', 'ammonia'), - ('ammonia', 'nh3_storage', 'ammonia'), - ('hydrogen', 'haber_bosch', 'ammonia') - ] - ] - >>> conversion_recipes[("hydrogen", "electricity", "hydrogen-2")] + ('ammonia', 'nh3_combiner', 'ammonia'), + ('ammonia', 'nh3_storage', 'ammonia'), + ('hydrogen', 'haber_bosch', 'ammonia') + ], + # recipe of electricity to hydrogen [ - [ - ('ammonia', 'nh3_combiner', 'ammonia'), - ('ammonia', 'nh3_storage', 'ammonia'), - ('hydrogen', 'haber_bosch', 'ammonia') - ], - [ - ('electricity', 'electrolyzer', 'hydrogen'), - ('hydrogen', 'h2_combiner', 'hydrogen'), - ('hydrogen', 'h2_storage', 'hydrogen') - ] + ('electricity', 'electrolyzer', 'hydrogen'), + ('hydrogen', 'h2_combiner', 'hydrogen'), + ('hydrogen', 'h2_storage', 'hydrogen') ] + ] - """ + """ + + def __init__( + self, + converter_upstreams: dict, + converters: set, + grouped_techs: dict, + simple_graph: nx.DiGraph, + converter_tech_names: set, + conversion_recipes: dict, + non_converter_keys: set, + ): self.converter_upstreams = converter_upstreams self.converters = converters self.grouped_techs = grouped_techs @@ -949,7 +973,7 @@ def _feedstock_marginal_cost(self, inputs, marginal_cost_data): def _post_setup_multi_commodity(self): """This method creates sets the attribute ``rename_me_config``, which is a - ``ChangeNameAttributeClass`` object. This method is only used in + ``HCHSConfig`` object. This method is only used in heterogeneous commodity hybrid system (HCHS). Below is a summary of what this method does: 1. Find the converter technologies and the technologies upstream of them. @@ -1071,7 +1095,7 @@ def _post_setup_multi_commodity(self): converters, simple_graph, grouped_techs ) - self.rename_me_config = ChangeNameAttributeClass( + self.rename_me_config = HCHSConfig( converter_upstreams, converters, grouped_techs, @@ -1174,27 +1198,27 @@ def _find_converter_techs(self): ``(input_commodity, tech_name, output_commodity)`` tuples. An example of this variable is shown below: - >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) - { - # (input_commodity, tech_name, output_commodity) - ("electricity", "electrolyzer", "hydrogen"), - ("electricity", "haber_bosch", "ammonia"), - ("hydrogen", "haber_bosch", "ammonia") - } + >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) + { + # (input_commodity, tech_name, output_commodity) + ("electricity", "electrolyzer", "hydrogen"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia") + } - **converter_upstreams** *(dict[tuple[str,str], list[str]])*: Keys are set of ``(input_commodity, tech_name)`` and the values are a set of upstream technologies that output the `input_commodity` to `tech_name`. An example of this variable is shown below: - >>> converter_upstreams # keys formatted as (input_commodity, tech) - { - # (input_commodity, tech) : [techs that provide input_commodity to tech] - ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], - ("electricity", "haber_bosch"): ["electricity_feedstock"], - ("hydrogen", "haber_bosch"): ["electrolyzer"], - ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] - } + >>> converter_upstreams # keys formatted as (input_commodity, tech) + { + # (input_commodity, tech) : [techs that provide input_commodity to tech] + ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], + ("electricity", "haber_bosch"): ["electricity_feedstock"], + ("hydrogen", "haber_bosch"): ["electrolyzer"], + ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] + } """ in_flows = dict(self.technology_graph.in_degree) From a45ca63f086a0c7e1f2b13b0d0b1ea624011428c Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:39:18 -0600 Subject: [PATCH 68/69] updated docstrings to prevent errors --- .../system_level/system_level_control_base.py | 67 +++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 5f5a3b49a..0c62bdf34 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1192,33 +1192,32 @@ def _find_converter_techs(self): produced by its upstream ancestors (e.g. an electrolyzer: electricity → hydrogen). Returns: - 2-element tuple containing: - - - **converters** *(set[tuple])*: Set of tuples formatted as - ``(input_commodity, tech_name, output_commodity)`` tuples. An - example of this variable is shown below: - - >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) - { - # (input_commodity, tech_name, output_commodity) - ("electricity", "electrolyzer", "hydrogen"), - ("electricity", "haber_bosch", "ammonia"), - ("hydrogen", "haber_bosch", "ammonia") - } + tuple[set, dict]: 2-element tuple containing ``converters`` and ``converter_upstreams``. + + **converters** *(set[tuple])*: Set of tuples formatted as + ``(input_commodity, tech_name, output_commodity)`` tuples. - - **converter_upstreams** *(dict[tuple[str,str], list[str]])*: Keys are set of + >>> converters # tuples formatted as (input_commodity, tech_name, output_commodity) + { + # (input_commodity, tech_name, output_commodity) + ("electricity", "electrolyzer", "hydrogen"), + ("electricity", "haber_bosch", "ammonia"), + ("hydrogen", "haber_bosch", "ammonia") + } + + **converter_upstreams** *(dict[tuple[str,str], list[str]])*: Keys are set of ``(input_commodity, tech_name)`` and the values are a set of upstream technologies that output the `input_commodity` to `tech_name`. An - example of this variable is shown below: - - >>> converter_upstreams # keys formatted as (input_commodity, tech) - { - # (input_commodity, tech) : [techs that provide input_commodity to tech] - ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], - ("electricity", "haber_bosch"): ["electricity_feedstock"], - ("hydrogen", "haber_bosch"): ["electrolyzer"], - ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] - } + example of this variable is shown below. + + >>> converter_upstreams # keys formatted as (input_commodity, tech) + { + # (input_commodity, tech) : [techs that provide input_commodity to tech] + ("electricity", "electrolyzer"): ["wind", "solar", "elec_combiner"], + ("electricity", "haber_bosch"): ["electricity_feedstock"], + ("hydrogen", "haber_bosch"): ["electrolyzer"], + ("ammonia", "nh3_load_demand"): ["nh3_combiner", "nh3_storage", "haber_bosch"] + } """ in_flows = dict(self.technology_graph.in_degree) @@ -1515,10 +1514,10 @@ def get_techs_to_demand_from_recipe(self, recipe_name): Args: recipe_name (tuple): name of recipe formatted as a tuple of - ``(input_commodity, output_commodity, tech_group_name)`` or - ``(input_commodity, output_commodity, (i,tech_group_name))``. - This should be a key from the dictionary returned from - ``_make_conversion_factor_recipes()``. + ``(input_commodity, output_commodity, tech_group_name)`` or + ``(input_commodity, output_commodity, (i,tech_group_name))``. + This should be a key from the dictionary returned from + ``_make_conversion_factor_recipes()``. Raises: ValueError: there are multiple techs @@ -1550,9 +1549,9 @@ def get_conversion_from_recipe(self, conversion_factors, recipe): Args: conversion_factors (dict): dictionary with keys of 3 element tuples - formatted as ``(input_commodity, tech, output_commodity)``. - Values are an array or float of the conversion factor - ``input_commodity/output_commodity``. An example is shown below: + formatted as ``(input_commodity, tech, output_commodity)``. + Values are an array or float of the conversion factor + ``input_commodity/output_commodity``. An example is shown below: >>> conversion_factors { @@ -1562,9 +1561,9 @@ def get_conversion_from_recipe(self, conversion_factors, recipe): } recipe (list[list[tuples]]): embedded list of conversions, - a value from from the ``conversion_recipes`` attribute. - This should be a value from the dictionary returned from - ``_make_conversion_factor_recipes()``. + a value from from the ``conversion_recipes`` attribute. + This should be a value from the dictionary returned from + ``_make_conversion_factor_recipes()``. >>> recipe [ From f22f5f94406605330c935bb9a080aaa86e4f6385 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:51:46 -0600 Subject: [PATCH 69/69] added comments for follow-on work to handle splitters --- .../system_level/system_level_control_base.py | 7 ++++++- .../system_level/test/test_slc_baseclass.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 0c62bdf34..78c3da77d 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1031,8 +1031,10 @@ def _post_setup_multi_commodity(self): if (tech_name, group_commodity) in reversed_commodity_groups: msg = ( f"The tech/commodity pair {tech_name}/{group_commodity} " - "should not be duplicated" + "should not be duplicated. This may be due to a splitter " + "in the system which is not currently supported." ) + # this error will get raised if using a splitter. raise ValueError(msg) reversed_commodity_groups[(tech_name, group_commodity)] = group_name @@ -1171,12 +1173,15 @@ def get_successors_for_tech_with_input_cmod(self, tech, input_commodity): commod := self.technology_graph.edges[upstream_tech, tech].get("commodity") ) is not None: if isinstance(commod, str) and commod == input_commodity: + # this if-statement is outdated and could be removed successor_techs_with_commod.add(upstream_tech) produces_cmod = True if isinstance(commod, list) and input_commodity in commod: successor_techs_with_commod.add(upstream_tech) produces_cmod = True if in_flows[upstream_tech] > 1 and produces_cmod: + # if only use >1, then it wouldn't catch splitters + # use `in_flows[upstream_tech] >= 1` to properly handle splitters new_techs = self.get_successors_for_tech_with_input_cmod( upstream_tech, input_commodity ) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py index c0f0375e5..c2eb27397 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_baseclass.py @@ -22,7 +22,7 @@ def make_tech_classifiers(tech_list): classifiers |= {k: "fixed" for k in fixed_techs} classifiers |= {k: "connector" for k in tech_list if "combiner" in k} - classifiers |= {k: "connector" for k in tech_list if "splitter" in k} + classifiers |= {k: "splitter" for k in tech_list if "splitter" in k} classifiers |= {k: "feedstock" for k in tech_list if "feedstock" in k} classifiers |= {k: "demand" for k in tech_list if "demand" in k}