From 1051727c795eedccf60bd6b87e27b3eb8d26cb4e Mon Sep 17 00:00:00 2001 From: Darice L Guittet Date: Mon, 21 Aug 2023 09:27:38 -0600 Subject: [PATCH] Update Custom Financial Model (#195) * pull in changes from pysam_update_capacity * fix import * update tests * undo a commenting --- .github/workflows/ci.yml | 1 - .gitignore | 1 + .readthedocs.yaml | 30 +++++ .readthedocs.yml | 9 -- hybrid/battery.py | 8 +- hybrid/csp_source.py | 3 + hybrid/detailed_pv_plant.py | 12 +- .../hybrid_dispatch_builder_solver.py | 18 +-- hybrid/dispatch/hybrid_dispatch_options.py | 2 +- hybrid/financial/custom_financial_model.py | 48 ++++++-- hybrid/grid.py | 6 +- hybrid/hybrid_simulation.py | 38 +++++-- hybrid/layout/pv_layout_tools.py | 22 ++-- hybrid/power_source.py | 105 +++++++++++++----- hybrid/pv_source.py | 8 +- hybrid/wind_source.py | 15 ++- requirements-dev.txt | 1 - tests/analysis/test_custom_financial.py | 55 ++++++--- tests/hybrid/test_hybrid.py | 80 +++++++++---- .../optimizer/CMA_ES_optimizer.py | 2 +- tools/utils.py | 64 +++++++++++ 21 files changed, 397 insertions(+), 131 deletions(-) create mode 100644 .readthedocs.yaml delete mode 100644 .readthedocs.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c29905fd8..e8227c92a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,6 @@ jobs: env: SKLEARN_ALLOW_DEPRECATED_SKLEARN_PACKAGE_INSTALL: True run: | - pip install scikit-learn sudo apt-get update && sudo apt-get install -y libglpk-dev glpk-utils coinor-cbc python -m pip install --upgrade pip pip install -r requirements.txt diff --git a/.gitignore b/.gitignore index d251d823f..bf249b1b2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ HOPP_examples/ HOPP-demos/ # needed for h2 test inputs: tests/analysis/results/ examples/H2 Analysis/results/reopt_precomputes/ +*_disp.txt tests/hybrid/REoptResultsNoExportAboveLoad.json diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..3ee6bf0c3 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,30 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.8" + # You can also specify other tool versions: + # nodejs: "19" + # rust: "1.64" + # golang: "1.19" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + fail_on_warning: true + +# If using Sphinx, optionally build your docs in additional formats such as PDF +# formats: +# - pdf + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: requirements-dev.txt diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index acc7c4ff5..000000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: 2 - -sphinx: - fail_on_warning: true - -python: - version: 3.7 - install: - - requirements: requirements-dev.txt diff --git a/hybrid/battery.py b/hybrid/battery.py index 2c70f2e3f..cb0c5405b 100644 --- a/hybrid/battery.py +++ b/hybrid/battery.py @@ -53,12 +53,13 @@ def __init__(self, if key not in battery_config.keys(): raise ValueError + self.config_name = "StandaloneBatterySingleOwner" system_model = BatteryModel.default(chemistry) if 'fin_model' in battery_config.keys(): - financial_model = battery_config['fin_model'] + financial_model = self.import_financial_model(battery_config['fin_model'], system_model, self.config_name) else: - financial_model = Singleowner.from_existing(system_model, "StandaloneBatterySingleOwner") + financial_model = Singleowner.from_existing(system_model, self.config_name) super().__init__("Battery", site, system_model, financial_model) @@ -255,6 +256,9 @@ def simulate_financials(self, interconnect_kw: float, project_life: int, cap_cre :param cap_cred_avail_storage: Base capacity credit on available storage (True), otherwise use only dispatched generation (False) """ + if not isinstance(self._financial_model, Singleowner.Singleowner): + self._financial_model.assign(self._system_model.export(), ignore_missing_vals=True) # copy system parameter values having same name + self._financial_model.value('batt_computed_bank_capacity', self.system_capacity_kwh) self.validate_replacement_inputs(project_life) diff --git a/hybrid/csp_source.py b/hybrid/csp_source.py index d53f8f977..99583cad6 100644 --- a/hybrid/csp_source.py +++ b/hybrid/csp_source.py @@ -658,6 +658,9 @@ def simulate_financials(self, interconnect_kw: float, project_life: int = 25, ca :param cap_cred_avail_storage: Base capacity credit on available storage (True), otherwise use only dispatched generation (False) """ + if not isinstance(self._financial_model, Singleowner.Singleowner): + self._financial_model.assign(self._system_model.export(), ignore_missing_vals=True) # copy system parameter values having same name + if project_life > 1: self._financial_model.value('system_use_lifetime_output', 1) else: diff --git a/hybrid/detailed_pv_plant.py b/hybrid/detailed_pv_plant.py index ef43cd98f..a79c59f73 100644 --- a/hybrid/detailed_pv_plant.py +++ b/hybrid/detailed_pv_plant.py @@ -8,6 +8,7 @@ from hybrid.dispatch.power_sources.pv_dispatch import PvDispatch from hybrid.layout.pv_module import get_module_attribs, set_module_attribs from hybrid.layout.pv_inverter import set_inverter_attribs +from tools.utils import flatten_dict class DetailedPVPlant(PowerSource): @@ -27,12 +28,13 @@ def __init__(self, 'layout_model': optional layout model object to use instead of the PVLayout model 'layout_params': optional DetailedPVParameters, the design vector w/ values. Required for layout modeling """ - system_model = Pvsam.default("FlatPlatePVSingleOwner") + self.config_name = "FlatPlatePVSingleOwner" + system_model = Pvsam.default(self.config_name) if 'fin_model' in pv_config.keys(): - financial_model = pv_config['fin_model'] + financial_model = self.import_financial_model(pv_config['fin_model'], system_model, self.config_name) else: - financial_model = Singleowner.from_existing(system_model, "FlatPlatePVSingleOwner") + financial_model = Singleowner.from_existing(system_model, self.config_name) super().__init__("SolarPlant", site, system_model, financial_model) @@ -142,14 +144,14 @@ def processed_assign(self, params): def simulate_financials(self, interconnect_kw: float, project_life: int): """ - Runs the finanical model + Runs the financial model :param interconnect_kw: ``float``, Hybrid interconnect limit [kW] :param project_life: ``int``, Number of year in the analysis period (execepted project lifetime) [years] :return: - """ + """ if not self._financial_model: return if self.system_capacity_kw <= 0: diff --git a/hybrid/dispatch/hybrid_dispatch_builder_solver.py b/hybrid/dispatch/hybrid_dispatch_builder_solver.py index e10f6c1e0..4902ad6dc 100644 --- a/hybrid/dispatch/hybrid_dispatch_builder_solver.py +++ b/hybrid/dispatch/hybrid_dispatch_builder_solver.py @@ -11,6 +11,8 @@ from hybrid.sites import SiteInfo from hybrid.dispatch import HybridDispatch, HybridDispatchOptions, DispatchProblemState from hybrid.clustering import Clustering +from hybrid.log import hybrid_logger as logger + class HybridDispatchBuilderSolver: """Helper class for building hybrid system dispatch problem, solving dispatch problem, and simulating system @@ -211,7 +213,7 @@ def cbc_solve_call(pyomo_model: pyomo.ConcreteModel, if sys.platform == 'win32' or sys.platform == 'cygwin': cbc_path = Path(__file__).parent / "cbc_solver" / "cbc-win64" / "cbc" if log_name != "": - print("Warning: CBC solver logging is active... This will significantly increase simulation time.") + logger.warning("Warning: CBC solver logging is active... This will significantly increase simulation time.") solver_options.constructed['log'] = 2 solver = pyomo.SolverFactory('asl:cbc', executable=cbc_path) results = solver.solve(pyomo_model, logfile=solver_options.instance_log, options=solver_options.constructed) @@ -304,7 +306,7 @@ def check_solve_condition(solver_termination_condition, pyomo_model): if solver_termination_condition == TerminationCondition.infeasible: HybridDispatchBuilderSolver.print_infeasible_problem(pyomo_model) elif not solver_termination_condition == TerminationCondition.optimal: - print("Warning: Dispatch problem termination condition was '" + logger.warning("Warning: Dispatch problem termination condition was '" + str(solver_termination_condition) + "'") @staticmethod @@ -358,9 +360,9 @@ def display_all_blocks(model: pyomo.ConcreteModel): def simulate_power(self): if self.needs_dispatch: # Dispatch Optimization Simulation with Rolling Horizon - print("Simulating system with dispatch optimization...") + logger.info("Simulating system with dispatch optimization...") else: - print("Dispatch optimization not required...") + logger.info("Dispatch optimization not required...") return ti = list(range(0, self.site.n_timesteps, self.options.n_roll_periods)) self.dispatch.initialize_parameters() @@ -373,15 +375,15 @@ def simulate_power(self): start_time = time.time() self.simulate_with_dispatch(t) sim_w_dispath_time = time.time() - print('Day {} dispatch optimized.'.format(i)) - print(" %6.2f seconds required to simulate with dispatch" % (sim_w_dispath_time - start_time)) + logger.info('Day {} dispatch optimized.'.format(i)) + logger.info(" %6.2f seconds required to simulate with dispatch" % (sim_w_dispath_time - start_time)) else: continue # TODO: can we make the csp and battery model run with heuristic dispatch here? # Maybe calling a simulate_with_heuristic() method else: if (i % 73) == 0: - print("\t {:.0f} % complete".format(i*20/73)) + logger.info("\t {:.0f} % complete".format(i*20/73)) self.simulate_with_dispatch(t) else: @@ -459,7 +461,7 @@ def simulate_with_dispatch(self, transmission_limit = self.power_sources['grid'].value('grid_interconnection_limit_kwac') / 1e3 for count, value in enumerate(system_limit): if value > transmission_limit: - print('Warning: Desired schedule is greater than transmission limit. ' + logger.warning('Warning: Desired schedule is greater than transmission limit. ' 'Overwriting schedule to transmission limit') system_limit[count] = transmission_limit diff --git a/hybrid/dispatch/hybrid_dispatch_options.py b/hybrid/dispatch/hybrid_dispatch_options.py index 8f37276e2..d4ee7a9fb 100644 --- a/hybrid/dispatch/hybrid_dispatch_options.py +++ b/hybrid/dispatch/hybrid_dispatch_options.py @@ -20,7 +20,7 @@ def __init__(self, dispatch_options: dict = None): dict: { 'solver': str (default='glpk'), MILP solver used for dispatch optimization problem - options: ('glpk', 'cbc') + options: ('glpk', 'cbc', 'xpress', 'xpress_persistent', 'gurobi_ampl', 'gurobi') 'solver_options': dict, Dispatch solver options 'battery_dispatch': str (default='simple'), sets the battery dispatch model to use for dispatch options: ('simple', 'one_cycle_heuristic', 'heuristic', 'non_convex_LV', 'convex_LV'), diff --git a/hybrid/financial/custom_financial_model.py b/hybrid/financial/custom_financial_model.py index e52fdfc89..260ff7519 100644 --- a/hybrid/financial/custom_financial_model.py +++ b/hybrid/financial/custom_financial_model.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, _is_classvar, asdict from typing import Sequence, List import numpy as np +from tools.utils import flatten_dict, equal @dataclass @@ -79,7 +80,7 @@ class SystemCosts(FinancialData): class Revenue(FinancialData): ppa_price_input: float=None ppa_soln_mode: float=1 - ppa_escalation: float=0 + ppa_escalation: float=1 ppa_multiplier_model: float=None dispatch_factors_ts: Sequence=(0,) @@ -158,6 +159,7 @@ def __init__(self, fin_config: dict) -> None: # Input parameters + self._system_model = None self.batt_annual_discharge_energy = None self.batt_annual_charge_energy = None self.batt_annual_charge_from_system = None @@ -178,17 +180,28 @@ def __init__(self, self.assign(fin_config) - def set_financial_inputs(self, power_source_dict: dict): + def set_financial_inputs(self, system_model=None): """ - Set financial inputs from PowerSource (e.g., PVPlant) parameters and outputs + Set financial inputs from PowerSource (e.g., PVPlant) + + This custom financial model needs to be able to update its inputs from the system model, as + parameters are not linked like they are when a PySAM.Singleowner model is created using from_existing(). + The inputs that need to be updated will depend on the financial model implementation, and these + are specified here. + The system model reference is also update here, as the system model is not always available during __init__. """ + if system_model is not None: + self._system_model = system_model + elif self._system_model is None: + raise ValueError('System model not set in custom financial model') + + power_source_dict = flatten_dict(self._system_model.export()) if 'system_capacity' in power_source_dict: self.value('system_capacity', power_source_dict['system_capacity']) - if 'dc_degradation' in power_source_dict: - self.value('degradation', power_source_dict['dc_degradation']) def execute(self, n=0): + self.set_financial_inputs() # update inputs from system model npv = self.npv( rate=self.nominal_discount_rate( inflation_rate=self.value('inflation_rate'), @@ -294,16 +307,35 @@ def value(self, var_name, var_value=None): else: try: setattr(attr_obj, var_name, var_value) + try: + # update system model if it has the same named attribute + # avoid infinite loops if same functionality is implemented in system model + if not equal(self._system_model.value(var_name), var_value) and var_name != 'gen': + self._system_model.value(var_name, var_value) + except: + pass except Exception as e: raise IOError(f"{self.__class__}'s attribute {var_name} could not be set to {var_value}: {e}") - def assign(self, input_dict): + def assign(self, input_dict, ignore_missing_vals=False): + """ + Assign attribues from nested dictionary, except for Outputs + + :param input_dict: nested dictionary of values + :param ignore_missing_vals: if True, do not throw exception if value not in self + """ for k, v in input_dict.items(): if not isinstance(v, dict): - self.value(k, v) + try: + self.value(k, v) + except: + if not ignore_missing_vals: + raise IOError(f"{self.__class__}'s attribute {k} could not be set to {v}") + elif k == 'Outputs': + continue # do not assign from Outputs category else: - getattr(self, k).assign(v) + self.assign(input_dict[k], ignore_missing_vals) def unassign(self, var_name): diff --git a/hybrid/grid.py b/hybrid/grid.py index de857e862..b68706f3c 100644 --- a/hybrid/grid.py +++ b/hybrid/grid.py @@ -27,7 +27,11 @@ def __init__(self, system_model = GridModel.default("GenericSystemSingleOwner") if 'fin_model' in grid_config.keys(): - financial_model = grid_config['fin_model'] + if isinstance(grid_config['fin_model'], Singleowner.Singleowner): + financial_model = Singleowner.from_existing(system_model, "GenericSystemSingleOwner") + financial_model.assign(grid_config['fin_model'].export()) + else: + financial_model = grid_config['fin_model'] else: financial_model = Singleowner.from_existing(system_model, "GenericSystemSingleOwner") financial_model.value("add_om_num_types", 1) diff --git a/hybrid/hybrid_simulation.py b/hybrid/hybrid_simulation.py index e1f4ce21e..bc1d30f88 100644 --- a/hybrid/hybrid_simulation.py +++ b/hybrid/hybrid_simulation.py @@ -136,7 +136,7 @@ def __init__(self, .. TODO: I don't really like the above table """ self._fileout = Path.cwd() / "results" - self.site = site + self.site: SiteInfo = site self.sim_options = simulation_options if simulation_options else dict() self.power_sources = OrderedDict() @@ -394,7 +394,7 @@ def calculate_financials(self): for v in generators: cost_ratios.append(v.total_installed_cost / total_cost) - def set_average_for_hybrid(var_name, weight_factor=None): + def set_average_for_hybrid(var_name, weight_factor=None, min_val=None, max_val=None): """ Sets the hybrid plant's financial input to the weighted average of each component's value """ @@ -412,6 +412,10 @@ def set_average_for_hybrid(var_name, weight_factor=None): weight_factor = [1 / len(generators) for _ in generators] hybrid_avg = sum(np.array(v.value(var_name)) * weight_factor[n] for n, v in enumerate(generators)) + if min_val is not None: + hybrid_avg = max(min_val, hybrid_avg) + if max_val is not None: + hybrid_avg = min(max_val, hybrid_avg) self.grid.value(var_name, hybrid_avg) return hybrid_avg @@ -449,13 +453,13 @@ def set_logical_or_for_hybrid(var_name): set_average_for_hybrid("itc_fed_percent", cost_ratios) # Federal Depreciation Allocations are averaged - set_average_for_hybrid("depr_alloc_macrs_5_percent", cost_ratios) - set_average_for_hybrid("depr_alloc_macrs_15_percent", cost_ratios) - set_average_for_hybrid("depr_alloc_sl_5_percent", cost_ratios) - set_average_for_hybrid("depr_alloc_sl_15_percent", cost_ratios) - set_average_for_hybrid("depr_alloc_sl_20_percent", cost_ratios) - set_average_for_hybrid("depr_alloc_sl_39_percent", cost_ratios) - set_average_for_hybrid("depr_alloc_custom_percent", cost_ratios) + set_average_for_hybrid("depr_alloc_macrs_5_percent", cost_ratios, 0, 100) + set_average_for_hybrid("depr_alloc_macrs_15_percent", cost_ratios, 0, 100) + set_average_for_hybrid("depr_alloc_sl_5_percent", cost_ratios, 0, 100) + set_average_for_hybrid("depr_alloc_sl_15_percent", cost_ratios, 0, 100) + set_average_for_hybrid("depr_alloc_sl_20_percent", cost_ratios, 0, 100) + set_average_for_hybrid("depr_alloc_sl_39_percent", cost_ratios, 0, 100) + set_average_for_hybrid("depr_alloc_custom_percent", cost_ratios, 0, 100) # Federal Depreciation Qualification are "hybridized" by taking the logical or set_logical_or_for_hybrid("depr_bonus_fed_macrs_5") @@ -571,7 +575,8 @@ def simulate_financials(self, project_life): if model: storage_cc = True if system in self.sim_options.keys(): - if 'skip_financial' in self.sim_options[system].keys(): + # cannot skip financials for battery because replacements, capacity credit, and intermediate variables are calculated here + if system != "battery" and 'skip_financial' in self.sim_options[system].keys() and self.sim_options[system]['skip_financial']: continue if 'storage_capacity_credit' in self.sim_options[system].keys(): storage_cc = self.sim_options[system]['storage_capacity_credit'] @@ -1082,11 +1087,20 @@ def assign(self, input_dict: dict): self.power_sources[tech.lower()].value(k, v) else: if k not in self.power_sources.keys(): - logger.warning(f"Cannot assign {v} to {k}: technology was not included in hybrid plant") + logger.info(f"Did not assign {v} to {k}: technology was not included in hybrid plant") continue for kk, vv in v.items(): self.power_sources[k.lower()].value(kk, vv) + def export(self): + """ + :return: dictionary of inputs and results for each technology + """ + export_dicts = {} + for tech in self.power_sources.keys(): + export_dicts[tech] = self.power_sources[tech.lower()].export() + return export_dicts + def copy(self): """ :return: a clone @@ -1103,4 +1117,4 @@ def plot_layout(self, site_alpha=0.95, linewidth=4.0 ): - self.layout.plot(figure, axes, wind_color, pv_color, site_border_color, site_alpha, linewidth) + return self.layout.plot(figure, axes, wind_color, pv_color, site_border_color, site_alpha, linewidth) diff --git a/hybrid/layout/pv_layout_tools.py b/hybrid/layout/pv_layout_tools.py index 620e6f0f8..2403514bc 100644 --- a/hybrid/layout/pv_layout_tools.py +++ b/hybrid/layout/pv_layout_tools.py @@ -1,4 +1,5 @@ from typing import List +import warnings from math import floor from shapely.geometry import MultiLineString, GeometryCollection, MultiPoint @@ -175,9 +176,9 @@ def place_solar_strands(max_num_modules: int, if not prepared_site.intersects(grid_line): continue - intersection_result = site_shape.intersection(grid_line) - if intersection_result.is_empty: + if not site_shape.intersects(grid_line): continue + intersection_result = site_shape.intersection(grid_line) if isinstance(intersection_result, GeometryCollection): intersections = list(intersection_result.geoms) @@ -267,14 +268,17 @@ def get_flicker_loss_multiplier(flicker_data: Tuple[float, np.ndarray, np.ndarra modules = [] if mode == 'strands': - active_segments = map(active_area_translated.intersection, [row[2] for row in primary_strands]) - for i, s in enumerate(active_segments): - if not s.is_empty: - length_per_module = primary_strands[0][1] / primary_strands[0][0] - module_distance = module_dimensions[np.argmin([abs(d - length_per_module) for d in module_dimensions])] + with warnings.catch_warnings(): + # if intersection is empty will get warnings, turn those off + warnings.simplefilter("ignore") + active_segments = map(active_area_translated.intersection, [row[2] for row in primary_strands]) + for i, s in enumerate(active_segments): + if not s.is_empty: + length_per_module = primary_strands[0][1] / primary_strands[0][0] + module_distance = module_dimensions[np.argmin([abs(d - length_per_module) for d in module_dimensions])] - distances = np.arange(0, s.length * (1 + 1e-6), module_distance) - modules += [s.interpolate(distance) for distance in distances] + distances = np.arange(0, s.length * (1 + 1e-6), module_distance) + modules += [s.interpolate(distance) for distance in distances] if mode == "points": modules = active_area_translated.intersection(module_points) if modules.is_empty: diff --git a/hybrid/power_source.py b/hybrid/power_source.py index 4d19eceae..b811addc7 100644 --- a/hybrid/power_source.py +++ b/hybrid/power_source.py @@ -2,9 +2,8 @@ import numpy as np from hybrid.sites import SiteInfo import PySAM.Singleowner as Singleowner -import PySAM.Pvsamv1 as Pvsamv1 import pandas as pd -from tools.utils import flatten_dict, array_not_scalar +from tools.utils import array_not_scalar, equal from hybrid.log import hybrid_logger as logger from hybrid.dispatch.power_sources.power_source_dispatch import PowerSourceDispatch @@ -25,6 +24,12 @@ def __init__(self, name, site: SiteInfo, system_model, financial_model): """ Abstract class for a renewable energy power plant simulation. + Financial model parameters are linked to the technology model when either: the + model is native to PySAM and linked using `from_existing`, a `set_financial_inputs` + method is defined in a user-defined financial model, or the financial and + technology parameters are named the same when the model is native to PySAM but not + linked using `from_existing`. + :param name: Name used to identify technology :param site: Power source site information (SiteInfo object) :param system_model: Technology performance model @@ -36,9 +41,32 @@ def __init__(self, name, site: SiteInfo, system_model, financial_model): self._financial_model = financial_model self._layout = None self._dispatch = PowerSourceDispatch + if isinstance(self._financial_model, Singleowner.Singleowner): self.initialize_financial_values() + else: + self._financial_model.assign(self._system_model.export(), ignore_missing_vals=True) # copy system parameter values having same name + self._financial_model.set_financial_inputs(system_model=self._system_model) # for custom financial models + + self.capacity_factor_mode = "cap_hours" # to calculate via "cap_hours" method or None to use external value self.gen_max_feasible = [0.] * self.site.n_timesteps + + @staticmethod + def import_financial_model(financial_model, system_model, config_name): + if isinstance(financial_model, Singleowner.Singleowner): + financial_model_new = Singleowner.from_existing(system_model, config_name) # make a linked model instead + financial_model_new.assign(financial_model.export()) # transfer parameter values + else: + def check_if_callable(obj, func_name): + if not hasattr(obj, func_name) or not callable(getattr(obj, func_name)): + raise ValueError(f"{obj.__class__.__name__} must have a callable function {func_name}() defined") + check_if_callable(financial_model, "set_financial_inputs") + check_if_callable(financial_model, "value") + check_if_callable(financial_model, "assign") + check_if_callable(financial_model, "unassign") + check_if_callable(financial_model, "execute") + financial_model_new = financial_model + return financial_model_new def initialize_financial_values(self): """ @@ -117,6 +145,14 @@ def value(self, var_name: str, var_value=None): else: try: setattr(attr_obj, var_name, var_value) + if self._financial_model is not None and not isinstance(self._financial_model, Singleowner.Singleowner): + try: + # update custom financial model if it has the same named attribute + # avoid infinite loops if same functionality is implemented in financial model + if not equal(self._financial_model.value(var_name), var_value): + self._financial_model.value(var_name, var_value) + except: + pass except Exception as e: raise IOError(f"{self.__class__}'s attribute {var_name} could not be set to {var_value}: {e}") @@ -171,32 +207,35 @@ def calc_capacity_credit_percent(self, interconnect_kw: float) -> float: :return: capacity value [%] """ - TIMESTEPS_YEAR = 8760 + if self.capacity_factor_mode == "cap_hours": + TIMESTEPS_YEAR = 8760 + + t_step = self.site.interval / 60 # [hr] + if t_step != 1 or len(self.site.capacity_hours) != TIMESTEPS_YEAR or len(self.gen_max_feasible) != TIMESTEPS_YEAR: + print("WARNING: Capacity credit could not be calculated. Therefore, it was set to zero for " + + type(self).__name__) + return 0 + else: + df = pd.DataFrame() + df['cap_hours'] = self.site.capacity_hours + df['E_net_max_feasible'] = self.gen_max_feasible # [kWh] - t_step = self.site.interval / 60 # [hr] - if t_step != 1 or len(self.site.capacity_hours) != TIMESTEPS_YEAR or len(self.gen_max_feasible) != TIMESTEPS_YEAR: - print("WARNING: Capacity credit could not be calculated. Therefore, it was set to zero for " - + type(self).__name__) - return 0 - else: - df = pd.DataFrame() - df['cap_hours'] = self.site.capacity_hours - df['E_net_max_feasible'] = self.gen_max_feasible # [kWh] + sel_df = df[df['cap_hours'] == True] - sel_df = df[df['cap_hours'] == True] + if type(self).__name__ != 'Grid': + W_ac_nom = self.calc_nominal_capacity(interconnect_kw) + else: + W_ac_nom = np.min((self.hybrid_nominal_capacity, interconnect_kw)) - if type(self).__name__ != 'Grid': - W_ac_nom = self.calc_nominal_capacity(interconnect_kw) - else: - W_ac_nom = min(self.hybrid_nominal_capacity, interconnect_kw) + if len(sel_df.index) > 0 and W_ac_nom > 0: + capacity_value = sum(np.minimum(sel_df['E_net_max_feasible'].values/(W_ac_nom*t_step), 1.0)) / len(sel_df.index) * 100 + capacity_value = np.min((100, capacity_value)) # [%] + else: + capacity_value = 0 - if len(sel_df.index) > 0 and W_ac_nom > 0: - capacity_value = sum(np.minimum(sel_df['E_net_max_feasible'].values/(W_ac_nom*t_step), 1.0)) / len(sel_df.index) * 100 - capacity_value = min(100, capacity_value) # [%] - else: - capacity_value = 0 - - return capacity_value + return capacity_value + else: + return self.capacity_credit_percent def setup_performance_model(self): """ @@ -241,6 +280,9 @@ def simulate_financials(self, interconnect_kw: float, project_life: int): if self.system_capacity_kw <= 0: return + if not isinstance(self._financial_model, Singleowner.Singleowner): + self._financial_model.assign(self._system_model.export(), ignore_missing_vals=True) # copy system parameter values having same name + self._financial_model.value('system_capacity', self.system_capacity_kw) # [kW] needed for custom financial models self._financial_model.value('analysis_period', project_life) self._financial_model.value('system_use_lifetime_output', 1 if project_life > 1 else 0) self._financial_model.value('ppa_soln_mode', 1) @@ -260,12 +302,6 @@ def simulate_financials(self, interconnect_kw: float, project_life: int): # TODO: Should we use the nominal capacity function here? self.gen_max_feasible = self.calc_gen_max_feasible_kwh(interconnect_kw) self.capacity_credit_percent = self.calc_capacity_credit_percent(interconnect_kw) - if not isinstance(self._financial_model, Singleowner.Singleowner): - try: - power_source_params = flatten_dict(self._system_model.export()) - self._financial_model.set_financial_inputs(power_source_params) - except: - raise NotImplementedError("Financial model cannot set its inputs.") self._financial_model.execute(0) @@ -686,6 +722,15 @@ def copy(self): :return: new instance """ raise NotImplementedError + + def export(self): + """ + :return: dictionary of variables for system and financial + """ + export_dict = {"system": self._system_model.export()} + if self._financial_model: + export_dict['financial'] = self._financial_model.export() + return export_dict def plot(self, figure=None, diff --git a/hybrid/pv_source.py b/hybrid/pv_source.py index 766f74049..1ef31f98d 100644 --- a/hybrid/pv_source.py +++ b/hybrid/pv_source.py @@ -28,12 +28,13 @@ def __init__(self, if 'system_capacity_kw' not in pv_config.keys(): raise ValueError - system_model = Pvwatts.default("PVWattsSingleOwner") + self.config_name = "PVWattsSingleOwner" + system_model = Pvwatts.default(self.config_name) if 'fin_model' in pv_config.keys(): - financial_model = pv_config['fin_model'] + financial_model = self.import_financial_model(pv_config['fin_model'], system_model, self.config_name) else: - financial_model = Singleowner.from_existing(system_model, "PVWattsSingleOwner") + financial_model = Singleowner.from_existing(system_model, self.config_name) super().__init__("SolarPlant", site, system_model, financial_model) @@ -69,6 +70,7 @@ def system_capacity_kw(self, size_kw: float): :return: """ self._system_model.SystemDesign.system_capacity = size_kw + self._financial_model.value('system_capacity', size_kw) # needed for custom financial models self._layout.set_system_capacity(size_kw) @property diff --git a/hybrid/wind_source.py b/hybrid/wind_source.py index 212b9dd02..f4182bb2e 100644 --- a/hybrid/wind_source.py +++ b/hybrid/wind_source.py @@ -1,7 +1,11 @@ from typing import Optional, Union, Sequence import PySAM.Windpower as Windpower import PySAM.Singleowner as Singleowner -from hybrid.add_custom_modules.custom_wind_floris import Floris + +try: + from hybrid.add_custom_modules.custom_wind_floris import Floris +except: + Floris = None from hybrid.power_source import * from hybrid.layout.wind_layout import WindLayout, WindBoundaryGridParameters @@ -30,21 +34,22 @@ def __init__(self, :param rating_range_kw: allowable kw range of turbines, default is 1000 - 3000 kW """ + self.config_name = "WindPowerSingleOwner" self._rating_range_kw = rating_range_kw if 'model_name' in farm_config.keys(): if farm_config['model_name'] == 'floris': print('FLORIS is the system model...') system_model = Floris(farm_config, site, timestep=farm_config['timestep']) - financial_model = Singleowner.default("WindPowerSingleOwner") + financial_model = Singleowner.default(self.config_name) else: raise NotImplementedError else: - system_model = Windpower.default("WindPowerSingleOwner") - financial_model = Singleowner.from_existing(system_model, "WindPowerSingleOwner") + system_model = Windpower.default(self.config_name) + financial_model = Singleowner.from_existing(system_model, self.config_name) if 'fin_model' in farm_config.keys(): - financial_model = farm_config['fin_model'] + financial_model = self.import_financial_model(farm_config['fin_model'], system_model, self.config_name) super().__init__("WindPlant", site, system_model, financial_model) self._system_model.value("wind_resource_data", self.site.wind_resource.data) diff --git a/requirements-dev.txt b/requirements-dev.txt index 04b2a8c7f..57c659eb9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,6 @@ pytz requests scipy timezonefinder -urllib3 sphinx sphinx-rtd-theme diff --git a/tests/analysis/test_custom_financial.py b/tests/analysis/test_custom_financial.py index 7fd416e21..595d32ae7 100644 --- a/tests/analysis/test_custom_financial.py +++ b/tests/analysis/test_custom_financial.py @@ -60,7 +60,7 @@ def test_custom_financial(): def test_detailed_pv(site): # Run detailed PV model (pvsamv1) using a custom financial model annual_energy_expected = 108239401 - npv_expected = -40021910 + npv_expected = -39144853 pvsamv1_defaults_file = Path(__file__).absolute().parent.parent / "hybrid/pvsamv1_basic_params.json" with open(pvsamv1_defaults_file, 'r') as f: @@ -100,6 +100,24 @@ def test_detailed_pv(site): } } hybrid_plant = HybridSimulation(power_sources, site) + + # Verify technology and financial parameters are linked, specifically testing 'analysis_period' + analysis_period_orig = hybrid_plant.pv.value('analysis_period') + assert analysis_period_orig == hybrid_plant.pv._system_model.value('analysis_period') + assert analysis_period_orig == hybrid_plant.pv._financial_model.value('analysis_period') + analysis_period_new = 7 + assert analysis_period_orig != analysis_period_new + hybrid_plant.pv.value('analysis_period', analysis_period_new) # modify via plant setter + assert analysis_period_new == hybrid_plant.pv._system_model.value('analysis_period') + assert analysis_period_new == hybrid_plant.pv._financial_model.value('analysis_period') + hybrid_plant.pv._system_model.value('analysis_period', analysis_period_orig) # modify via system model setter + assert analysis_period_orig == hybrid_plant.pv.value('analysis_period') + assert analysis_period_orig != hybrid_plant.pv._financial_model.value('analysis_period') # NOTE: this is updated just before execute + hybrid_plant.pv._financial_model.value('analysis_period', analysis_period_new) # modify via financial model setter + assert analysis_period_new == hybrid_plant.pv.value('analysis_period') + assert analysis_period_new == hybrid_plant.pv._system_model.value('analysis_period') + hybrid_plant.pv.value('analysis_period', analysis_period_orig) # reset value + hybrid_plant.layout.plot() hybrid_plant.ppa_price = (0.01, ) hybrid_plant.pv.dc_degradation = [0] * 25 @@ -117,9 +135,9 @@ def test_hybrid_simple_pv_with_wind(site): annual_energy_expected_pv = 98821626 annual_energy_expected_wind = 33637984 annual_energy_expected_hybrid = 132459610 - npv_expected_pv = -40714053 - npv_expected_wind = -12059963 - npv_expected_hybrid = -52774017 + npv_expected_pv = -39911660 + npv_expected_wind = -11786833 + npv_expected_hybrid = -51698493 interconnect_kw = 150e6 pv_kw = 50000 @@ -179,9 +197,9 @@ def test_hybrid_detailed_pv_with_wind(site): annual_energy_expected_pv = 21500708 annual_energy_expected_wind = 33637984 annual_energy_expected_hybrid = 55138692 - npv_expected_pv = -8015242 - npv_expected_wind = -12059963 - npv_expected_hybrid = -20075205 + npv_expected_pv = -7840663 + npv_expected_wind = -11786833 + npv_expected_hybrid = -19627496 interconnect_kw = 150e6 wind_kw = 10000 @@ -243,8 +261,11 @@ def test_hybrid_detailed_pv_with_wind(site): hybrid_plant.ppa_price = (0.01, ) hybrid_plant.pv.dc_degradation = [0] * 25 hybrid_plant.simulate() + sizes = hybrid_plant.system_capacity_kw aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values + assert sizes.pv == approx(10000, 1e-3) + assert sizes.wind == approx(wind_kw, 1e-3) assert aeps.pv == approx(annual_energy_expected_pv, 1e-3) assert aeps.wind == approx(annual_energy_expected_wind, 1e-3) assert aeps.hybrid == approx(annual_energy_expected_hybrid, 1e-3) @@ -259,10 +280,10 @@ def test_hybrid_simple_pv_with_wind_storage_dispatch(site): annual_energy_expected_wind = 33637983 annual_energy_expected_battery = -31287 annual_energy_expected_hybrid = 43489117 - npv_expected_pv = -2138980 - npv_expected_wind = -5483725 + npv_expected_pv = -1898253 + npv_expected_wind = -4664335 npv_expected_battery = -8163435 - npv_expected_hybrid = -15786128 + npv_expected_hybrid = -14726773 interconnect_kw = 15000 pv_kw = 5000 @@ -307,8 +328,12 @@ def test_hybrid_simple_pv_with_wind_storage_dispatch(site): hybrid_plant.ppa_price = (0.03, ) hybrid_plant.pv.dc_degradation = [0] * 25 hybrid_plant.simulate() + sizes = hybrid_plant.system_capacity_kw aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values + assert sizes.pv == approx(pv_kw, 1e-3) + assert sizes.wind == approx(wind_kw, 1e-3) + assert sizes.battery == approx(batt_kw, 1e-3) assert aeps.pv == approx(annual_energy_expected_pv, 1e-3) assert aeps.wind == approx(annual_energy_expected_wind, 1e-3) assert aeps.battery == approx(annual_energy_expected_battery, 1e-3) @@ -325,10 +350,10 @@ def test_hybrid_detailed_pv_with_wind_storage_dispatch(site): annual_energy_expected_wind = 33637984 annual_energy_expected_battery = -30147 annual_energy_expected_hybrid = 54020819 - npv_expected_pv = -4104598 - npv_expected_wind = -5483725 + npv_expected_pv = -3607348 + npv_expected_wind = -4664335 npv_expected_battery = -8163128 - npv_expected_hybrid = -17751533 + npv_expected_hybrid = -16435636 interconnect_kw = 15000 wind_kw = 10000 @@ -388,8 +413,12 @@ def test_hybrid_detailed_pv_with_wind_storage_dispatch(site): hybrid_plant.ppa_price = (0.03, ) hybrid_plant.pv.dc_degradation = [0] * 25 hybrid_plant.simulate() + sizes = hybrid_plant.system_capacity_kw aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values + assert sizes.pv == approx(10000, 1e-3) + assert sizes.wind == approx(wind_kw, 1e-3) + assert sizes.battery == approx(batt_kw, 1e-3) assert aeps.pv == approx(annual_energy_expected_pv, 1e-3) assert aeps.wind == approx(annual_energy_expected_wind, 1e-3) assert aeps.battery == approx(annual_energy_expected_battery, 1e-3) diff --git a/tests/hybrid/test_hybrid.py b/tests/hybrid/test_hybrid.py index 015ccce46..f43d9f1d6 100644 --- a/tests/hybrid/test_hybrid.py +++ b/tests/hybrid/test_hybrid.py @@ -66,6 +66,17 @@ def site(): 'grid': { 'interconnect_kw': interconnection_size_kw }} +detailed_pv = { + 'tech_config': { + 'system_capacity_kw': pv_kw + }, + 'layout_params': PVGridParameters(x_position=0.5, + y_position=0.5, + aspect_power=0, + gcr=0.5, + s_buffer=2, + x_buffer=2) + } # From a Cambium midcase BA10 2030 analysis (Jan 1 = 1): capacity_credit_hours_of_year = [4604,4605,4606,4628,4629,4630,4652,4821,5157,5253, @@ -174,21 +185,23 @@ def test_detailed_pv_system_capacity(site): def test_hybrid_detailed_pv_only(site): # Run standalone detailed PV model (pvsamv1) using defaults - annual_energy_expected = 112401677 - solar_only = deepcopy(technologies['pv']) - solar_only.pop('system_capacity_kw') # use default system capacity instead + annual_energy_expected = 11236852 + solar_only = detailed_pv pv_plant = DetailedPVPlant(site=site, pv_config=solar_only) - assert pv_plant.system_capacity_kw == approx(50002.2, 1e-2) + assert pv_plant.system_capacity_kw == approx(pv_kw, 1e-2) pv_plant.simulate_power(1, False) + assert pv_plant.system_capacity_kw == approx(pv_kw, 1e-2) assert pv_plant._system_model.Outputs.annual_energy == approx(annual_energy_expected, 1e-2) assert pv_plant._system_model.Outputs.capacity_factor == approx(25.66, 1e-2) # Run detailed PV model (pvsamv1) using defaults - npv_expected = -25676157 - solar_only = deepcopy({key: technologies[key] for key in ('pv', 'grid')}) + npv_expected = -2566581 + solar_only = { + 'pv': detailed_pv, + 'grid': technologies['grid'] + } solar_only['pv']['use_pvwatts'] = False # specify detailed PV model but don't change any defaults solar_only['grid']['interconnect_kw'] = 150e3 - solar_only['pv'].pop('system_capacity_kw') # use default system capacity instead hybrid_plant = HybridSimulation(solar_only, site) hybrid_plant.layout.plot() hybrid_plant.ppa_price = (0.01, ) @@ -211,7 +224,7 @@ def test_hybrid_detailed_pv_only(site): solar_only['pv']['use_pvwatts'] = False # specify detailed PV model solar_only['pv']['tech_config'] = tech_config # specify parameters solar_only['grid']['interconnect_kw'] = 150e3 - solar_only['pv'].pop('system_capacity_kw') # use default system capacity instead + solar_only['pv']['system_capacity_kw'] = 50000 # use another system capacity hybrid_plant = HybridSimulation(solar_only, site) hybrid_plant.layout.plot() hybrid_plant.ppa_price = (0.01, ) @@ -285,6 +298,7 @@ def test_hybrid_detailed_pv_only(site): hybrid_plant.simulate() aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values + assert hybrid_plant.pv.system_capacity_kw == approx(50002.2, 1e-2) assert aeps.pv == approx(annual_energy_expected, 1e-3) assert aeps.hybrid == approx(annual_energy_expected, 1e-3) assert npvs.pv == approx(npv_expected, 1e-3) @@ -293,9 +307,10 @@ def test_hybrid_detailed_pv_only(site): def test_hybrid_user_instantiated(site): # Run detailed PV model (pvsamv1) using defaults and user-instantiated financial models - annual_energy_expected = 112401677 - npv_expected = -25676141 + annual_energy_expected = 11236852 + npv_expected = -2566581 system_capacity_kw = 5000 + system_capacity_kw_expected = 4998 layout_params = PVGridParameters(x_position=0.5, y_position=0.5, aspect_power=0, @@ -308,6 +323,10 @@ def test_hybrid_user_instantiated(site): solar_only = { 'pv': { 'use_pvwatts': False, + 'tech_config': + { + 'system_capacity_kw': system_capacity_kw + }, 'layout_params': layout_params, }, 'grid': { @@ -321,16 +340,21 @@ def test_hybrid_user_instantiated(site): hybrid_plant.simulate() aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values - assert aeps.pv == approx(annual_energy_expected, 1e-3) - assert aeps.hybrid == approx(annual_energy_expected, 1e-3) - assert npvs.pv == approx(npv_expected, 1e-3) - assert npvs.hybrid == approx(npv_expected, 1e-3) + assert hybrid_plant.pv.system_capacity_kw == approx(system_capacity_kw, 1e-2) + assert aeps.pv == approx(annual_energy_expected, 1e-2) + assert aeps.hybrid == approx(annual_energy_expected, 1e-2) + assert npvs.pv == approx(npv_expected, 1e-2) + assert npvs.hybrid == approx(npv_expected, 1e-2) # Run user-instantiated detailed PV plant, grid and respective financial models detailed_pvplant = DetailedPVPlant( site=site, pv_config={ + 'tech_config': + { + 'system_capacity_kw': system_capacity_kw + }, 'layout_params': layout_params, 'fin_model': Singleowner.default('FlatPlatePVSingleOwner'), } @@ -359,6 +383,8 @@ def test_hybrid_user_instantiated(site): hybrid_plant.simulate() aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values + assert hybrid_plant.pv._system_model.value("system_capacity") == approx(system_capacity_kw_expected, 1e-3) + assert hybrid_plant.pv._financial_model.value("system_capacity") == approx(system_capacity_kw_expected, 1e-3) assert aeps.pv == approx(annual_energy_expected, 1e-3) assert aeps.hybrid == approx(annual_energy_expected, 1e-3) assert npvs.pv == approx(npv_expected, 1e-3) @@ -367,8 +393,8 @@ def test_hybrid_user_instantiated(site): def test_custom_layout(site): # Run detailed (pvsamv1) and simple (PVWattsv8) PV models using a custom layout model - annual_energy_expected = 80145550 - npv_expected = -28481459 + annual_energy_expected = 7996844 + npv_expected = -2848449 interconnect_kw = 150e3 design_vec = DetailedPVParameters( @@ -413,6 +439,9 @@ def test_custom_layout(site): solar_only = { 'pv': { 'use_pvwatts': False, + 'tech_config': { + 'system_capacity_kw': 5000 + }, 'layout_model': detailed_layout, }, 'grid': { @@ -425,10 +454,10 @@ def test_custom_layout(site): hybrid_plant.simulate() aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values - assert aeps.pv == approx(annual_energy_expected, 1e-3) - assert aeps.hybrid == approx(annual_energy_expected, 1e-3) - assert npvs.pv == approx(npv_expected, 1e-3) - assert npvs.hybrid == approx(npv_expected, 1e-3) + assert aeps.pv == approx(annual_energy_expected, 1e-2) + assert aeps.hybrid == approx(annual_energy_expected, 1e-2) + assert npvs.pv == approx(npv_expected, 1e-2) + assert npvs.hybrid == approx(npv_expected, 1e-2) # Use simple plant (PVWattsv8) with detailed layout annual_energy_expected = 10405832 @@ -772,17 +801,22 @@ def test_hybrid_tax_incentives(site): assert ptc_hybrid == approx(ptc_fed_amount * hybrid_plant.grid._financial_model.value('cf_energy_net')[1], rel=1e-3) -def test_capacity_credit(site): +def test_capacity_credit(): site = SiteInfo(data=flatirons_site, solar_resource_file=solar_resource_file, wind_resource_file=wind_resource_file, capacity_hours=capacity_credit_hours) - wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} + wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery')} + wind_pv_battery['grid'] = { + 'interconnect_kw': interconnection_size_kw + } hybrid_plant = HybridSimulation(wind_pv_battery, site) hybrid_plant.battery.dispatch.lifecycle_cost_per_kWh_cycle = 0.01 hybrid_plant.ppa_price = (0.03, ) hybrid_plant.pv.dc_degradation = [0] * 25 + assert hybrid_plant.interconnect_kw == 15e3 + # Backup values for resetting before tests gen_max_feasible_orig = hybrid_plant.battery.gen_max_feasible capacity_hours_orig = hybrid_plant.site.capacity_hours @@ -820,6 +854,8 @@ def reinstate_orig_values(): cap_payment_mw = 100000 hybrid_plant.assign({"cp_capacity_payment_amount": [cap_payment_mw]}) + assert hybrid_plant.interconnect_kw == 15e3 + hybrid_plant.simulate() total_gen_max_feasible = np.array(hybrid_plant.pv.gen_max_feasible) \ diff --git a/tools/optimization/optimizer/CMA_ES_optimizer.py b/tools/optimization/optimizer/CMA_ES_optimizer.py index 777faa75b..8e1257a1f 100644 --- a/tools/optimization/optimizer/CMA_ES_optimizer.py +++ b/tools/optimization/optimizer/CMA_ES_optimizer.py @@ -177,7 +177,7 @@ def ask(self, num: Optional[int] = None) -> [any]: # self.print('D\n{}', self.D) # self.print('BD\n{}', BD) - candidates = [] + candidates = [self.m.reshape(n)] for _ in range(self._lambda): z = np.random.multivariate_normal(zero, eye).reshape((n, 1)) # self.print('z\n{}', z) diff --git a/tools/utils.py b/tools/utils.py index 102f62d9e..6a067003d 100644 --- a/tools/utils.py +++ b/tools/utils.py @@ -12,6 +12,70 @@ def get_key_values(d): return {key:value for (key,value) in get_key_values(d)} +def equal(a, b): + """Determines whether integers, floats, lists, tupes or dictionaries are equal""" + if isinstance(a, (int, float)): + return np.isclose(a, b) + elif isinstance(a, (list, tuple)): + if len(a) != len(b): + return False + else: + for i in range(len(a)): + if not np.isclose(a[i], b[i]): + return False + return True + elif isinstance(a, dict): + if len(a) != len(b): + return False + else: + for key in a.keys(): + if key not in b.keys(): + return False + if not np.isclose(a[key], b[key]): + return False + return True + else: + raise Exception('Type not recognized') + +def export_all(obj): + """ + Exports all variables from pysam objects including those not assigned + + Assumes the object is a collection of objects with all the variables within them: + obj: + object1: + variable1: + variable2: + + """ + output_dict = {} + for attribute_name in dir(obj): + try: + attribute = getattr(obj, attribute_name) + except: + continue + if not callable(attribute) and not attribute_name.startswith('__'): + output_dict[attribute_name] = {} + for subattribute_name in dir(attribute): + if subattribute_name.startswith('__'): + continue + try: + subattribute = getattr(attribute, subattribute_name) + except Exception as e: + if 'not assigned' in str(e): + output_dict[attribute_name][subattribute_name] = None + continue + else: + continue + if not callable(subattribute): + output_dict[attribute_name][subattribute_name] = subattribute + + # Remove dictionary if empty + if len(output_dict[attribute_name]) == 0: + del output_dict[attribute_name] + + return output_dict + def array_not_scalar(array): """Return True if array is array-like and not a scalar""" return isinstance(array, Sequence) or (isinstance(array, np.ndarray) and hasattr(array, "__len__")) \ No newline at end of file