From 8f7d5430484b8f4a728d3b0c26ebd42eca5467cd Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 12 Dec 2023 11:30:56 -0500 Subject: [PATCH 01/12] Add support for dynamics to MS Contactor (#1291) * Working on dynamic terms for material balance * Finishing dynamic material baalnces * Adding dynamic terms to energy balances * Updating docs * Adding phase fraction terms --- .../generic/unit_models/mscontactor.rst | 33 +- idaes/models/unit_models/mscontactor.py | 233 ++++++- .../unit_models/tests/test_mscontactor.py | 610 +++++++++++++++--- 3 files changed, 760 insertions(+), 116 deletions(-) diff --git a/docs/reference_guides/model_libraries/generic/unit_models/mscontactor.rst b/docs/reference_guides/model_libraries/generic/unit_models/mscontactor.rst index b50a52c08e..d98a6ee1bb 100644 --- a/docs/reference_guides/model_libraries/generic/unit_models/mscontactor.rst +++ b/docs/reference_guides/model_libraries/generic/unit_models/mscontactor.rst @@ -75,6 +75,13 @@ Variable Name Des :math:`X_{equil,t,x,s,r}` stream + "_equilibrium_reaction_extent" Extent of equilibrium reaction ``r`` in stream ``s`` at ``x`` and ``t`` Only if equilibrium reactions present for stream :math:`X_{inher,t,x,s,r}` stream + "_inherent_reaction_extent" Extent of inherent reaction ``r`` in stream ``s`` at ``x`` and ``t`` Only if inherent reactions present for stream :math:`X_{hetero,t,x,r}` heterogeneous_reaction_extent Extent of heterogeneous reaction ``r`` at ``x`` and ``t`` Only if heterogeneous reactions present +:math:`V_x` volume Total volume of element ``x`` Only if ``has_holdup`` +:math:`f_{t,x,s}` volume_frac_stream Volume fraction of stream ``s`` in element ``x`` at time ``t`` Only if ``has_holdup`` +:math:`\phi_{t,x,s,p}` stream + "_phase_fraction" Volume fraction of phase ``p`` in stream ``s`` in element ``x`` at time ``t`` Only if ``has_holdup`` +:math:`N_{t,x,s,p,j}` stream + "_material_holdup" Holdup of component ``j`` in phase ``p`` for stream ``s`` at ``x`` and ``t`` Only if ``has_holdup`` +:math:`dN/dt_{t,x,s,p,j}` stream + "_material_accumulation" Accumulation of component ``j`` in phase ``p`` for stream ``s`` at ``x`` and ``t`` Only if ``dynamic`` +:math:`U_{t,x,s,p}` stream + "_energy_holdup" Holdup of energy in phase ``p`` for stream ``s`` at ``x`` and ``t`` Only if ``has_holdup`` +:math:`dU/dt_{t,x,s,p}` stream + "_energy_accumulation" Accumulation of energy in phase ``p`` for stream ``s`` at ``x`` and ``t`` Only if ``dynamic`` ============================= ============================================== ============================================================================================================= ========================================= Constraints @@ -82,7 +89,7 @@ Constraints In all cases, the multi-stage contactor model writes a set of material balances for each stream in the model. For component ``j`` in stream ``s`` the following constraint, named ``stream + "_material_balance"``, is written for all finite elements ``x``: -.. math:: 0 = \sum_p{F_{t,x-,s,p,j}} - \sum_p{F_{t,x,s,p,j}} + \left[ \sum_p{F_{side,t,x,s,p,j}} \right] + \sum_o{M_{t,x,s,o,j}} + \left[ \sum_p{G_{rate,t,x,s,p,j}} + \sum_p{G_{equil,t,x,s,p,j}} + \sum_p{G_{inher,t,x,s,p,j}} + \sum_p{G_{hetero,t,x,s,p,j}} \right] +.. math:: dN/dt_{t,x,s,p,j} = \sum_p{F_{t,x-,s,p,j}} - \sum_p{F_{t,x,s,p,j}} + \left[ \sum_p{F_{side,t,x,s,p,j}} \right] + \sum_o{M_{t,x,s,o,j}} + \left[ \sum_p{G_{rate,t,x,s,p,j}} + \sum_p{G_{equil,t,x,s,p,j}} + \sum_p{G_{inher,t,x,s,p,j}} + \sum_p{G_{hetero,t,x,s,p,j}} \right] where ``F`` is the material flow term, ``x-`` represents the previous finite element (``x-1`` in the case of co-current flow and ``x+1`` in the case of counter-current flow), ``F_side`` is the material flow term for a side stream (if present) and ``o`` represents all other streams in the model (for cases where ``s`` is the second index (i.e., M_{t,x,o,s,j}) the term is multiplied by -1). The reaction generation terms are only included if the appropriate reaction type is supported by the reaction or property package for the stream. @@ -94,7 +101,7 @@ Equivalent constraints are written for equilibrium, inherent and heterogeneous r For streams including energy balances (``has_energy_balance = True``) the following constraint (named ``stream + "_energy_balance"``) is written at each finite element: -.. math:: 0 = \sum_p{H_{t,x-,s,p}} - \sum_p{H_{t,x,s,p}} + \biggl[ \sum_p{H_{side,t,x,s,p}} \biggr] + \sum_o{E_{t,x,s,o}} + \biggl[ Q_{t,x,s} \biggr] + \biggl[ \sum_{rate}{\Delta H_{rxn,r} \times X_{rate,t,x,s,r}} + \sum_{equil}{\Delta H_{rxn,r} \times X_{equil,t,x,s,r}} + \sum_{inher}{\Delta H_{rxn,r} \times X_{inher,t,x,s,r}} \biggr] +.. math:: \sum_p{dU/dt_{t,x,s,p}} = \sum_p{H_{t,x-,s,p}} - \sum_p{H_{t,x,s,p}} + \biggl[ \sum_p{H_{side,t,x,s,p}} \biggr] + \sum_o{E_{t,x,s,o}} + \biggl[ Q_{t,x,s} \biggr] + \biggl[ \sum_{rate}{\Delta H_{rxn,r} \times X_{rate,t,x,s,r}} + \sum_{equil}{\Delta H_{rxn,r} \times X_{equil,t,x,s,r}} + \sum_{inher}{\Delta H_{rxn,r} \times X_{inher,t,x,s,r}} \biggr] where ``H`` represent enthalpy flow terms and :math:`\Delta H_{rxn}` represents heat of reaction. The heat of reaction terms are only included if a reaction package is provided for the stream AND the configuration option ``has_heat_of_reaction = True`` is set for the stream. **Note** heterogeneous reactions **do not** support heat of reaction terms as it is uncertain which stream/phase the heat should be added too. @@ -102,10 +109,30 @@ For streams including pressure balances (``has_pressure_balance = True``) the fo .. math:: 0 = P_{t,x-,s} - P_{t,x,s} + \biggl[ \Delta P_{t,x,s} \biggr] -where ``P`` represents pressure. For streams with side streams, the following pressure equality constraint (names ``stream + "_side_stream_pressure_balance"``) is also written: +where ``P`` represents pressure. For streams with side streams, the following pressure equality constraint (named ``stream + "_side_stream_pressure_balance"``) is also written: .. math:: P_{t,x,s} = P_{side,t,x,s} +If ``has_holdup`` is true, the following additional constraints are included to calculate holdup terms. First, ``sum_volume_frac`` constrains the sum of all volume fractions to be 1. + +.. math:: 1 = \sum_s{f_{t,x,s}} + +Additionally, constraints are written for the sum of phase fractions in each stream (named ``stream + "_sum_phase_fractions"``): + +.. math:: 1 = \sum_p{\phi_{t,x,s,p}} + +The material holdup is defined by the following constraint (named ``stream + "_material_holdup_constraint"``): + +.. math:: N_{t,x,s,p,j} = V \times f_{t,x,s} \phi_{t,x,s,p} \times \C_{t,x,s,p,j} + +where :math:`C_{t,x,s,p,j}` is the concentration of component ``j`` in phase ``p`` for stream ``s`` at ``x`` and ``t``. + +The energy holdup is defined by the following constraint (named ``stream + "_energy_holdup_constraint"``): + +.. math:: U_{t,x,s,p} = V*f_{t,x,s} \times \phi_{t,x,s,p} \times \u_{t,x,s,p} + +where :math:`u_{t,x,s,p}` is the internal energy density of phase ``p`` for stream ``s`` at ``x`` and ``t``. + Initialization -------------- diff --git a/idaes/models/unit_models/mscontactor.py b/idaes/models/unit_models/mscontactor.py index 963f6f8ef2..5837066831 100644 --- a/idaes/models/unit_models/mscontactor.py +++ b/idaes/models/unit_models/mscontactor.py @@ -18,9 +18,19 @@ from pandas import DataFrame # Import Pyomo libraries -from pyomo.environ import Block, Constraint, RangeSet, Reals, Set, units, Var +from pyomo.environ import ( + Block, + Constraint, + Expression, + RangeSet, + Reals, + Set, + units, + Var, +) from pyomo.common.config import ConfigDict, ConfigValue, Bool, In from pyomo.contrib.incidence_analysis import solve_strongly_connected_components +from pyomo.dae import DerivativeVar # Import IDAES cores from idaes.core import ( @@ -381,6 +391,8 @@ def build(self): if self.config.heterogeneous_reactions is not None: self._build_heterogeneous_reaction_blocks() + self._add_geometry(uom) + self._build_material_balance_constraints(flow_basis, uom) self._build_energy_balance_constraints(uom) self._build_pressure_balance_constraints(uom) @@ -394,17 +406,16 @@ def _verify_inputs(self): f"{list(self.config.streams.keys())}" ) - if self.config.dynamic: - raise NotImplementedError( - "MSContactor model does not support dynamics yet." - ) - # Build indexing sets self.elements = RangeSet( 1, self.config.number_of_finite_elements, doc="Set of finite elements in cascade (1 to number of elements)", ) + self.streams = Set( + initialize=[k for k in self.config.streams.keys()], + doc="Set of streams in unit", + ) interacting_streams = self.config.interacting_streams # If user did not provide interacting streams list, assume all steams interact @@ -558,15 +569,49 @@ def _build_heterogeneous_reaction_blocks(self): "reactions (reaction_idx)." ) + def _add_geometry(self, uom): + if self.config.has_holdup: + # Add volume for each element + # TODO: Assuming constant volume for now + self.volume = Var( + self.elements, + initialize=1, + units=uom.VOLUME, + doc="Volume of element", + ) + self.volume_frac_stream = Var( + self.flowsheet().time, + self.elements, + self.streams, + initialize=1 / len(self.streams), + units=units.dimensionless, + doc="Volume fraction of each stream in element", + ) + + @self.Constraint( + self.flowsheet().time, + self.elements, + doc="Sum of volume fractions constraint", + ) + def sum_volume_frac(b, t, e): + return 1 == sum(b.volume_frac_stream[t, e, s] for s in b.streams) + + for stream in self.config.streams.keys(): + phase_list = getattr(self, stream).phase_list + _add_phase_fractions(self, stream, phase_list) + def _build_material_balance_constraints(self, flow_basis, uom): # Get units for transfer terms if flow_basis is MaterialFlowBasis.molar: mb_units = uom.FLOW_MOLE + hu_units = uom.AMOUNT elif flow_basis is MaterialFlowBasis.mass: mb_units = uom.FLOW_MASS + hu_units = uom.MASS else: # Flow type other, so cannot determine units mb_units = None + hu_units = None # Material transfer terms are indexed by stream pairs and components. # Convention is that a positive material flow term indicates flow into @@ -603,6 +648,49 @@ def _build_material_balance_constraints(self, flow_basis, uom): if hasattr(self, stream + "_reactions"): reaction_block = getattr(self, stream + "_reactions") + # Material holdup and accumulation + if self.config.has_holdup: + material_holdup = Var( + self.flowsheet().time, + self.elements, + pc_set, + domain=Reals, + initialize=1.0, + doc="Material holdup of stream in element", + units=hu_units, + ) + self.add_component( + stream + "_material_holdup", + material_holdup, + ) + + holdup_eq = Constraint( + self.flowsheet().time, + self.elements, + pc_set, + doc=f"Holdup constraint for stream {stream}", + rule=partial( + _holdup_rule, + stream=stream, + ), + ) + self.add_component( + stream + "_material_holdup_constraint", + holdup_eq, + ) + + if self.config.dynamic: + material_accumulation = DerivativeVar( + material_holdup, + wrt=self.flowsheet().time, + doc="Material accumulation for in element", + units=mb_units, + ) + self.add_component( + stream + "_material_accumulation", + material_accumulation, + ) + # Add homogeneous rate reaction terms (if required) if sconfig.has_rate_reactions: if not hasattr(sconfig.reaction_package, "rate_reaction_idx"): @@ -812,6 +900,52 @@ def _build_energy_balance_constraints(self, uom): for stream, pconfig in self.config.streams.items(): if pconfig.has_energy_balance: + state_block = getattr(self, stream) + phase_list = state_block.phase_list + + # Material holdup and accumulation + if self.config.has_holdup: + energy_holdup = Var( + self.flowsheet().time, + self.elements, + phase_list, + domain=Reals, + initialize=1.0, + doc="Energy holdup of stream in element", + units=uom.ENERGY, + ) + self.add_component( + stream + "_energy_holdup", + energy_holdup, + ) + + energy_holdup_eq = Constraint( + self.flowsheet().time, + self.elements, + phase_list, + doc=f"Energy holdup constraint for stream {stream}", + rule=partial( + _energy_holdup_rule, + stream=stream, + ), + ) + self.add_component( + stream + "_energy_holdup_constraint", + energy_holdup_eq, + ) + + if self.config.dynamic: + energy_accumulation = DerivativeVar( + energy_holdup, + wrt=self.flowsheet().time, + doc="Energy accumulation for in element", + units=uom.POWER, + ) + self.add_component( + stream + "_energy_accumulation", + energy_accumulation, + ) + if pconfig.has_heat_transfer: heat = Var( self.flowsheet().time, @@ -1136,7 +1270,15 @@ def _material_balance_rule(blk, t, s, j, stream, mb_units): for p in phase_list ) - return 0 == rhs + if not blk.config.dynamic: + lhs = 0 * mb_units + else: + acc = getattr(blk, stream + "_material_accumulation") + lhs = sum(acc[t, s, p, j] for p in phase_list) + if mb_units is not None: + lhs = units.convert(lhs, mb_units) + + return lhs == rhs def _get_energy_transfer_term(blk, uom): @@ -1225,7 +1367,13 @@ def _energy_balance_rule(blk, t, s, stream, uom): for e in pconfig.reaction_package.equilibrium_reaction_idx ) - return 0 == rhs + if not blk.config.dynamic: + lhs = 0 * uom.POWER + else: + acc = getattr(blk, stream + "_energy_accumulation") + lhs = units.convert(sum(acc[t, s, p] for p in phase_list), uom.POWER) + + return lhs == rhs def _pressure_balance_rule(blk, t, s, stream, uom): @@ -1246,7 +1394,7 @@ def _pressure_balance_rule(blk, t, s, stream, uom): if pconfig.has_pressure_change: rhs += getattr(blk, stream + "_deltaP")[t, s] - return 0 == rhs + return 0 * uom.PRESSURE == rhs def _side_stream_pressure_rule(b, t, s, stream): @@ -1254,3 +1402,70 @@ def _side_stream_pressure_rule(b, t, s, stream): side_state = getattr(b, stream + "_side_stream_state")[t, s] return stage_state.pressure == side_state.pressure + + +def _holdup_rule(b, t, e, p, j, stream): + holdup = getattr(b, stream + "_material_holdup") + stage_state = getattr(b, stream)[t, e] + phase_frac = getattr(b, stream + "_phase_fraction") + + return holdup[t, e, p, j] == ( + b.volume[e] + * b.volume_frac_stream[t, e, stream] + * phase_frac[t, e, p] + * stage_state.get_material_density_terms(p, j) + ) + + +def _energy_holdup_rule(b, t, e, p, stream): + holdup = getattr(b, stream + "_energy_holdup") + stage_state = getattr(b, stream)[t, e] + phase_frac = getattr(b, stream + "_phase_fraction") + + return holdup[t, e, p] == ( + b.volume[e] + * b.volume_frac_stream[t, e, stream] + * phase_frac[t, e, p] + * stage_state.get_energy_density_terms(p) + ) + + +def _add_phase_fractions(b, stream, phase_list): + if len(phase_list) > 1: + phase_fraction = Var( + b.flowsheet().time, + b.elements, + phase_list, + initialize=1 / len(phase_list), + doc=f"Volume fraction of holdup by phase in stream {stream}", + ) + b.add_component(stream + "_phase_fraction", phase_fraction) + + sum_of_phase_fractions = Constraint( + b.flowsheet().time, + b.elements, + rule=partial( + _sum_phase_frac_rule, phase_frac=phase_fraction, phase_list=phase_list + ), + doc=f"Sum of phase fractions constraint for stream {stream}", + ) + b.add_component(stream + "_sum_phase_fractions", sum_of_phase_fractions) + + else: + + def phase_frac_rule(b, t, x, p): + return 1 + + phase_fraction = Expression( + b.flowsheet().time, + b.elements, + phase_list, + rule=phase_frac_rule, + doc=f"Volume fraction of holdup by phase in stream {stream}", + ) + + b.add_component(stream + "_phase_fraction", phase_fraction) + + +def _sum_phase_frac_rule(b, t, x, phase_frac, phase_list): + return 1 == sum(phase_frac[t, x, p] for p in phase_list) diff --git a/idaes/models/unit_models/tests/test_mscontactor.py b/idaes/models/unit_models/tests/test_mscontactor.py index 55b78ca9f2..028d1e8b3f 100644 --- a/idaes/models/unit_models/tests/test_mscontactor.py +++ b/idaes/models/unit_models/tests/test_mscontactor.py @@ -23,6 +23,7 @@ Block, ConcreteModel, Constraint, + Expression, log, RangeSet, Set, @@ -34,6 +35,7 @@ from pyomo.network import Arc, Port from pyomo.common.config import ConfigBlock from pyomo.util.check_units import assert_units_consistent, assert_units_equivalent +from pyomo.dae import DerivativeVar from idaes.core import ( FlowsheetBlock, @@ -133,6 +135,12 @@ def get_material_flow_terms(self, p, j): def get_enthalpy_flow_terms(self, p): return self.enth_flow + def get_material_density_terms(self, p, j): + return 42 + + def get_energy_density_terms(self, p): + return 43 + def get_material_flow_basis(self): return MaterialFlowBasis.molar @@ -200,6 +208,12 @@ def get_material_flow_terms(self, p, j): def get_enthalpy_flow_terms(self, p): return self.enth_flow + def get_material_density_terms(self, p, j): + return 52 + + def get_energy_density_terms(self, p): + return 53 + def get_material_flow_basis(self): return MaterialFlowBasis.molar @@ -254,6 +268,45 @@ def get_material_flow_basis(self): return MaterialFlowBasis.mass +@declare_process_block_class("Parameters4") +class Parameter3Data(PhysicalParameterBlock): + def build(self): + super().build() + + self.phase1 = Phase() + self.phase2 = Phase() + + self.solvent1 = Component() + self.solute1 = Component() + self.solute2 = Component() + self.solute3 = Component() + + self._state_block_class = StateBlock4 + + @classmethod + def define_metadata(cls, obj): + obj.add_default_units( + { + "time": units.s, + "length": units.m, + "mass": units.kg, + "amount": units.mol, + "temperature": units.K, + } + ) + + +@declare_process_block_class("StateBlock4", block_class=SBlock1Base) +class StateBlock4Data(StateBlockData): + CONFIG = ConfigBlock(implicit=True) + + def build(self): + super().build() + + def get_material_flow_basis(self): + return MaterialFlowBasis.molar + + # ----------------------------------------------------------------------------- # Frame class for unit testing @declare_process_block_class("ECFrame") @@ -285,6 +338,31 @@ def model(self): return m + @pytest.fixture + def dynamic(self): + m = ConcreteModel() + m.fs = FlowsheetBlock( + dynamic=True, + time_set=[0, 1], + time_units=units.s, + ) + + m.fs.properties1 = Parameters1() + m.fs.properties2 = Parameters2() + + m.fs.unit = ECFrame( + number_of_finite_elements=2, + streams={ + "stream1": {"property_package": m.fs.properties1}, + "stream2": { + "property_package": m.fs.properties2, + "flow_direction": FlowDirection.backward, + }, + }, + ) + + return m + @pytest.mark.unit def test_config(self, model): assert not model.fs.unit.config.dynamic @@ -353,27 +431,6 @@ def test_verify_inputs_too_few_streams(self): ): m.fs.unit._verify_inputs() - @pytest.mark.unit - def test_verify_inputs_dynamic(self): - m = ConcreteModel() - m.fs = FlowsheetBlock(dynamic=True, time_units=units.s) - - m.fs.properties1 = Parameters1() - - m.fs.unit = ECFrame( - number_of_finite_elements=2, - streams={ - "stream1": {"property_package": m.fs.properties1}, - "stream2": {"property_package": m.fs.properties1}, - }, - ) - - with pytest.raises( - NotImplementedError, - match="MSContactor model does not support dynamics yet.", - ): - m.fs.unit._verify_inputs() - @pytest.mark.unit def test_verify_inputs_no_common_components(self): m = ConcreteModel() @@ -688,6 +745,96 @@ def test_get_state_blocks_side_streams(self, model): assert out_state is model.fs.unit.stream2[0, 2] assert side_state is model.fs.unit.stream2_side_stream_state[0, 2] + @pytest.mark.unit + def test_add_geometry_no_holdup(self, model): + model.fs.unit._verify_inputs() + flow_basis, uom = model.fs.unit._build_state_blocks() + model.fs.unit._add_geometry(uom) + + assert not hasattr(model.fs.unit, "volume") + assert not hasattr(model.fs.unit, "volume_frac_stream") + assert not hasattr(model.fs.unit, "sum_volume_frac") + + assert not hasattr(model.fs.unit, "stream1_phase_fraction") + assert not hasattr(model.fs.unit, "stream1_sum_phase_fractions") + + assert not hasattr(model.fs.unit, "stream2_phase_fraction") + assert not hasattr(model.fs.unit, "stream2_sum_phase_fractions") + + @pytest.mark.unit + def test_add_geometry_holdup_single_phase(self, dynamic): + dynamic.fs.unit._verify_inputs() + flow_basis, uom = dynamic.fs.unit._build_state_blocks() + dynamic.fs.unit._add_geometry(uom) + + assert isinstance(dynamic.fs.unit.volume, Var) + assert len(dynamic.fs.unit.volume) == 2 + assert isinstance(dynamic.fs.unit.volume_frac_stream, Var) + assert len(dynamic.fs.unit.volume_frac_stream) == 2 * 2 * 2 + assert isinstance(dynamic.fs.unit.sum_volume_frac, Constraint) + assert len(dynamic.fs.unit.sum_volume_frac) == 2 * 2 * 1 + + assert isinstance(dynamic.fs.unit.stream1_phase_fraction, Expression) + assert isinstance(dynamic.fs.unit.stream2_phase_fraction, Expression) + assert not hasattr(dynamic.fs.unit, "stream1_sum_phase_fractions") + assert not hasattr(dynamic.fs.unit, "stream2_sum_phase_fractions") + + for i in dynamic.fs.unit.stream1_phase_fraction.values(): + assert i.expr == 1 + for i in dynamic.fs.unit.stream2_phase_fraction.values(): + assert i.expr == 1 + + @pytest.mark.unit + def test_add_geometry_holdup_multi_phase(self): + m = ConcreteModel() + m.fs = FlowsheetBlock( + dynamic=True, + time_set=[0, 1], + time_units=units.s, + ) + + m.fs.properties1 = Parameters1() + m.fs.properties2 = Parameters4() + + m.fs.unit = ECFrame( + number_of_finite_elements=2, + streams={ + "stream1": {"property_package": m.fs.properties1}, + "stream2": { + "property_package": m.fs.properties2, + "flow_direction": FlowDirection.backward, + }, + }, + ) + + m.fs.unit._verify_inputs() + flow_basis, uom = m.fs.unit._build_state_blocks() + m.fs.unit._add_geometry(uom) + + assert isinstance(m.fs.unit.volume, Var) + assert len(m.fs.unit.volume) == 2 + assert isinstance(m.fs.unit.volume_frac_stream, Var) + assert len(m.fs.unit.volume_frac_stream) == 2 * 2 * 2 + assert isinstance(m.fs.unit.sum_volume_frac, Constraint) + assert len(m.fs.unit.sum_volume_frac) == 2 * 2 * 1 + + assert isinstance(m.fs.unit.stream1_phase_fraction, Expression) + assert isinstance(m.fs.unit.stream2_phase_fraction, Var) + assert not hasattr(m.fs.unit, "stream1_sum_phase_fractions") + assert isinstance(m.fs.unit.stream2_sum_phase_fractions, Constraint) + + for i in m.fs.unit.stream1_phase_fraction.values(): + assert i.expr == 1 + + for (t, e), con in m.fs.unit.stream2_sum_phase_fractions.items(): + assert str(con.expr) == str( + 1 + == sum( + m.fs.unit.stream2_phase_fraction[t, e, p] + for p in ["phase1", "phase2"] + ) + ) + @pytest.mark.unit def test_material_balances(self, model): model.fs.unit._verify_inputs() @@ -707,24 +854,24 @@ def test_material_balances(self, model): for j in ["solvent1", "solute3"]: # no mass transfer, forward flow assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 2].flow_mol_phase_comp["phase1", j] ) for j in ["solute1", "solute2"]: # has +ve mass transfer, forward flow assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] + model.fs.unit.material_transfer_term[0, 1, "stream1", "stream2", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 2].flow_mol_phase_comp["phase1", j] + model.fs.unit.material_transfer_term[0, 2, "stream1", "stream2", j] @@ -735,29 +882,179 @@ def test_material_balances(self, model): assert len(model.fs.unit.stream2_material_balance) == 6 for j in ["solvent2"]: # no mass transfer, reverse flow assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 1].flow_mol_phase_comp["phase1", j] ) for j in ["solute1", "solute2"]: # has -ve mass transfer, reverse flow assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.material_transfer_term[0, 2, "stream1", "stream2", j] ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.material_transfer_term[0, 1, "stream1", "stream2", j] ) + @pytest.mark.unit + def test_material_balances_dynamic(self, dynamic): + dynamic.fs.unit._verify_inputs() + flow_basis, uom = dynamic.fs.unit._build_state_blocks() + dynamic.fs.unit._add_geometry(uom) + dynamic.fs.unit._build_material_balance_constraints(flow_basis, uom) + + assert isinstance(dynamic.fs.unit.material_transfer_term, Var) + # One stream pair with two common components over two elements and 2 time point + assert len(dynamic.fs.unit.material_transfer_term) == 8 + assert_units_equivalent( + dynamic.fs.unit.material_transfer_term._units, units.mol / units.s + ) + + assert isinstance(dynamic.fs.unit.stream1_material_holdup, Var) + assert len(dynamic.fs.unit.stream1_material_holdup) == 16 + assert isinstance(dynamic.fs.unit.stream1_material_accumulation, DerivativeVar) + assert len(dynamic.fs.unit.stream1_material_accumulation) == 16 + assert isinstance( + dynamic.fs.unit.stream1_material_holdup_constraint, Constraint + ) + assert len(dynamic.fs.unit.stream1_material_holdup_constraint) == 16 + for ( + t, + x, + p, + j, + ), con in dynamic.fs.unit.stream1_material_holdup_constraint.items(): + assert str(con.expr) == str( + dynamic.fs.unit.stream1_material_holdup[t, x, p, j] + == dynamic.fs.unit.volume[x] + * dynamic.fs.unit.volume_frac_stream[t, x, "stream1"] + * dynamic.fs.unit.stream1_phase_fraction[t, x, p] + * 42 + ) + + assert isinstance(dynamic.fs.unit.stream2_material_holdup, Var) + assert len(dynamic.fs.unit.stream2_material_holdup) == 12 + assert isinstance(dynamic.fs.unit.stream2_material_accumulation, DerivativeVar) + assert len(dynamic.fs.unit.stream2_material_accumulation) == 12 + assert isinstance( + dynamic.fs.unit.stream2_material_holdup_constraint, Constraint + ) + assert len(dynamic.fs.unit.stream2_material_holdup_constraint) == 12 + for ( + t, + x, + p, + j, + ), con in dynamic.fs.unit.stream2_material_holdup_constraint.items(): + assert str(con.expr) == str( + dynamic.fs.unit.stream2_material_holdup[t, x, p, j] + == dynamic.fs.unit.volume[x] + * dynamic.fs.unit.volume_frac_stream[t, x, "stream2"] + * dynamic.fs.unit.stream2_phase_fraction[t, x, p] + * 52 + ) + + assert isinstance(dynamic.fs.unit.stream1_material_balance, Constraint) + # 2 time point, 2 elements, 4 components + assert len(dynamic.fs.unit.stream1_material_balance) == 16 + + for t in dynamic.fs.time: + for j in ["solvent1", "solute3"]: # no mass transfer, forward flow + assert str( + dynamic.fs.unit.stream1_material_balance[t, 1, j].expr + ) == str( + dynamic.fs.unit.stream1_material_accumulation[t, 1, "phase1", j] + == dynamic.fs.unit.stream1_inlet_state[t].flow_mol_phase_comp[ + "phase1", j + ] + - dynamic.fs.unit.stream1[t, 1].flow_mol_phase_comp["phase1", j] + ) + assert str( + dynamic.fs.unit.stream1_material_balance[t, 2, j].expr + ) == str( + dynamic.fs.unit.stream1_material_accumulation[t, 2, "phase1", j] + == dynamic.fs.unit.stream1[t, 1].flow_mol_phase_comp["phase1", j] + - dynamic.fs.unit.stream1[t, 2].flow_mol_phase_comp["phase1", j] + ) + for j in ["solute1", "solute2"]: # has +ve mass transfer, forward flow + assert str( + dynamic.fs.unit.stream1_material_balance[t, 1, j].expr + ) == str( + dynamic.fs.unit.stream1_material_accumulation[t, 1, "phase1", j] + == dynamic.fs.unit.stream1_inlet_state[t].flow_mol_phase_comp[ + "phase1", j + ] + - dynamic.fs.unit.stream1[t, 1].flow_mol_phase_comp["phase1", j] + + dynamic.fs.unit.material_transfer_term[ + t, 1, "stream1", "stream2", j + ] + ) + assert str( + dynamic.fs.unit.stream1_material_balance[t, 2, j].expr + ) == str( + dynamic.fs.unit.stream1_material_accumulation[t, 2, "phase1", j] + == dynamic.fs.unit.stream1[t, 1].flow_mol_phase_comp["phase1", j] + - dynamic.fs.unit.stream1[t, 2].flow_mol_phase_comp["phase1", j] + + dynamic.fs.unit.material_transfer_term[ + t, 2, "stream1", "stream2", j + ] + ) + + assert isinstance(dynamic.fs.unit.stream2_material_balance, Constraint) + # 2 time point, 2 elements, 3 components + assert len(dynamic.fs.unit.stream2_material_balance) == 12 + + for t in dynamic.fs.time: + for j in ["solvent2"]: # no mass transfer, reverse flow + assert str( + dynamic.fs.unit.stream2_material_balance[t, 2, j].expr + ) == str( + dynamic.fs.unit.stream2_material_accumulation[t, 2, "phase1", j] + == dynamic.fs.unit.stream2_inlet_state[t].flow_mol_phase_comp[ + "phase1", j + ] + - dynamic.fs.unit.stream2[t, 2].flow_mol_phase_comp["phase1", j] + ) + assert str( + dynamic.fs.unit.stream2_material_balance[t, 1, j].expr + ) == str( + dynamic.fs.unit.stream2_material_accumulation[t, 1, "phase1", j] + == dynamic.fs.unit.stream2[t, 2].flow_mol_phase_comp["phase1", j] + - dynamic.fs.unit.stream2[t, 1].flow_mol_phase_comp["phase1", j] + ) + for j in ["solute1", "solute2"]: # has -ve mass transfer, reverse flow + assert str( + dynamic.fs.unit.stream2_material_balance[t, 2, j].expr + ) == str( + dynamic.fs.unit.stream2_material_accumulation[t, 2, "phase1", j] + == dynamic.fs.unit.stream2_inlet_state[t].flow_mol_phase_comp[ + "phase1", j + ] + - dynamic.fs.unit.stream2[t, 2].flow_mol_phase_comp["phase1", j] + - dynamic.fs.unit.material_transfer_term[ + t, 2, "stream1", "stream2", j + ] + ) + assert str( + dynamic.fs.unit.stream2_material_balance[t, 1, j].expr + ) == str( + dynamic.fs.unit.stream2_material_accumulation[t, 1, "phase1", j] + == dynamic.fs.unit.stream2[t, 2].flow_mol_phase_comp["phase1", j] + - dynamic.fs.unit.stream2[t, 1].flow_mol_phase_comp["phase1", j] + - dynamic.fs.unit.material_transfer_term[ + t, 1, "stream1", "stream2", j + ] + ) + @pytest.mark.unit def test_build_material_balances_no_feed(self, model): model.fs.unit.config.streams["stream2"].has_feed = False @@ -778,24 +1075,24 @@ def test_build_material_balances_no_feed(self, model): for j in ["solvent1", "solute3"]: # no mass transfer, forward flow assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 2].flow_mol_phase_comp["phase1", j] ) for j in ["solute1", "solute2"]: # has +ve mass transfer, forward flow assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] + model.fs.unit.material_transfer_term[0, 1, "stream1", "stream2", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 2].flow_mol_phase_comp["phase1", j] + model.fs.unit.material_transfer_term[0, 2, "stream1", "stream2", j] @@ -806,21 +1103,22 @@ def test_build_material_balances_no_feed(self, model): assert len(model.fs.unit.stream2_material_balance) == 6 for j in ["solvent2"]: # no mass transfer, reverse flow assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 == -model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] + 0 * (units.mol * units.s**-1) + == -model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 1].flow_mol_phase_comp["phase1", j] ) for j in ["solute1", "solute2"]: # has -ve mass transfer, reverse flow assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == -model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.material_transfer_term[0, 2, "stream1", "stream2", j] ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.material_transfer_term[0, 1, "stream1", "stream2", j] @@ -846,24 +1144,24 @@ def test_material_balances_side_stream(self, model): for j in ["solvent1", "solute3"]: # no mass transfer, forward flow assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 2].flow_mol_phase_comp["phase1", j] ) for j in ["solute1", "solute2"]: # has +ve mass transfer, forward flow assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] + model.fs.unit.material_transfer_term[0, 1, "stream1", "stream2", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream1[0, 1].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream1[0, 2].flow_mol_phase_comp["phase1", j] + model.fs.unit.material_transfer_term[0, 2, "stream1", "stream2", j] @@ -874,12 +1172,12 @@ def test_material_balances_side_stream(self, model): assert len(model.fs.unit.stream2_material_balance) == 6 for j in ["solvent2"]: # no mass transfer, reverse flow assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 1].flow_mol_phase_comp["phase1", j] + model.fs.unit.stream2_side_stream_state[0, 1].flow_mol_phase_comp[ @@ -888,13 +1186,13 @@ def test_material_balances_side_stream(self, model): ) for j in ["solute1", "solute2"]: # has -ve mass transfer, reverse flow assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2_inlet_state[0].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.material_transfer_term[0, 2, "stream1", "stream2", j] ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == model.fs.unit.stream2[0, 2].flow_mol_phase_comp["phase1", j] - model.fs.unit.stream2[0, 1].flow_mol_phase_comp["phase1", j] + model.fs.unit.stream2_side_stream_state[0, 1].flow_mol_phase_comp[ @@ -923,7 +1221,7 @@ def test_energy_balances(self, model): assert len(model.fs.unit.stream1_energy_balance) == 2 assert str(model.fs.unit.stream1_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1_inlet_state[0].enth_flow - model.fs.unit.stream1[0, 1].enth_flow, @@ -932,7 +1230,7 @@ def test_energy_balances(self, model): + model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) assert str(model.fs.unit.stream1_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1[0, 1].enth_flow - model.fs.unit.stream1[0, 2].enth_flow, @@ -946,7 +1244,7 @@ def test_energy_balances(self, model): assert len(model.fs.unit.stream2_energy_balance) == 2 assert str(model.fs.unit.stream2_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream2_inlet_state[0].enth_flow - model.fs.unit.stream2[0, 2].enth_flow, @@ -955,7 +1253,7 @@ def test_energy_balances(self, model): - model.fs.unit.energy_transfer_term[0, 2, "stream1", "stream2"] ) assert str(model.fs.unit.stream2_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream2[0, 2].enth_flow - model.fs.unit.stream2[0, 1].enth_flow, @@ -964,6 +1262,110 @@ def test_energy_balances(self, model): - model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) + @pytest.mark.unit + def test_energy_balances_dynamic(self, dynamic): + dynamic.fs.unit._verify_inputs() + _, uom = dynamic.fs.unit._build_state_blocks() + dynamic.fs.unit._add_geometry(uom) + dynamic.fs.unit._build_energy_balance_constraints(uom) + + assert isinstance(dynamic.fs.unit.energy_transfer_term, Var) + # 1 stream interaction, 2 elements + assert len(dynamic.fs.unit.energy_transfer_term) == 4 + for k in dynamic.fs.unit.energy_transfer_term: + assert k in [ + (0, 1, "stream1", "stream2"), + (0, 2, "stream1", "stream2"), + (1, 1, "stream1", "stream2"), + (1, 2, "stream1", "stream2"), + ] + + assert isinstance(dynamic.fs.unit.stream1_energy_holdup, Var) + assert len(dynamic.fs.unit.stream1_energy_holdup) == 4 + assert isinstance(dynamic.fs.unit.stream1_energy_accumulation, DerivativeVar) + assert len(dynamic.fs.unit.stream1_energy_accumulation) == 4 + assert isinstance(dynamic.fs.unit.stream1_energy_holdup_constraint, Constraint) + assert len(dynamic.fs.unit.stream1_energy_holdup_constraint) == 4 + for ( + t, + x, + p, + ), con in dynamic.fs.unit.stream1_energy_holdup_constraint.items(): + assert str(con.expr) == str( + dynamic.fs.unit.stream1_energy_holdup[t, x, p] + == dynamic.fs.unit.volume[x] + * dynamic.fs.unit.volume_frac_stream[t, x, "stream1"] + * dynamic.fs.unit.stream1_phase_fraction[t, x, p] + * 43 + ) + + assert isinstance(dynamic.fs.unit.stream2_energy_holdup, Var) + assert len(dynamic.fs.unit.stream2_energy_holdup) == 4 + assert isinstance(dynamic.fs.unit.stream2_energy_accumulation, DerivativeVar) + assert len(dynamic.fs.unit.stream2_energy_accumulation) == 4 + assert isinstance(dynamic.fs.unit.stream2_energy_holdup_constraint, Constraint) + assert len(dynamic.fs.unit.stream2_energy_holdup_constraint) == 4 + for ( + t, + x, + p, + ), con in dynamic.fs.unit.stream2_energy_holdup_constraint.items(): + assert str(con.expr) == str( + dynamic.fs.unit.stream2_energy_holdup[t, x, p] + == dynamic.fs.unit.volume[x] + * dynamic.fs.unit.volume_frac_stream[t, x, "stream2"] + * dynamic.fs.unit.stream2_phase_fraction[t, x, p] + * 53 + ) + + assert isinstance(dynamic.fs.unit.stream1_energy_balance, Constraint) + # 1 time point, 2 elements + assert len(dynamic.fs.unit.stream1_energy_balance) == 4 + + for t in dynamic.fs.time: + assert str(dynamic.fs.unit.stream1_energy_balance[t, 1].expr) == str( + dynamic.fs.unit.stream1_energy_accumulation[t, 1, "phase1"] + == units.convert( + dynamic.fs.unit.stream1_inlet_state[t].enth_flow + - dynamic.fs.unit.stream1[t, 1].enth_flow, + units.kg * units.m**2 / units.s**3, + ) + + dynamic.fs.unit.energy_transfer_term[t, 1, "stream1", "stream2"] + ) + assert str(dynamic.fs.unit.stream1_energy_balance[t, 2].expr) == str( + dynamic.fs.unit.stream1_energy_accumulation[t, 2, "phase1"] + == units.convert( + dynamic.fs.unit.stream1[t, 1].enth_flow + - dynamic.fs.unit.stream1[t, 2].enth_flow, + units.kg * units.m**2 / units.s**3, + ) + + dynamic.fs.unit.energy_transfer_term[t, 2, "stream1", "stream2"] + ) + + assert isinstance(dynamic.fs.unit.stream2_energy_balance, Constraint) + # 1 time point, 2 elements + assert len(dynamic.fs.unit.stream2_energy_balance) == 4 + + for t in dynamic.fs.time: + assert str(dynamic.fs.unit.stream2_energy_balance[t, 2].expr) == str( + dynamic.fs.unit.stream2_energy_accumulation[t, 2, "phase1"] + == units.convert( + dynamic.fs.unit.stream2_inlet_state[t].enth_flow + - dynamic.fs.unit.stream2[t, 2].enth_flow, + units.kg * units.m**2 / units.s**3, + ) + - dynamic.fs.unit.energy_transfer_term[t, 2, "stream1", "stream2"] + ) + assert str(dynamic.fs.unit.stream2_energy_balance[t, 1].expr) == str( + dynamic.fs.unit.stream2_energy_accumulation[t, 1, "phase1"] + == units.convert( + dynamic.fs.unit.stream2[t, 2].enth_flow + - dynamic.fs.unit.stream2[t, 1].enth_flow, + units.kg * units.m**2 / units.s**3, + ) + - dynamic.fs.unit.energy_transfer_term[t, 1, "stream1", "stream2"] + ) + @pytest.mark.unit def test_energy_balances_has_heat_transfer(self, model): model.fs.unit.config.streams["stream2"].has_heat_transfer = True @@ -977,7 +1379,7 @@ def test_energy_balances_has_heat_transfer(self, model): assert len(model.fs.unit.stream1_energy_balance) == 2 assert str(model.fs.unit.stream1_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1_inlet_state[0].enth_flow - model.fs.unit.stream1[0, 1].enth_flow, @@ -986,7 +1388,7 @@ def test_energy_balances_has_heat_transfer(self, model): + model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) assert str(model.fs.unit.stream1_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1[0, 1].enth_flow - model.fs.unit.stream1[0, 2].enth_flow, @@ -1004,7 +1406,7 @@ def test_energy_balances_has_heat_transfer(self, model): assert len(model.fs.unit.stream2_energy_balance) == 2 assert str(model.fs.unit.stream2_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream2_inlet_state[0].enth_flow - model.fs.unit.stream2[0, 2].enth_flow, @@ -1014,7 +1416,7 @@ def test_energy_balances_has_heat_transfer(self, model): + model.fs.unit.stream2_heat[0, 2] ) assert str(model.fs.unit.stream2_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream2[0, 2].enth_flow - model.fs.unit.stream2[0, 1].enth_flow, @@ -1036,7 +1438,7 @@ def test_energy_balances_no_feed(self, model): assert len(model.fs.unit.stream1_energy_balance) == 2 assert str(model.fs.unit.stream1_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1_inlet_state[0].enth_flow - model.fs.unit.stream1[0, 1].enth_flow, @@ -1045,7 +1447,7 @@ def test_energy_balances_no_feed(self, model): + model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) assert str(model.fs.unit.stream1_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1[0, 1].enth_flow - model.fs.unit.stream1[0, 2].enth_flow, @@ -1059,7 +1461,7 @@ def test_energy_balances_no_feed(self, model): assert len(model.fs.unit.stream2_energy_balance) == 2 assert str(model.fs.unit.stream2_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( -model.fs.unit.stream2[0, 2].enth_flow, units.kg * units.m**2 / units.s**3, @@ -1067,7 +1469,7 @@ def test_energy_balances_no_feed(self, model): - model.fs.unit.energy_transfer_term[0, 2, "stream1", "stream2"] ) assert str(model.fs.unit.stream2_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream2[0, 2].enth_flow - model.fs.unit.stream2[0, 1].enth_flow, @@ -1088,7 +1490,7 @@ def test_energy_balances_has_energy_balance_false(self, model): assert len(model.fs.unit.stream1_energy_balance) == 2 assert str(model.fs.unit.stream1_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1_inlet_state[0].enth_flow - model.fs.unit.stream1[0, 1].enth_flow, @@ -1097,7 +1499,7 @@ def test_energy_balances_has_energy_balance_false(self, model): + model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) assert str(model.fs.unit.stream1_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1[0, 1].enth_flow - model.fs.unit.stream1[0, 2].enth_flow, @@ -1120,7 +1522,7 @@ def test_energy_balances_side_stream(self, model): assert len(model.fs.unit.stream1_energy_balance) == 2 assert str(model.fs.unit.stream1_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1_inlet_state[0].enth_flow - model.fs.unit.stream1[0, 1].enth_flow, @@ -1129,7 +1531,7 @@ def test_energy_balances_side_stream(self, model): + model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) assert str(model.fs.unit.stream1_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream1[0, 1].enth_flow - model.fs.unit.stream1[0, 2].enth_flow, @@ -1143,7 +1545,7 @@ def test_energy_balances_side_stream(self, model): assert len(model.fs.unit.stream2_energy_balance) == 2 assert str(model.fs.unit.stream2_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream2_inlet_state[0].enth_flow - model.fs.unit.stream2[0, 2].enth_flow, @@ -1152,7 +1554,7 @@ def test_energy_balances_side_stream(self, model): - model.fs.unit.energy_transfer_term[0, 2, "stream1", "stream2"] ) assert str(model.fs.unit.stream2_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( model.fs.unit.stream2[0, 2].enth_flow - model.fs.unit.stream2[0, 1].enth_flow @@ -1173,7 +1575,7 @@ def test_pressure_balances(self, model): assert len(model.fs.unit.stream1_pressure_balance) == 2 assert str(model.fs.unit.stream1_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1_inlet_state[0].pressure - model.fs.unit.stream1[0, 1].pressure, @@ -1181,7 +1583,7 @@ def test_pressure_balances(self, model): ) ) assert str(model.fs.unit.stream1_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1[0, 1].pressure - model.fs.unit.stream1[0, 2].pressure, @@ -1194,7 +1596,7 @@ def test_pressure_balances(self, model): assert len(model.fs.unit.stream2_pressure_balance) == 2 assert str(model.fs.unit.stream2_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream2_inlet_state[0].pressure - model.fs.unit.stream2[0, 2].pressure, @@ -1202,7 +1604,7 @@ def test_pressure_balances(self, model): ) ) assert str(model.fs.unit.stream2_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream2[0, 2].pressure - model.fs.unit.stream2[0, 1].pressure, @@ -1226,7 +1628,7 @@ def test_pressure_balances_deltaP(self, model): assert len(model.fs.unit.stream1_pressure_balance) == 2 assert str(model.fs.unit.stream1_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1_inlet_state[0].pressure - model.fs.unit.stream1[0, 1].pressure, @@ -1234,7 +1636,7 @@ def test_pressure_balances_deltaP(self, model): ) ) assert str(model.fs.unit.stream1_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1[0, 1].pressure - model.fs.unit.stream1[0, 2].pressure, @@ -1251,7 +1653,7 @@ def test_pressure_balances_deltaP(self, model): assert len(model.fs.unit.stream2_pressure_balance) == 2 assert str(model.fs.unit.stream2_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream2_inlet_state[0].pressure - model.fs.unit.stream2[0, 2].pressure, @@ -1260,7 +1662,7 @@ def test_pressure_balances_deltaP(self, model): + model.fs.unit.stream2_deltaP[0, 2] ) assert str(model.fs.unit.stream2_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream2[0, 2].pressure - model.fs.unit.stream2[0, 1].pressure, @@ -1284,7 +1686,7 @@ def test_pressure_balances_no_feed(self, model): assert len(model.fs.unit.stream1_pressure_balance) == 2 assert str(model.fs.unit.stream1_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1_inlet_state[0].pressure - model.fs.unit.stream1[0, 1].pressure, @@ -1292,7 +1694,7 @@ def test_pressure_balances_no_feed(self, model): ) ) assert str(model.fs.unit.stream1_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1[0, 1].pressure - model.fs.unit.stream1[0, 2].pressure, @@ -1305,7 +1707,7 @@ def test_pressure_balances_no_feed(self, model): assert len(model.fs.unit.stream2_pressure_balance) == 1 assert str(model.fs.unit.stream2_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream2[0, 2].pressure - model.fs.unit.stream2[0, 1].pressure, @@ -1328,7 +1730,7 @@ def test_pressure_balances_has_pressure_balance_false(self, model): assert len(model.fs.unit.stream1_pressure_balance) == 2 assert str(model.fs.unit.stream1_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1_inlet_state[0].pressure - model.fs.unit.stream1[0, 1].pressure, @@ -1336,7 +1738,7 @@ def test_pressure_balances_has_pressure_balance_false(self, model): ) ) assert str(model.fs.unit.stream1_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1[0, 1].pressure - model.fs.unit.stream1[0, 2].pressure, @@ -1361,7 +1763,7 @@ def test_pressure_balances_side_stream(self, model): assert len(model.fs.unit.stream1_pressure_balance) == 2 assert str(model.fs.unit.stream1_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1_inlet_state[0].pressure - model.fs.unit.stream1[0, 1].pressure, @@ -1369,7 +1771,7 @@ def test_pressure_balances_side_stream(self, model): ) ) assert str(model.fs.unit.stream1_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream1[0, 1].pressure - model.fs.unit.stream1[0, 2].pressure, @@ -1382,7 +1784,7 @@ def test_pressure_balances_side_stream(self, model): assert len(model.fs.unit.stream2_pressure_balance) == 2 assert str(model.fs.unit.stream2_pressure_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream2_inlet_state[0].pressure - model.fs.unit.stream2[0, 2].pressure, @@ -1390,7 +1792,7 @@ def test_pressure_balances_side_stream(self, model): ) ) assert str(model.fs.unit.stream2_pressure_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**-1 * units.s**-2) == units.convert( model.fs.unit.stream2[0, 2].pressure - model.fs.unit.stream2[0, 1].pressure, @@ -1602,7 +2004,7 @@ def test_inherent_reactions(self, model): "c2", ]: # has +ve mass transfer, forward flow, inherent reactions assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1618,7 +2020,7 @@ def test_inherent_reactions(self, model): ) ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1[0, 1].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1674,7 +2076,7 @@ def test_inherent_reactions(self, model): "c2", ]: # has -ve mass transfer, forward flow, inherent reactions assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1690,7 +2092,7 @@ def test_inherent_reactions(self, model): ) ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2[0, 2].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1780,7 +2182,7 @@ def test_equilibrium_reactions(self, model): "c2", ]: # has +ve mass transfer, forward flow, no reactions assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1792,7 +2194,7 @@ def test_equilibrium_reactions(self, model): + model.fs.unit.material_transfer_term[0, 1, "stream1", "stream2", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1[0, 1].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1809,7 +2211,7 @@ def test_equilibrium_reactions(self, model): "c2", ]: # has -ve mass transfer, forward flow, equilibrium reactions assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1825,7 +2227,7 @@ def test_equilibrium_reactions(self, model): ) ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2[0, 2].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1969,7 +2371,7 @@ def test_heterogeneous_reactions(self, model): "c2", ]: # has +ve mass transfer, forward flow, heterogeneous reactions assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -1985,7 +2387,7 @@ def test_heterogeneous_reactions(self, model): ) ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1[0, 1].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -2006,7 +2408,7 @@ def test_heterogeneous_reactions(self, model): "c2", ]: # has -ve mass transfer, forward flow, heterogeneous reactions assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -2022,7 +2424,7 @@ def test_heterogeneous_reactions(self, model): ) ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2[0, 2].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -2089,7 +2491,7 @@ def test_rate_reactions(self, model): "c2", ]: # has +ve mass transfer, forward flow, no reactions assert str(model.fs.unit.stream1_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -2101,7 +2503,7 @@ def test_rate_reactions(self, model): + model.fs.unit.material_transfer_term[0, 1, "stream1", "stream2", j] ) assert str(model.fs.unit.stream1_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream1[0, 1].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -2118,7 +2520,7 @@ def test_rate_reactions(self, model): "c2", ]: # has -ve mass transfer, forward flow, rate reactions assert str(model.fs.unit.stream2_material_balance[0, 2, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2_inlet_state[0].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -2134,7 +2536,7 @@ def test_rate_reactions(self, model): ) ) assert str(model.fs.unit.stream2_material_balance[0, 1, j].expr) == str( - 0 + 0 * (units.mol * units.s**-1) == sum( model.fs.unit.stream2[0, 2].get_material_flow_terms(p, j) for p in ["p1", "p2"] @@ -2162,7 +2564,7 @@ def test_heat_of_reaction_rate(self, model): model.fs.unit._build_energy_balance_constraints(uom) assert str(model.fs.unit.stream1_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream1_inlet_state[0].get_enthalpy_flow_terms(p) @@ -2177,7 +2579,7 @@ def test_heat_of_reaction_rate(self, model): + model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) assert str(model.fs.unit.stream1_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream1[0, 1].get_enthalpy_flow_terms(p) @@ -2193,7 +2595,7 @@ def test_heat_of_reaction_rate(self, model): ) assert str(model.fs.unit.stream2_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream2_inlet_state[0].get_enthalpy_flow_terms(p) @@ -2213,7 +2615,7 @@ def test_heat_of_reaction_rate(self, model): ) ) assert str(model.fs.unit.stream2_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream2[0, 2].get_enthalpy_flow_terms(p) @@ -2245,7 +2647,7 @@ def test_heat_of_reaction_equilibrium(self, model): model.fs.unit._build_energy_balance_constraints(uom) assert str(model.fs.unit.stream1_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream1_inlet_state[0].get_enthalpy_flow_terms(p) @@ -2260,7 +2662,7 @@ def test_heat_of_reaction_equilibrium(self, model): + model.fs.unit.energy_transfer_term[0, 1, "stream1", "stream2"] ) assert str(model.fs.unit.stream1_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream1[0, 1].get_enthalpy_flow_terms(p) @@ -2276,7 +2678,7 @@ def test_heat_of_reaction_equilibrium(self, model): ) assert str(model.fs.unit.stream2_energy_balance[0, 2].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream2_inlet_state[0].get_enthalpy_flow_terms(p) @@ -2296,7 +2698,7 @@ def test_heat_of_reaction_equilibrium(self, model): ) ) assert str(model.fs.unit.stream2_energy_balance[0, 1].expr) == str( - 0 + 0 * (units.kg * units.m**2 * units.s**-3) == units.convert( sum( model.fs.unit.stream2[0, 2].get_enthalpy_flow_terms(p) From 87ee4c506d8b90a582270e5bb9b8943822a29b7f Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 12 Dec 2023 12:50:24 -0500 Subject: [PATCH 02/12] Adding beta warning to Thickener model (#1302) * Adding beta warning * Adding beta warning to docs --- .../unit_models/solid_liquid/thickener0d.rst | 3 +++ .../solid_liquid/tests/test_thickener.py | 20 +++++++++++++++++++ .../unit_models/solid_liquid/thickener.py | 5 +++++ 3 files changed, 28 insertions(+) diff --git a/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst b/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst index aae5e2ed92..d04a479be5 100644 --- a/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst +++ b/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst @@ -1,6 +1,9 @@ Thickener (0D) ============== +.. warning:: + The Thickener model is currently in beta status and will likely change in the next release as a more predictive version is developed. + The ``Thickener0D`` unit model is an extension of the :ref:`SLSeparator ` model which adds constraints to estimate the area and height of a vessel required to achieve the desired separation of solid and liquid based on experimental measurements of the settling velocity. This model is based on correlations described in: [1] Coulson & Richardson's Chemical Engineering, Volume 2 Particle Technology & Separation Processes (4th Ed.), Butterworth-Heinemann (2001) diff --git a/idaes/models/unit_models/solid_liquid/tests/test_thickener.py b/idaes/models/unit_models/solid_liquid/tests/test_thickener.py index 91716ea608..96d5225de6 100644 --- a/idaes/models/unit_models/solid_liquid/tests/test_thickener.py +++ b/idaes/models/unit_models/solid_liquid/tests/test_thickener.py @@ -155,6 +155,26 @@ def get_material_flow_basis(b): # ----------------------------------------------------------------------------- +@pytest.mark.unit +def test_beta_logger(caplog): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = TestParameterBlock() + + m.fs.unit = Thickener0D( + solid_property_package=m.fs.properties, + liquid_property_package=m.fs.properties, + ) + expected = ( + "The Thickener0D model is currently a beta capability and will " + "likely change in the next release as a more predictive version is " + "developed." + ) + + assert expected in caplog.text + + class TestThickener0DBasic: @pytest.fixture(scope="class") def model(self): diff --git a/idaes/models/unit_models/solid_liquid/thickener.py b/idaes/models/unit_models/solid_liquid/thickener.py index 66097380fa..3d86aa204a 100644 --- a/idaes/models/unit_models/solid_liquid/thickener.py +++ b/idaes/models/unit_models/solid_liquid/thickener.py @@ -58,6 +58,11 @@ def build(self): Returns: None """ + logger.warning( + "The Thickener0D model is currently a beta capability and will " + "likely change in the next release as a more predictive version is " + "developed." + ) # Call super().build to setup dynamics super().build() From 75a784ec12415862bdcc4d1083d48e6405e756cb Mon Sep 17 00:00:00 2001 From: Ludovico Bianchi Date: Tue, 12 Dec 2023 19:54:18 -0600 Subject: [PATCH 03/12] Prevent unwanted directories to be installed as Python packages (#1301) * Prevent unwanted directories to be installed as Python packages * Add missing __init__.py * Try using find_namespace_packages() with include filter instead * Try including DMF data after changing root dir name to be more explicit * Actually rename DMF data directory --- {data => dmf_data}/config.yaml | 0 .../14b83b0763c5424b8a056f5b0821dc32/pitzer_4.csv | 0 .../47c37ea39cd04decae3a13cb89397255/pitzer_5.csv | 0 .../527176e4eb33410fa40cbdd155e57d60/pitzer_3.csv | 0 .../6136a7bd211c4a5683f6f389f9a762df/pitzer_13.csv | 0 .../770e34089045464db1afad7f5cd384c7/pitzer_14.csv | 0 .../79865319d0fd4b328b4cf53af35301f7/pitzer_16.csv | 0 .../80e72419c7c243059dea2fbac2a991a7/pitzer_2.csv | 0 .../810b04b2994646c1bb5c188aa8f318af/pitzer_10.csv | 0 .../81b031700e67446887efccafd43015da/pitzer_12.csv | 0 .../Pitzer_1984.pdf | Bin .../9d2a8a43c8134ba2b11235b0fb475a68/pitzer_1.csv | 0 .../c160f86d2cf3439a9dfbe71f5ca468c4/pitzer_8.csv | 0 .../c8bc9646005e45af98e94990648eef5f/pitzer_9.csv | 0 .../d65aa0a466a24a4a9f5f7c9ef6809355/pitzer_6.csv | 0 .../e5bffe45d9e54011ad7f45506cd943d9/pitzer_17.csv | 0 .../f215996b34d24048891713a8d3a1be71/pitzer_7.csv | 0 .../fd1d039e4707443a9768f81a84c3ba85/pitzer_11.csv | 0 .../fe744c9ba1f54e3a85f0f3e383951fc3/pitzer_15.csv | 0 {data => dmf_data}/resourcedb.json | 0 idaes/core/dmf/datasets.py | 2 +- setup.py | 9 +++++++-- 22 files changed, 8 insertions(+), 3 deletions(-) rename {data => dmf_data}/config.yaml (100%) rename {data => dmf_data}/files/14b83b0763c5424b8a056f5b0821dc32/pitzer_4.csv (100%) rename {data => dmf_data}/files/47c37ea39cd04decae3a13cb89397255/pitzer_5.csv (100%) rename {data => dmf_data}/files/527176e4eb33410fa40cbdd155e57d60/pitzer_3.csv (100%) rename {data => dmf_data}/files/6136a7bd211c4a5683f6f389f9a762df/pitzer_13.csv (100%) rename {data => dmf_data}/files/770e34089045464db1afad7f5cd384c7/pitzer_14.csv (100%) rename {data => dmf_data}/files/79865319d0fd4b328b4cf53af35301f7/pitzer_16.csv (100%) rename {data => dmf_data}/files/80e72419c7c243059dea2fbac2a991a7/pitzer_2.csv (100%) rename {data => dmf_data}/files/810b04b2994646c1bb5c188aa8f318af/pitzer_10.csv (100%) rename {data => dmf_data}/files/81b031700e67446887efccafd43015da/pitzer_12.csv (100%) rename {data => dmf_data}/files/8d7b6e93a7a54fcf99d3a8bfe925ab19/Pitzer_1984.pdf (100%) rename {data => dmf_data}/files/9d2a8a43c8134ba2b11235b0fb475a68/pitzer_1.csv (100%) rename {data => dmf_data}/files/c160f86d2cf3439a9dfbe71f5ca468c4/pitzer_8.csv (100%) rename {data => dmf_data}/files/c8bc9646005e45af98e94990648eef5f/pitzer_9.csv (100%) rename {data => dmf_data}/files/d65aa0a466a24a4a9f5f7c9ef6809355/pitzer_6.csv (100%) rename {data => dmf_data}/files/e5bffe45d9e54011ad7f45506cd943d9/pitzer_17.csv (100%) rename {data => dmf_data}/files/f215996b34d24048891713a8d3a1be71/pitzer_7.csv (100%) rename {data => dmf_data}/files/fd1d039e4707443a9768f81a84c3ba85/pitzer_11.csv (100%) rename {data => dmf_data}/files/fe744c9ba1f54e3a85f0f3e383951fc3/pitzer_15.csv (100%) rename {data => dmf_data}/resourcedb.json (100%) diff --git a/data/config.yaml b/dmf_data/config.yaml similarity index 100% rename from data/config.yaml rename to dmf_data/config.yaml diff --git a/data/files/14b83b0763c5424b8a056f5b0821dc32/pitzer_4.csv b/dmf_data/files/14b83b0763c5424b8a056f5b0821dc32/pitzer_4.csv similarity index 100% rename from data/files/14b83b0763c5424b8a056f5b0821dc32/pitzer_4.csv rename to dmf_data/files/14b83b0763c5424b8a056f5b0821dc32/pitzer_4.csv diff --git a/data/files/47c37ea39cd04decae3a13cb89397255/pitzer_5.csv b/dmf_data/files/47c37ea39cd04decae3a13cb89397255/pitzer_5.csv similarity index 100% rename from data/files/47c37ea39cd04decae3a13cb89397255/pitzer_5.csv rename to dmf_data/files/47c37ea39cd04decae3a13cb89397255/pitzer_5.csv diff --git a/data/files/527176e4eb33410fa40cbdd155e57d60/pitzer_3.csv b/dmf_data/files/527176e4eb33410fa40cbdd155e57d60/pitzer_3.csv similarity index 100% rename from data/files/527176e4eb33410fa40cbdd155e57d60/pitzer_3.csv rename to dmf_data/files/527176e4eb33410fa40cbdd155e57d60/pitzer_3.csv diff --git a/data/files/6136a7bd211c4a5683f6f389f9a762df/pitzer_13.csv b/dmf_data/files/6136a7bd211c4a5683f6f389f9a762df/pitzer_13.csv similarity index 100% rename from data/files/6136a7bd211c4a5683f6f389f9a762df/pitzer_13.csv rename to dmf_data/files/6136a7bd211c4a5683f6f389f9a762df/pitzer_13.csv diff --git a/data/files/770e34089045464db1afad7f5cd384c7/pitzer_14.csv b/dmf_data/files/770e34089045464db1afad7f5cd384c7/pitzer_14.csv similarity index 100% rename from data/files/770e34089045464db1afad7f5cd384c7/pitzer_14.csv rename to dmf_data/files/770e34089045464db1afad7f5cd384c7/pitzer_14.csv diff --git a/data/files/79865319d0fd4b328b4cf53af35301f7/pitzer_16.csv b/dmf_data/files/79865319d0fd4b328b4cf53af35301f7/pitzer_16.csv similarity index 100% rename from data/files/79865319d0fd4b328b4cf53af35301f7/pitzer_16.csv rename to dmf_data/files/79865319d0fd4b328b4cf53af35301f7/pitzer_16.csv diff --git a/data/files/80e72419c7c243059dea2fbac2a991a7/pitzer_2.csv b/dmf_data/files/80e72419c7c243059dea2fbac2a991a7/pitzer_2.csv similarity index 100% rename from data/files/80e72419c7c243059dea2fbac2a991a7/pitzer_2.csv rename to dmf_data/files/80e72419c7c243059dea2fbac2a991a7/pitzer_2.csv diff --git a/data/files/810b04b2994646c1bb5c188aa8f318af/pitzer_10.csv b/dmf_data/files/810b04b2994646c1bb5c188aa8f318af/pitzer_10.csv similarity index 100% rename from data/files/810b04b2994646c1bb5c188aa8f318af/pitzer_10.csv rename to dmf_data/files/810b04b2994646c1bb5c188aa8f318af/pitzer_10.csv diff --git a/data/files/81b031700e67446887efccafd43015da/pitzer_12.csv b/dmf_data/files/81b031700e67446887efccafd43015da/pitzer_12.csv similarity index 100% rename from data/files/81b031700e67446887efccafd43015da/pitzer_12.csv rename to dmf_data/files/81b031700e67446887efccafd43015da/pitzer_12.csv diff --git a/data/files/8d7b6e93a7a54fcf99d3a8bfe925ab19/Pitzer_1984.pdf b/dmf_data/files/8d7b6e93a7a54fcf99d3a8bfe925ab19/Pitzer_1984.pdf similarity index 100% rename from data/files/8d7b6e93a7a54fcf99d3a8bfe925ab19/Pitzer_1984.pdf rename to dmf_data/files/8d7b6e93a7a54fcf99d3a8bfe925ab19/Pitzer_1984.pdf diff --git a/data/files/9d2a8a43c8134ba2b11235b0fb475a68/pitzer_1.csv b/dmf_data/files/9d2a8a43c8134ba2b11235b0fb475a68/pitzer_1.csv similarity index 100% rename from data/files/9d2a8a43c8134ba2b11235b0fb475a68/pitzer_1.csv rename to dmf_data/files/9d2a8a43c8134ba2b11235b0fb475a68/pitzer_1.csv diff --git a/data/files/c160f86d2cf3439a9dfbe71f5ca468c4/pitzer_8.csv b/dmf_data/files/c160f86d2cf3439a9dfbe71f5ca468c4/pitzer_8.csv similarity index 100% rename from data/files/c160f86d2cf3439a9dfbe71f5ca468c4/pitzer_8.csv rename to dmf_data/files/c160f86d2cf3439a9dfbe71f5ca468c4/pitzer_8.csv diff --git a/data/files/c8bc9646005e45af98e94990648eef5f/pitzer_9.csv b/dmf_data/files/c8bc9646005e45af98e94990648eef5f/pitzer_9.csv similarity index 100% rename from data/files/c8bc9646005e45af98e94990648eef5f/pitzer_9.csv rename to dmf_data/files/c8bc9646005e45af98e94990648eef5f/pitzer_9.csv diff --git a/data/files/d65aa0a466a24a4a9f5f7c9ef6809355/pitzer_6.csv b/dmf_data/files/d65aa0a466a24a4a9f5f7c9ef6809355/pitzer_6.csv similarity index 100% rename from data/files/d65aa0a466a24a4a9f5f7c9ef6809355/pitzer_6.csv rename to dmf_data/files/d65aa0a466a24a4a9f5f7c9ef6809355/pitzer_6.csv diff --git a/data/files/e5bffe45d9e54011ad7f45506cd943d9/pitzer_17.csv b/dmf_data/files/e5bffe45d9e54011ad7f45506cd943d9/pitzer_17.csv similarity index 100% rename from data/files/e5bffe45d9e54011ad7f45506cd943d9/pitzer_17.csv rename to dmf_data/files/e5bffe45d9e54011ad7f45506cd943d9/pitzer_17.csv diff --git a/data/files/f215996b34d24048891713a8d3a1be71/pitzer_7.csv b/dmf_data/files/f215996b34d24048891713a8d3a1be71/pitzer_7.csv similarity index 100% rename from data/files/f215996b34d24048891713a8d3a1be71/pitzer_7.csv rename to dmf_data/files/f215996b34d24048891713a8d3a1be71/pitzer_7.csv diff --git a/data/files/fd1d039e4707443a9768f81a84c3ba85/pitzer_11.csv b/dmf_data/files/fd1d039e4707443a9768f81a84c3ba85/pitzer_11.csv similarity index 100% rename from data/files/fd1d039e4707443a9768f81a84c3ba85/pitzer_11.csv rename to dmf_data/files/fd1d039e4707443a9768f81a84c3ba85/pitzer_11.csv diff --git a/data/files/fe744c9ba1f54e3a85f0f3e383951fc3/pitzer_15.csv b/dmf_data/files/fe744c9ba1f54e3a85f0f3e383951fc3/pitzer_15.csv similarity index 100% rename from data/files/fe744c9ba1f54e3a85f0f3e383951fc3/pitzer_15.csv rename to dmf_data/files/fe744c9ba1f54e3a85f0f3e383951fc3/pitzer_15.csv diff --git a/data/resourcedb.json b/dmf_data/resourcedb.json similarity index 100% rename from data/resourcedb.json rename to dmf_data/resourcedb.json diff --git a/idaes/core/dmf/datasets.py b/idaes/core/dmf/datasets.py index a9062dab98..b499b79a35 100644 --- a/idaes/core/dmf/datasets.py +++ b/idaes/core/dmf/datasets.py @@ -39,7 +39,7 @@ NAME = "idaes-pse" # Keep this the same as constant DMF_DATA_ROOT in setup.py -DMF_DATA_ROOT = "data" +DMF_DATA_ROOT = "dmf_data" _log = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index 558f7377b6..184d36536b 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def rglob(path, glob): # For included DMF data -DMF_DATA_ROOT = "data" +DMF_DATA_ROOT = "dmf_data" def dmf_data_files(root: str = DMF_DATA_ROOT) -> List[Tuple[str, List[str]]]: @@ -123,7 +123,12 @@ def __getitem__(self, key): zip_safe=False, name=NAME, version=VERSION, - packages=find_namespace_packages(), + packages=find_namespace_packages( + include=[ + "idaes*", + "dmf_data*", + ] + ), # Put abstract (non-versioned) deps here. # Concrete dependencies go in requirements[-dev].txt install_requires=[ From 1f3fc513b899fe3e0a2504e8d546d324cd1e0327 Mon Sep 17 00:00:00 2001 From: Ludovico Bianchi Date: Tue, 12 Dec 2023 20:01:48 -0600 Subject: [PATCH 04/12] 2.4.0dev0 --- idaes/ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idaes/ver.py b/idaes/ver.py index 453f76c354..c3999426ca 100644 --- a/idaes/ver.py +++ b/idaes/ver.py @@ -184,7 +184,7 @@ def git_hash(): pass #: Package's version as an object -package_version = Version(2, 3, 0, "development", 0, gh) +package_version = Version(2, 4, 0, "development", 0, gh) #: Package's version as a simple string __version__ = str(package_version) From 021fb145456c69062fa31863da22097e6556ca5c Mon Sep 17 00:00:00 2001 From: dallan-keylogic <88728506+dallan-keylogic@users.noreply.github.com> Date: Thu, 14 Dec 2023 15:29:24 -0500 Subject: [PATCH 05/12] Fix SOC sign error (#1303) * fix sign error * Undo double negative, add sanity tests * run black --------- --- .../unit_models/soc_submodels/channel.py | 2 +- .../test_herring_replication_interconnect.py | 41 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/idaes/models_extra/power_generation/unit_models/soc_submodels/channel.py b/idaes/models_extra/power_generation/unit_models/soc_submodels/channel.py index db8523bd7b..2d49878c52 100644 --- a/idaes/models_extra/power_generation/unit_models/soc_submodels/channel.py +++ b/idaes/models_extra/power_generation/unit_models/soc_submodels/channel.py @@ -437,7 +437,7 @@ def mass_transfer_coeff(b, t, iz, i): @self.Constraint(tset, iznodes, comps) def material_flux_x0_eqn(b, t, iz, i): return ( - -b.material_flux_x0[t, iz, i] / b.mass_transfer_coeff[t, iz, i] + b.material_flux_x0[t, iz, i] / b.mass_transfer_coeff[t, iz, i] == b.conc_mol_comp_deviation_x0[t, iz, i] ) diff --git a/idaes/models_extra/power_generation/unit_models/soc_submodels/tests/test_herring_replication_interconnect.py b/idaes/models_extra/power_generation/unit_models/soc_submodels/tests/test_herring_replication_interconnect.py index 06f0a1519f..d89991e26e 100644 --- a/idaes/models_extra/power_generation/unit_models/soc_submodels/tests/test_herring_replication_interconnect.py +++ b/idaes/models_extra/power_generation/unit_models/soc_submodels/tests/test_herring_replication_interconnect.py @@ -730,6 +730,43 @@ def test_initialization_cell_voltage_drop_custom(model_vdc): assert approx(0.3094487) == pyo.value(cell.oxygen_outlet.mole_frac_comp[0, "O2"]) assert approx(0.690551) == pyo.value(cell.oxygen_outlet.mole_frac_comp[0, "N2"]) + for iz in cell.iznodes: + # H2O is consumed at fuel electrode, concentration at electrode surface + # should be less than that in channel, and concentration at TPB should + # be less than that at the surface + assert pyo.value(cell.fuel_channel.conc_mol_comp_deviation_x1[0, iz, "H2O"]) < 0 + assert ( + pyo.value( + cell.fuel_electrode.conc_mol_comp_deviation_x1[0, iz, "H2O"] + - cell.fuel_channel.conc_mol_comp_deviation_x1[0, iz, "H2O"] + ) + < 0 + ) + # H2 is produced at fuel electrode, concentration at electrode surface + # should be greater than that in channel, and concentration at TPB should + # be greater than that at the surface + assert pyo.value(cell.fuel_channel.conc_mol_comp_deviation_x1[0, iz, "H2"]) > 0 + assert ( + pyo.value( + cell.fuel_electrode.conc_mol_comp_deviation_x1[0, iz, "H2"] + - cell.fuel_channel.conc_mol_comp_deviation_x1[0, iz, "H2"] + ) + > 0 + ) + # O2 is produced at oxygen electrode, concentration at electrode surface + # should be greater than that in channel, and concentration at TPB should + # be greater than that at the surface + assert ( + pyo.value(cell.oxygen_channel.conc_mol_comp_deviation_x0[0, iz, "O2"]) > 0 + ) + assert ( + pyo.value( + cell.oxygen_electrode.conc_mol_comp_deviation_x0[0, iz, "O2"] + - cell.oxygen_channel.conc_mol_comp_deviation_x0[0, iz, "O2"] + ) + > 0 + ) + # Test whether unfixed degrees of freedom remain unfixed cell.potential.unfix() cell.fuel_inlet.temperature[0].unfix() @@ -790,5 +827,5 @@ def test_model_replication_voltage_drop_custom(model_vdc): m = model_func() out = kazempoor_braun_replication(m) # Uncomment to recreate cached data - # for i, df in enumerate(out): - # df.to_csv(os.sep.join([data_cache, f"case_{i+1}_interconnect.csv"])) + for i, df in enumerate(out): + df.to_csv(os.sep.join([data_cache, f"case_{i+1}_interconnect.csv"])) From 6352629b52da9f6fff3026387829ebfca13e3b74 Mon Sep 17 00:00:00 2001 From: OOAmusat <47539353+OOAmusat@users.noreply.github.com> Date: Mon, 18 Dec 2023 11:12:45 -0800 Subject: [PATCH 06/12] Add custom sampling option to PySMO (#1298) * Fix NumPy array creation error by specifying object type * Removing print and display statements * Adding a function for custom sampling. - User can explicitly define a distribution for sampling of each variable. Sampling options currently available are random, uniform and Gaussian. * Improve errors and warnings * Tests for CustomSampling * running black... * Updating docs and example. * Fix docs * Improve docsstrings. * Improving tests based on feedback * Edit Gaussian sampling bounds to allow for strict enforcement * Add tests to validate for Gaussian bounds * Update test_sampling.py * Update test_sampling.py * Add missing check in init * Improve docs on Gaussian distribution samples. * Update test_sampling.py --------- Co-authored-by: Keith Beattie Co-authored-by: Dan Gunter Co-authored-by: Andrew Lee --- .../surrogate/sampling/index.rst | 1 + .../surrogate/sampling/pysmo_custom.rst | 40 + idaes/core/surrogate/pysmo/sampling.py | 436 ++++- .../surrogate/pysmo/tests/test_sampling.py | 1514 ++++++++++++++--- 4 files changed, 1678 insertions(+), 313 deletions(-) create mode 100644 docs/explanations/modeling_extensions/surrogate/sampling/pysmo_custom.rst diff --git a/docs/explanations/modeling_extensions/surrogate/sampling/index.rst b/docs/explanations/modeling_extensions/surrogate/sampling/index.rst index 0dfd301e87..db12c77c76 100644 --- a/docs/explanations/modeling_extensions/surrogate/sampling/index.rst +++ b/docs/explanations/modeling_extensions/surrogate/sampling/index.rst @@ -15,6 +15,7 @@ The PySMO package offers five common sampling methods for one-shot design: pysmo_halton pysmo_hammersley pysmo_cvt + pysmo_custom pysmo_sampling_properties diff --git a/docs/explanations/modeling_extensions/surrogate/sampling/pysmo_custom.rst b/docs/explanations/modeling_extensions/surrogate/sampling/pysmo_custom.rst new file mode 100644 index 0000000000..f5eaf39792 --- /dev/null +++ b/docs/explanations/modeling_extensions/surrogate/sampling/pysmo_custom.rst @@ -0,0 +1,40 @@ +Custom Sampling +=========================================== +With this method, users can explicitly define the distribution for the sampling of each input variable explicitly. + +The ``pysmo.sampling.CustomSampling`` method carries out the user-defined sampling strategy. This can be done in two modes: + +* The samples can be selected from a user-provided dataset, or +* The samples can be generated from a set of provided bounds. + +We currently support three distributions options for sampling: + +* "random", for sampling from a random distribution. +* "uniform", for sampling from a uniform distribution. +* "normal", for sampling from a normal (i.e. Gaussian) distribution. + +.. warning:: + **A note on Gaussian-based sampling** + + To remain consistent with the other sampling methods and distributions, bounds are required for specifying normal distributions, rather than the mean (:math:`\bar{x}`) and standard deviation (:math:`\sigma`). For a normal distribution, 99.7% of the points/sample fall within three standard deviations of the mean. Thus, the bounds of the distribution ay be computed as: + + .. math:: + \begin{equation} + LB = \bar{x} - 3\sigma + \end{equation} + + .. math:: + \begin{equation} + UB = \bar{x} + 3\sigma + \end{equation} + + While almost all of the points generated will typically fall between LB and UB, a few points may be generated outside the bounds (as should be expected from a normal distribution). However, users can choose to enforce the bounds as hard constraints by setting the boolean option **strictly_enforce_gaussian_bounds** to True during initialization. In that case, values exceeding the bounds are replaced by new values generated from the distributions. However, this may affect the underlying distribution. + + +Available Methods +------------------ + +.. autoclass:: idaes.core.surrogate.pysmo.sampling.CustomSampling + :members: __init__, sample_points + + diff --git a/idaes/core/surrogate/pysmo/sampling.py b/idaes/core/surrogate/pysmo/sampling.py index 739d4edfb1..f3904a2d42 100644 --- a/idaes/core/surrogate/pysmo/sampling.py +++ b/idaes/core/surrogate/pysmo/sampling.py @@ -501,11 +501,11 @@ def __init__( **self** function containing the input information Raises: - ValueError: The input data (**data_input**) is the wrong type. + ValueError: The input data (**data_input**) is the wrong type/dimension, or **number_of_samples** is invalid (too large, zero, or negative) - IndexError: When invalid column names are supplied in **xlabels** or **ylabels** + TypeError: When **number_of_samples** is not the right type, or **sampling_type** entry is not a string. - Exception: When **number_of_samples** is invalid (not an integer, too large, zero, or negative) + IndexError: When invalid column names are supplied in **xlabels** or **ylabels** """ @@ -514,14 +514,14 @@ def __init__( self.sampling_type = sampling_type print("Creation-type sampling will be used.") elif not isinstance(sampling_type, str): - raise Exception("Invalid sampling type entry. Must be of type .") + raise TypeError("Invalid sampling type entry. Must be of type .") elif (sampling_type.lower() == "creation") or ( sampling_type.lower() == "selection" ): sampling_type = sampling_type.lower() self.sampling_type = sampling_type else: - raise Exception( + raise ValueError( 'Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.' ) print("Sampling type: ", self.sampling_type, "\n") @@ -541,13 +541,13 @@ def __init__( ) number_of_samples = 5 elif number_of_samples > self.data.shape[0]: - raise Exception( + raise ValueError( "LHS sample size cannot be greater than number of samples in the input data set" ) elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_samples = number_of_samples elif self.sampling_type == "creation": @@ -555,16 +555,19 @@ def __init__( raise ValueError( 'List entry of two elements expected for sampling_type "creation."' ) - elif len(data_input) != 2: - raise Exception("data_input must contain two lists of equal lengths.") - elif not isinstance(data_input[0], list) or not isinstance( - data_input[1], list + elif ( + len(data_input) != 2 + or not isinstance(data_input[0], list) + or not isinstance(data_input[1], list) + or len(data_input[0]) != len(data_input[1]) ): - raise Exception("data_input must contain two lists of equal lengths.") - elif len(data_input[0]) != len(data_input[1]): - raise Exception("data_input must contain two lists of equal lengths.") + raise ValueError("data_input must contain two lists of equal lengths.") elif data_input[0] == data_input[1]: - raise Exception("Invalid entry: both lists are equal.") + raise ValueError("Invalid entry: both lists are equal.") + elif any(x == y for x, y in zip(data_input[0], data_input[1])): + raise ValueError( + "Invalid entry: at least one variable contains the same value for the lower and upper bounds." + ) else: bounds_array = np.zeros( ( @@ -585,9 +588,9 @@ def __init__( ) number_of_samples = 5 elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_samples = number_of_samples self.x_data = bounds_array # Only x data will be present in this case @@ -740,28 +743,26 @@ def __init__( **self** function containing the input information Raises: - ValueError: The **data_input** is the wrong type + ValueError: The **data_input** is the wrong type, or **list_of_samples_per_variable** is of the wrong length, or **list_of_samples_per_variable** is invalid. - ValueError: When **list_of_samples_per_variable** is of the wrong length, is not a list or contains elements other than integers + TypeError: When **list_of_samples_per_variable** is not a list, or **list_of_samples_per_variable** contains elements other than integers, **sampling_type** is not a string, or **edges** entry is not Boolean IndexError: When invalid column names are supplied in **xlabels** or **ylabels** - Exception: When **edges** entry is not Boolean - """ if sampling_type is None: sampling_type = "creation" self.sampling_type = sampling_type print("Creation-type sampling will be used.") elif not isinstance(sampling_type, str): - raise Exception("Invalid sampling type entry. Must be of type .") + raise TypeError("Invalid sampling type entry. Must be of type .") elif (sampling_type.lower() == "creation") or ( sampling_type.lower() == "selection" ): sampling_type = sampling_type.lower() self.sampling_type = sampling_type else: - raise Exception( + raise ValueError( 'Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.' ) print("Sampling type: ", self.sampling_type, "\n") @@ -779,16 +780,19 @@ def __init__( raise ValueError( 'List entry of two elements expected for sampling_type "creation."' ) - elif len(data_input) != 2: - raise Exception("data_input must contain two lists of equal lengths.") - elif not isinstance(data_input[0], list) or not isinstance( - data_input[1], list + elif ( + len(data_input) != 2 + or not isinstance(data_input[0], list) + or not isinstance(data_input[1], list) + or len(data_input[0]) != len(data_input[1]) ): - raise Exception("data_input must contain two lists of equal lengths.") - elif len(data_input[0]) != len(data_input[1]): - raise Exception("data_input must contain two lists of equal lengths.") + raise ValueError("data_input must contain two lists of equal lengths.") elif data_input[0] == data_input[1]: - raise Exception("Invalid entry: both lists are equal.") + raise ValueError("Invalid entry: both lists are equal.") + elif any(x == y for x, y in zip(data_input[0], data_input[1])): + raise ValueError( + "Invalid entry: at least one variable contains the same value for the lower and upper bounds." + ) else: bounds_array = np.zeros( ( @@ -807,7 +811,7 @@ def __init__( edges = True self.edge = edges elif not isinstance(edges, bool): - raise Exception('Invalid "edges" entry. Must be boolean') + raise TypeError('Invalid "edges" entry. Must be boolean') elif (edges is True) or (edges is False): self.edge = edges @@ -832,7 +836,7 @@ def __init__( self.sampling_type == "selection" and self.number_of_samples > self.data.shape[0] ): - raise Exception( + raise ValueError( "Sample size cannot be greater than number of samples in the input data set" ) @@ -922,11 +926,11 @@ def __init__( **self** function containing the input information. Raises: - ValueError: The **data_input** is the wrong type. + ValueError: The input data (**data_input**) is the wrong type/dimension, or **number_of_samples** is invalid (too large, zero, or negative) - IndexError: When invalid column names are supplied in **xlabels** or **ylabels** + TypeError: When **number_of_samples** is not the right type, or **sampling_type** entry is not a string. - Exception: When the **number_of_samples** is invalid (not an integer, too large, zero or negative.) + IndexError: When invalid column names are supplied in **xlabels** or **ylabels** """ if sampling_type is None: @@ -934,14 +938,14 @@ def __init__( self.sampling_type = sampling_type print("Creation-type sampling will be used.") elif not isinstance(sampling_type, str): - raise Exception("Invalid sampling type entry. Must be of type .") + raise TypeError("Invalid sampling type entry. Must be of type .") elif (sampling_type.lower() == "creation") or ( sampling_type.lower() == "selection" ): sampling_type = sampling_type.lower() self.sampling_type = sampling_type else: - raise Exception( + raise ValueError( 'Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.' ) print("Sampling type: ", self.sampling_type, "\n") @@ -961,30 +965,33 @@ def __init__( ) number_of_samples = 5 elif number_of_samples > self.data.shape[0]: - raise Exception( + raise ValueError( "Sample size cannot be greater than number of samples in the input data set" ) elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_samples = number_of_samples elif self.sampling_type == "creation": if not isinstance(data_input, list): - raise ValueError( + raise TypeError( 'List entry of two elements expected for sampling_type "creation."' ) - elif len(data_input) != 2: - raise Exception("data_input must contain two lists of equal lengths.") - elif not isinstance(data_input[0], list) or not isinstance( - data_input[1], list + elif ( + len(data_input) != 2 + or not isinstance(data_input[0], list) + or not isinstance(data_input[1], list) + or len(data_input[0]) != len(data_input[1]) ): - raise Exception("data_input must contain two lists of equal lengths.") - elif len(data_input[0]) != len(data_input[1]): - raise Exception("data_input must contain two lists of equal lengths.") + raise ValueError("data_input must contain two lists of equal lengths.") elif data_input[0] == data_input[1]: - raise Exception("Invalid entry: both lists are equal.") + raise ValueError("Invalid entry: both lists are equal.") + elif any(x == y for x, y in zip(data_input[0], data_input[1])): + raise ValueError( + "Invalid entry: at least one variable contains the same value for the lower and upper bounds." + ) else: bounds_array = np.zeros( ( @@ -1005,9 +1012,9 @@ def __init__( ) number_of_samples = 5 elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_samples = number_of_samples self.x_data = bounds_array # Only x data will be present in this case @@ -1100,11 +1107,11 @@ def __init__( **self** function containing the input information. Raises: - ValueError: When **data_input** is the wrong type. + ValueError: The input data (**data_input**) is the wrong type/dimension, or **number_of_samples** is invalid (too large, zero, or negative) - IndexError: When invalid column names are supplied in **xlabels** or **ylabels** + TypeError: When **number_of_samples** is not the right type, or **sampling_type** entry is not a string. - Exception: When the **number_of_samples** is invalid (not an integer, too large, zero, negative) + IndexError: When invalid column names are supplied in **xlabels** or **ylabels** """ if sampling_type is None: @@ -1112,20 +1119,19 @@ def __init__( self.sampling_type = sampling_type print("Creation-type sampling will be used.") elif not isinstance(sampling_type, str): - raise Exception("Invalid sampling type entry. Must be of type .") + raise TypeError("Invalid sampling type entry. Must be of type .") elif (sampling_type.lower() == "creation") or ( sampling_type.lower() == "selection" ): sampling_type = sampling_type.lower() self.sampling_type = sampling_type else: - raise Exception( + raise ValueError( 'Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.' ) print("Sampling type: ", self.sampling_type, "\n") if self.sampling_type == "selection": - if isinstance(data_input, (pd.DataFrame, np.ndarray)): self.selection_columns_preprocessing(data_input, xlabels, ylabels) else: @@ -1140,13 +1146,13 @@ def __init__( ) number_of_samples = 5 elif number_of_samples > self.data.shape[0]: - raise Exception( + raise ValueError( "Sample size cannot be greater than number of samples in the input data set" ) elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_samples = number_of_samples elif self.sampling_type == "creation": @@ -1154,16 +1160,19 @@ def __init__( raise ValueError( 'List entry of two elements expected for sampling_type "creation."' ) - elif len(data_input) != 2: - raise Exception("data_input must contain two lists of equal lengths.") - elif not isinstance(data_input[0], list) or not isinstance( - data_input[1], list + elif ( + len(data_input) != 2 + or not isinstance(data_input[0], list) + or not isinstance(data_input[1], list) + or len(data_input[0]) != len(data_input[1]) ): - raise Exception("data_input must contain two lists of equal lengths.") - elif len(data_input[0]) != len(data_input[1]): - raise Exception("data_input must contain two lists of equal lengths.") + raise ValueError("data_input must contain two lists of equal lengths.") elif data_input[0] == data_input[1]: - raise Exception("Invalid entry: both lists are equal.") + raise ValueError("Invalid entry: both lists are equal.") + elif any(x == y for x, y in zip(data_input[0], data_input[1])): + raise ValueError( + "Invalid entry: at least one variable contains the same value for the lower and upper bounds." + ) else: bounds_array = np.zeros( ( @@ -1184,9 +1193,9 @@ def __init__( ) number_of_samples = 5 elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_samples = number_of_samples self.x_data = bounds_array # Only x data will be present in this case @@ -1285,13 +1294,15 @@ def __init__( Raises: - ValueError: When **data_input** is the wrong type. + ValueError: The input data (**data_input**) is the wrong type/dimension, or **number_of_samples** is invalid (too large, zero, or negative) - IndexError: When invalid column names are supplied in **xlabels** or **ylabels** + ValueError: When the tolerance specified is too loose (tolerance > 0.1) + + TypeError: When **number_of_samples** is not the right type, or **sampling_type** entry is not a string - Exception: When the **number_of_samples** is invalid (not an integer, too large, zero, negative) + IndexError: When invalid column names are supplied in **xlabels** or **ylabels** - Exception: When the tolerance specified is too loose (tolerance > 0.1) or invalid + Exception: When the tolerance specified is invalid warnings.warn: when the tolerance specified by the user is too tight (tolerance < :math:`10^{-9}`) @@ -1301,14 +1312,14 @@ def __init__( self.sampling_type = sampling_type print("Creation-type sampling will be used.") elif not isinstance(sampling_type, str): - raise Exception("Invalid sampling type entry. Must be of type .") + raise TypeError("Invalid sampling type entry. Must be of type .") elif (sampling_type.lower() == "creation") or ( sampling_type.lower() == "selection" ): sampling_type = sampling_type.lower() self.sampling_type = sampling_type else: - raise Exception( + raise ValueError( 'Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.' ) print("Sampling type: ", self.sampling_type, "\n") @@ -1328,13 +1339,13 @@ def __init__( ) number_of_samples = 5 elif number_of_samples > self.data.shape[0]: - raise Exception( + raise ValueError( "CVT sample size cannot be greater than number of samples in the input data set" ) elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_centres = number_of_samples elif self.sampling_type == "creation": @@ -1342,16 +1353,19 @@ def __init__( raise ValueError( 'List entry of two elements expected for sampling_type "creation."' ) - elif len(data_input) != 2: - raise Exception("data_input must contain two lists of equal lengths.") - elif not isinstance(data_input[0], list) or not isinstance( - data_input[1], list + elif ( + len(data_input) != 2 + or not isinstance(data_input[0], list) + or not isinstance(data_input[1], list) + or len(data_input[0]) != len(data_input[1]) ): - raise Exception("data_input must contain two lists of equal lengths.") - elif len(data_input[0]) != len(data_input[1]): - raise Exception("data_input must contain two lists of equal lengths.") + raise ValueError("data_input must contain two lists of equal lengths.") elif data_input[0] == data_input[1]: - raise Exception("Invalid entry: both lists are equal.") + raise ValueError("Invalid entry: both lists are equal.") + elif any(x == y for x, y in zip(data_input[0], data_input[1])): + raise ValueError( + "Invalid entry: at least one variable contains the same value for the lower and upper bounds." + ) else: bounds_array = np.zeros( ( @@ -1372,9 +1386,9 @@ def __init__( ) number_of_samples = 5 elif not isinstance(number_of_samples, int): - raise Exception("number_of_samples must be an integer.") + raise TypeError("number_of_samples must be an integer.") elif number_of_samples <= 0: - raise Exception("number_of_samples must a positive, non-zero integer.") + raise ValueError("number_of_samples must a positive, non-zero integer.") self.number_of_centres = number_of_samples x_data = bounds_array # Only x data will be present in this case @@ -1386,7 +1400,7 @@ def __init__( if tolerance is None: tolerance = 1e-7 elif tolerance > 0.1: - raise Exception("Tolerance must be less than 0.1 to achieve good results") + raise ValueError("Tolerance must be less than 0.1 to achieve good results") elif tolerance < 1e-9: warnings.warn( "Tolerance too tight. CVT algorithm may take long time to converge." @@ -1536,3 +1550,241 @@ def sample_points(self): unique_sample_points, columns=self.data_headers ) return unique_sample_points + + +class CustomSampling(SamplingMethods): + """ + A class that performs custom sampling per dimension as specified by the user. The distribution to be applied per dimension must be specified by the user. + + - The distribution to be used per variable needs to be specified in a list. + + - Users are urged to visit the documentation for more information about normal distribution-based sampling. + + To use: call class with inputs, and then ``sample_points`` function + + **Example:** + + .. code-block:: python + + # To select 50 samples drom a dataset: + >>> b = rbf.CustomSampling(data, 50, list_of_distributions= ['normal', 'uniform'], sampling_type="selection") + >>> samples = b.sample_points() + + Note: + + To remain consistent with the other sampling methods and distributions, **bounds are required for specifying normal distributions, rather than the mean and standard deviation**. + + Given the mean (:math:`\\bar{x}`) and standard deviation (:math:`\\sigma`), the bounds of the normal distribution may be computed as: + + Lower bound = :math:`\\bar{x} - 3\\sigma` ; Upper bound = :math:`\\bar{x} + 3\\sigma` + + Users should visit the documentation for more information. + + """ + + def __init__( + self, + data_input, + number_of_samples=None, + list_of_distributions=None, + sampling_type=None, + xlabels=None, + ylabels=None, + strictly_enforce_gaussian_bounds=False, + ): + """ + Initialization of CustomSampling class. Four inputs are required. + + Args: + data_input (NumPy Array, Pandas Dataframe or list) : The input data set or range to be sampled. + + - When the aim is to select a set of samples from an existing dataset, the dataset must be a NumPy Array or a Pandas Dataframe and **sampling_type** option must be set to "selection". A single output variable (y) is assumed to be supplied in the last column if **xlabels** and **ylabels** are not supplied. + - When the aim is to generate a set of samples from a data range, the dataset must be a list containing two lists of equal lengths which contain the variable bounds and **sampling_type** option must be set to "creation". It is assumed that the range contains no output variable information in this case. + + number_of_samples(int): The number of samples to be generated. Should be a positive integer less than or equal to the number of entries (rows) in **data_input**. + list_of_distributions (list): The list containing the probability distribution for each variable. The length of the list must match the number of input (i.e. dependent) variables to be sampled. We currently support random, uniform and normal (i.e. Gaussian) distributions. + sampling_type (str) : Option which determines whether the algorithm selects samples from an existing dataset ("selection") or attempts to generate sample from a supplied range ("creation"). Default is "creation". + + Keyword Args: + xlabels (list): List of column names (if **data_input** is a dataframe) or column numbers (if **data_input** is an array) for the independent/input variables. Only used in "selection" mode. Default is None. + ylabels (list): List of column names (if **data_input** is a dataframe) or column numbers (if **data_input** is an array) for the dependent/output variables. Only used in "selection" mode. Default is None. + strictly_enforce_gaussian_bounds (bool): Boolean specifying whether the provided bounds for normal distributions should be strictly enforced. Note that selecting this option may affect the underlying distribution. Default is False. + + Returns: + **self** function containing the input information + + Raises: + ValueError: The input data (**data_input**) is the wrong type/dimension, or **number_of_samples** is invalid (too large, zero, or negative), **list_of_distributions** is the wrong length, or a non-implemented distribution is supplied in **list_of_distributions**. + + TypeError: When **number_of_samples** is not an integer, **list_of_distributions** is not a list, or **sampling_type** entry is not a string + + IndexError: When invalid column names are supplied in **xlabels** or **ylabels** + + + """ + if sampling_type is None: + sampling_type = "creation" + self.sampling_type = sampling_type + print("Creation-type sampling will be used.") + elif not isinstance(sampling_type, str): + raise TypeError("Invalid sampling type entry. Must be of type .") + elif (sampling_type.lower() == "creation") or ( + sampling_type.lower() == "selection" + ): + sampling_type = sampling_type.lower() + self.sampling_type = sampling_type + else: + raise ValueError( + 'Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.' + ) + print("Sampling type: ", self.sampling_type, "\n") + + if self.sampling_type == "selection": + if isinstance(data_input, (pd.DataFrame, np.ndarray)): + self.selection_columns_preprocessing(data_input, xlabels, ylabels) + else: + raise ValueError( + 'Pandas dataframe or numpy array required for sampling_type "selection."' + ) + + # Catch potential errors in number_of_samples + if number_of_samples is None: + print( + "\nNo entry for number of samples to be generated. The default value of 5 will be used." + ) + number_of_samples = 5 + elif number_of_samples > self.data.shape[0]: + raise ValueError( + "Sample size cannot be greater than number of samples in the input data set" + ) + elif not isinstance(number_of_samples, int): + raise TypeError("number_of_samples must be an integer.") + elif number_of_samples <= 0: + raise ValueError("number_of_samples must a positive, non-zero integer.") + self.number_of_samples = number_of_samples + + elif self.sampling_type == "creation": + if not isinstance(data_input, list): + raise ValueError( + 'List entry of two elements expected for sampling_type "creation."' + ) + elif ( + len(data_input) != 2 + or not isinstance(data_input[0], list) + or not isinstance(data_input[1], list) + or len(data_input[0]) != len(data_input[1]) + ): + raise ValueError("data_input must contain two lists of equal lengths.") + elif data_input[0] == data_input[1]: + raise ValueError("Invalid entry: both lists are equal.") + elif any(x == y for x, y in zip(data_input[0], data_input[1])): + raise ValueError( + "Invalid entry: at least one variable contains the same value for the lower and upper bounds." + ) + else: + bounds_array = np.zeros( + ( + 2, + len(data_input[0]), + ) + ) + bounds_array[0, :] = np.array(data_input[0]) + bounds_array[1, :] = np.array(data_input[1]) + data_headers = [] + self.data = bounds_array + self.data_headers = data_headers + + # Catch potential errors in number_of_samples + if number_of_samples is None: + print( + "\nNo entry for number of samples to be generated. The default value of 5 will be used." + ) + number_of_samples = 5 + elif not isinstance(number_of_samples, int): + raise TypeError("number_of_samples must be an integer.") + elif number_of_samples <= 0: + raise ValueError("number_of_samples must a positive, non-zero integer.") + self.number_of_samples = number_of_samples + self.x_data = bounds_array # Only x data will be present in this case + + # Check that list_of_distributions is a list, list length is correct and all list values are strings + if list_of_distributions is None: + raise ValueError("list_of_distributions cannot be empty.") + if not isinstance(list_of_distributions, list): + raise TypeError("Error with list_of_distributions: list required.") + if len(list_of_distributions) != self.x_data.shape[1]: + raise ValueError( + "Length of list_of_distributions must equal the number of variables." + ) + if all(isinstance(q, str) for q in list_of_distributions) is False: + raise TypeError("All values in list must be strings") + if not all( + q.lower() in ["random", "normal", "uniform"] for q in list_of_distributions + ): + raise ValueError( + "list_of_distributions only supports 'random', 'normal' and 'uniform' sampling options." + ) + self.dist_vector = list_of_distributions + + if not isinstance(strictly_enforce_gaussian_bounds, bool): + raise TypeError( + "Invalid 'strictly_enforce_gaussian_bounds' entry. Must be boolean." + ) + self.normal_bounds_enforced = strictly_enforce_gaussian_bounds + + def generate_from_dist(self, dist_name): + if dist_name.lower() in ["uniform", "random"]: + dist = getattr(np.random.default_rng(), dist_name.lower()) + var_values = np.array(dist(size=self.number_of_samples)) + return dist, var_values + elif dist_name.lower() == "normal": + dist = getattr(np.random.default_rng(), "normal") + var_values = dist(loc=0.5, scale=1 / 6, size=self.number_of_samples) + if not self.normal_bounds_enforced: + return dist, np.array(var_values) + else: + if ( + sum([1 for i in range(0, var_values.shape[0]) if var_values[i] > 1]) + + sum( + [1 for i in range(0, var_values.shape[0]) if var_values[i] < 0] + ) + > 0 + ): + warnings.warn( + "Points adjusted to remain within specified Gaussian bounds. This may affect the underlying distribution." + ) + out_locations = [ + i + for i in range(0, var_values.shape[0]) + if var_values[i] > 1 or var_values[i] < 0 + ] + for k in out_locations: + rep_value = var_values[k] + while (rep_value < 0) or (rep_value > 1): + rep_value = dist(loc=0.5, scale=1 / 6, size=1) + var_values[k] = rep_value + assert ( + sum([1 for i in range(0, var_values.shape[0]) if var_values[i] > 1]) + + sum( + [1 for i in range(0, var_values.shape[0]) if var_values[i] < 0] + ) + == 0 + ) + return dist, np.array(var_values) + + def sample_points(self): + points_spread = [] + for i in self.dist_vector: + _, var_values = self.generate_from_dist(i) + points_spread.append(var_values) + samples_array = np.asarray(points_spread).T + + # Scale input data, then find data points closest in sample space. Unscale before returning points + unique_sample_points = self.sample_point_selection( + self.data, samples_array, self.sampling_type + ) + if len(self.data_headers) > 0 and self.df_flag: + unique_sample_points = pd.DataFrame( + unique_sample_points, columns=self.data_headers + ) + return unique_sample_points diff --git a/idaes/core/surrogate/pysmo/tests/test_sampling.py b/idaes/core/surrogate/pysmo/tests/test_sampling.py index 6f27569e3d..67f2daf957 100644 --- a/idaes/core/surrogate/pysmo/tests/test_sampling.py +++ b/idaes/core/surrogate/pysmo/tests/test_sampling.py @@ -24,6 +24,7 @@ HaltonSampling, HammersleySampling, CVTSampling, + CustomSampling, SamplingMethods, FeatureScaling, ) @@ -469,7 +470,7 @@ class TestLatinHypercubeSampling: @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_01(self, array_type): + def test__init__selection_right_behaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array) LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="selection" @@ -480,7 +481,9 @@ def test__init__selection_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_02(self, array_type): + def test__init__selection_right_behaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array) LHSClass = LatinHypercubeSampling( input_array, number_of_samples=6, sampling_type="selection" @@ -491,52 +494,62 @@ def test__init__selection_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_03(self, array_type): + def test__init__selection_zero_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=0, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_04(self, array_type): + def test__init__selection_negative_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=-1, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_05(self, array_type): + def test__init__selection_excess_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="LHS sample size cannot be greater than number of samples in the input data set", + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=101, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_06(self, array_type): + def test__init__selection_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=1.1, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__selection_07(self, array_type): + def test__init__selection_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='Pandas dataframe or numpy array required for sampling_type "selection."', + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_01(self, array_type): + def test__init__creation_right_behaviour_with_none_samplingtype(self, array_type): input_array = array_type(self.input_array_list) LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type=None @@ -546,7 +559,7 @@ def test__init__creation_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_02(self, array_type): + def test__init__creation_right_behaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array_list) LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="creation" @@ -556,7 +569,9 @@ def test__init__creation_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_03(self, array_type): + def test__init__creation_right_behaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array_list) LHSClass = LatinHypercubeSampling( input_array, number_of_samples=100, sampling_type="creation" @@ -566,36 +581,43 @@ def test__init__creation_03(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_04(self, array_type): + def test__init__creation_zero_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=0, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_05(self, array_type): + def test__init__creation_negative_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=-1, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_06(self, array_type): + def test__init__creation_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=1.1, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_07(self, array_type): + def test__init__creation_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='List entry of two elements expected for sampling_type "creation."', + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="creation" ) @@ -610,63 +632,90 @@ def test__init__creation_08(self, array_type): ) @pytest.mark.unit - def test__init__creation_09(self): + def test__init__creation_missing_bounds(self): input_array = [[2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_10(self): + def test__init__creation_wrong_data_input_format_lb(self): input_array = [np.array([1, 10, 3]), [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_11(self): + def test__init__creation_wrong_data_input_format_ub(self): input_array = [[1, 10, 3], np.array([2, 11, 4.5])] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_12(self): + def test__init__creation_unequal_length_list_bounds(self): input_array = [[1, 10], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_13(self): + def test__init__creation_equal_input_output_bounds_all(self): input_array = [[2, 11, 4.5], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises(ValueError, match="Invalid entry: both lists are equal."): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_selection_01(self, array_type): + def test__init__samplingtype_nonstring(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + TypeError, match="Invalid sampling type entry. Must be of type ." + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type=1 ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_selection_02(self, array_type): + def test__init__samplingtype_undefined_string(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match='Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.', + ): LHSClass = LatinHypercubeSampling( input_array, number_of_samples=None, sampling_type="jp" ) + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__test_single_equal_ub_lb(self, array_type): + input_array = array_type([[0, 0, 0], [0, 1, 1]]) + with pytest.raises( + ValueError, + match="Invalid entry: at least one variable contains the same value for the lower and upper bounds.", + ): + LHSClass = LatinHypercubeSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + ) + @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) def test_variable_sample_creation(self, array_type): @@ -810,7 +859,7 @@ class TestUniformSampling: @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_01(self, array_type): + def test__init__selection_right_behaviour(self, array_type): input_array = array_type(self.input_array) UniClass = UniformSampling(input_array, [2, 5], sampling_type="selection") np.testing.assert_array_equal(UniClass.data, input_array) @@ -819,62 +868,90 @@ def test__init__selection_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_02(self, array_type): + def test__init__selection_wrong_type_for_list_of_samples_per_variable_01( + self, array_type + ): input_array = array_type(self.input_array) - with pytest.raises(TypeError): + with pytest.raises( + TypeError, match="list_of_samples_per_variable: list required." + ): UniClass = UniformSampling( input_array, np.array([2, 5]), sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_03(self, array_type): + def test__init__selection_wrong_type_for_list_of_samples_per_variable_02( + self, array_type + ): input_array = array_type(self.input_array) - with pytest.raises(TypeError): + with pytest.raises( + TypeError, match="list_of_samples_per_variable: list required." + ): UniClass = UniformSampling( input_array, pd.DataFrame([2, 5]), sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_04(self, array_type): + def test__init__selection_wrong_length_for_list_of_samples_per_variable_01( + self, array_type + ): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="Length of list_of_samples_per_variable must equal the number of variables.", + ): UniClass = UniformSampling(input_array, [2], sampling_type="selection") @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_05(self, array_type): + def test__init__selection_wrong_length_for_list_of_samples_per_variable_02( + self, array_type + ): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="Length of list_of_samples_per_variable must equal the number of variables.", + ): UniClass = UniformSampling( input_array, [2, 5, 5], sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_06(self, array_type): + def test__init__selection_negative_entry_in_list_of_samples_per_variable( + self, array_type + ): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="All variables must have at least two points per dimension", + ): UniClass = UniformSampling(input_array, [-2, 5], sampling_type="selection") @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_07(self, array_type): + def test__init__selection_fractional_entry_in_list_of_samples_per_variable( + self, array_type + ): input_array = array_type(self.input_array) - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="All values in list must be integers"): UniClass = UniformSampling(input_array, [2.1, 5], sampling_type="selection") @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_08(self, array_type): + def test__init__selection_excess_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="Sample size cannot be greater than number of samples in the input data set", + ): UniClass = UniformSampling(input_array, [2, 50], sampling_type="selection") @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_09(self, array_type): + def test__init__selection_assert_correct_behaviour_edge_true(self, array_type): input_array = array_type(self.input_array) UniClass = UniformSampling( input_array, [2, 5], sampling_type="selection", edges=True @@ -882,10 +959,11 @@ def test__init__selection_09(self, array_type): np.testing.assert_array_equal(UniClass.data, input_array) np.testing.assert_array_equal(UniClass.number_of_samples, 10) np.testing.assert_array_equal(UniClass.x_data, np.array(input_array)[:, :-1]) + assert UniClass.edge == True @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_10(self, array_type): + def test__init__selection_assert_correct_behaviour_edge_false(self, array_type): input_array = array_type(self.input_array) UniClass = UniformSampling( input_array, [2, 5], sampling_type="selection", edges=False @@ -893,35 +971,39 @@ def test__init__selection_10(self, array_type): np.testing.assert_array_equal(UniClass.data, input_array) np.testing.assert_array_equal(UniClass.number_of_samples, 10) np.testing.assert_array_equal(UniClass.x_data, np.array(input_array)[:, :-1]) + assert UniClass.edge == False @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_11(self, array_type): + def test__init__selection_nonboolean_edge_entry_01(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises(TypeError, match='Invalid "edges" entry. Must be boolean'): UniClass = UniformSampling( input_array, [2, 5], sampling_type="selection", edges=1 ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_12(self, array_type): + def test__init__selection_nonboolean_edge_entry_02(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises(TypeError, match='Invalid "edges" entry. Must be boolean'): UniClass = UniformSampling( input_array, [2, 5], sampling_type="selection", edges="x" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__selection_13(self, array_type): + def test__init__selection_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='Pandas dataframe or numpy array required for sampling_type "selection."', + ): UniClass = UniformSampling(input_array, [2, 5], sampling_type="selection") @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_01(self, array_type): + def test__init__creation_right_behaviour_with_none_samplingtype(self, array_type): input_array = array_type(self.input_array_list) UniClass = UniformSampling(input_array, [2, 7, 5], sampling_type=None) np.testing.assert_array_equal(UniClass.data, input_array) @@ -929,7 +1011,7 @@ def test__init__creation_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_02(self, array_type): + def test__init__creation_right_behaviour_with_specified_sampling(self, array_type): input_array = array_type(self.input_array_list) UniClass = UniformSampling(input_array, [2, 7, 5], sampling_type="creation") np.testing.assert_array_equal(UniClass.data, input_array) @@ -937,80 +1019,125 @@ def test__init__creation_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_03(self, array_type): + def test__init__creation_wrong_entry_in_list_of_samples_per_variable( + self, array_type + ): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="All variables must have at least two points per dimension", + ): UniClass = UniformSampling(input_array, [1, 7, 5], sampling_type="creation") @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_04(self, array_type): + def test__init__creation__negative_entry_in_list_of_samples_per_variable( + self, array_type + ): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="All variables must have at least two points per dimension", + ): UniClass = UniformSampling( input_array, [-1, 7, 5], sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_05(self, array_type): + def test__init__creation_invalid_entry_in_list_of_samples_per_variable( + self, array_type + ): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="All variables must have at least two points per dimension", + ): UniClass = UniformSampling( input_array, [1.1, 7, 5], sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_06(self, array_type): + def test__init__creation_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='List entry of two elements expected for sampling_type "creation."', + ): UniClass = UniformSampling(input_array, [2, 5], sampling_type="creation") @pytest.mark.unit - def test__init__creation_08(self): + def test__init__creation_missing_bounds(self): input_array = [[2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): UniClass = UniformSampling(input_array, [2, 7, 5], sampling_type=None) @pytest.mark.unit - def test__init__creation_09(self): + def test__init__creation_wrong_data_input_format_lb(self): input_array = [np.array([1, 10, 3]), [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): UniClass = UniformSampling(input_array, [2, 7, 5], sampling_type=None) @pytest.mark.unit - def test__init__creation_10(self): + def test__init__creation_wrong_data_input_format_ub(self): input_array = [[1, 10, 3], np.array([2, 11, 4.5])] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): UniClass = UniformSampling(input_array, [2, 7, 5], sampling_type=None) @pytest.mark.unit - def test__init__creation_11(self): + def test__init__creation_unequal_length_list_bounds(self): input_array = [[1, 10], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): UniClass = UniformSampling(input_array, [2, 7, 5], sampling_type=None) @pytest.mark.unit - def test__init__creation_12(self): + def est__init__creation_equal_input_output_bounds_all(self): input_array = [[2, 11, 4.5], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises(ValueError, match="Invalid entry: both lists are equal."): UniClass = UniformSampling(input_array, [2, 7, 5], sampling_type=None) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_selection_01(self, array_type): + def test__init__samplingtype_nonstring(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + TypeError, match="Invalid sampling type entry. Must be of type ." + ): UniClass = UniformSampling(input_array, [2, 5], sampling_type=1) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_selection_02(self, array_type): + def test__init__samplingtype_undefined_string(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match='Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.', + ): UniClass = UniformSampling(input_array, [2, 5], sampling_type="jp") + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_equal_input_output_bounds_one(self, array_type): + input_array = array_type([[0, 0, 0], [0, 1, 1]]) + with pytest.raises( + ValueError, + match="Invalid entry: at least one variable contains the same value for the lower and upper bounds.", + ): + UniClass = UniformSampling( + input_array, + [2, 7, 5], + sampling_type="creation", + ) + @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array]) def test_sample_points_01(self, array_type): @@ -1147,7 +1274,7 @@ class TestHaltonSampling: @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_01(self, array_type): + def test__init__selection_right_behaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array) HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="selection" @@ -1158,7 +1285,9 @@ def test__init__selection_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_02(self, array_type): + def test__init__selection_right_behaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array) HaltonClass = HaltonSampling( input_array, number_of_samples=6, sampling_type="selection" @@ -1169,79 +1298,97 @@ def test__init__selection_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_03(self, array_type): + def test__init__selection_zero_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=0, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_04(self, array_type): + def test__init__selection_negative_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=-1, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_05(self, array_type): + def test__init__selection_excess_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="Sample size cannot be greater than number of samples in the input data set", + ): HaltonClass = HaltonSampling( input_array, number_of_samples=101, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_06(self, array_type): + def test__init__selection_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): HaltonClass = HaltonSampling( input_array, number_of_samples=1.1, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__selection_07(self, array_type): + def test__init__selection_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='Pandas dataframe or numpy array required for sampling_type "selection."', + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_08(self, array_type): + def test__init__samplingtype_nonstring(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + TypeError, match="Invalid sampling type entry. Must be of type ." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type=[1, 2, 3] ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_09(self, array_type): + def test__init__samplingtype_undefined_string(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match='Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.', + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="choose" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_10(self, array_type): + def test__init__selection_method_dimensionality_exceeded(self, array_type): input_array = array_type(self.input_array_high) - with pytest.raises(Exception): + with pytest.raises( + Exception, + match="Dimensionality problem: This method is not available for problems with dimensionality > 10: the performance of the method degrades substantially at higher dimensions", + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_01(self, array_type): + def test__init__creation_right_behaviour_with_none_samplingtype(self, array_type): input_array = array_type(self.input_array_list) HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type=None @@ -1251,7 +1398,7 @@ def test__init__creation_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_02(self, array_type): + def test__init__creation_right_hahaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array_list) HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="creation" @@ -1261,7 +1408,9 @@ def test__init__creation_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_03(self, array_type): + def test__init__creation_right_hahaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array_list) HaltonClass = HaltonSampling( input_array, number_of_samples=100, sampling_type="creation" @@ -1271,89 +1420,121 @@ def test__init__creation_03(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_04(self, array_type): + def test__init__creation_zero_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=0, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_05(self, array_type): + def test__init__creation_negative_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=-1, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_06(self, array_type): + def test__init__creation_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): HaltonClass = HaltonSampling( input_array, number_of_samples=1.1, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_07(self, array_type): + def test__init__creation_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + TypeError, + match='List entry of two elements expected for sampling_type "creation."', + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_09(self): + def test__init__creation_missing_bounds(self): input_array = [[2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_10(self): + def test__init__creation_wrong_data_input_format_lb(self): input_array = [np.array([1, 10, 3]), [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_11(self): + def test__init__creation_wrong_data_input_format_ub(self): input_array = [[1, 10, 3], np.array([2, 11, 4.5])] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_12(self): + def test__init__creation_unequal_length_list_bounds(self): input_array = [[1, 10], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_13(self): + def test__init__creation_equal_input_output_bounds_all(self): input_array = [[2, 11, 4.5], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises(ValueError, match="Invalid entry: both lists are equal."): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection(self, array_type): + def test__init__selection_dimensionality_exceeded(self, array_type): input_array = array_type(self.input_array_high) - with pytest.raises(Exception): + with pytest.raises( + Exception, + match="Dimensionality problem: This method is not available for problems with dimensionality > 10: the performance of the method degrades substantially at higher dimensions", + ): HaltonClass = HaltonSampling( input_array, number_of_samples=None, sampling_type="selection" ) + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__test_single_equal_ub_lb(self, array_type): + input_array = array_type([[0, 0, 0], [0, 1, 1]]) + with pytest.raises( + ValueError, + match="Invalid entry: at least one variable contains the same value for the lower and upper bounds.", + ): + HaltonClass = HaltonSampling( + input_array, + number_of_samples=5, + sampling_type="creation", + ) + @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array]) def test_sample_points_01(self, array_type): @@ -1468,7 +1649,7 @@ class TestHammersleySampling: @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_01(self, array_type): + def test__init__selection_right_behaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array) HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="selection" @@ -1481,7 +1662,9 @@ def test__init__selection_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_02(self, array_type): + def test__init__selection_right_behaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array) HammersleyClass = HammersleySampling( input_array, number_of_samples=6, sampling_type="selection" @@ -1494,79 +1677,97 @@ def test__init__selection_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_03(self, array_type): + def test__init__selection_zero_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=0, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_04(self, array_type): + def test__init__selection_negative_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=-1, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_05(self, array_type): + def test__init__selection_excess_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="Sample size cannot be greater than number of samples in the input data set", + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=101, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_06(self, array_type): + def test__init__selection_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): HammersleyClass = HammersleySampling( input_array, number_of_samples=1.1, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__selection_07(self, array_type): + def test__init__selection_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='Pandas dataframe or numpy array required for sampling_type "selection."', + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_08(self, array_type): + def test__init__samplingtype_nonstring(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + TypeError, match="Invalid sampling type entry. Must be of type ." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type=[1, 2, 3] ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_09(self, array_type): + def test__init__samplingtype_undefined_string(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match='Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.', + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="choose" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_10(self, array_type): + def test__init__selection_method_dimensionality_exceeded(self, array_type): input_array = array_type(self.input_array_high) - with pytest.raises(Exception): + with pytest.raises( + Exception, + match="Dimensionality problem: This method is not available for problems with dimensionality > 10: the performance of the method degrades substantially at higher dimensions", + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="selection" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_01(self, array_type): + def test__init__creation_right_hahaviour_with_none_samplingtype(self, array_type): input_array = array_type(self.input_array_list) HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type=None @@ -1576,7 +1777,7 @@ def test__init__creation_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_02(self, array_type): + def test__init__creation_right_behaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array_list) HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="creation" @@ -1586,7 +1787,9 @@ def test__init__creation_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_03(self, array_type): + def test__init__creation_right_behaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array_list) HammersleyClass = HammersleySampling( input_array, number_of_samples=100, sampling_type="creation" @@ -1596,89 +1799,121 @@ def test__init__creation_03(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_04(self, array_type): + def test__init__creation_zero_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=0, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_05(self, array_type): + def test__init__creation_negative_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=-1, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_06(self, array_type): + def test__init__creation_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): HammersleyClass = HammersleySampling( input_array, number_of_samples=1.1, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_06(self, array_type): + def test__init__creation_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='List entry of two elements expected for sampling_type "creation."', + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_09(self): + def test__init__creation_missing_bounds(self): input_array = [[2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_10(self): + def test__init__creation_wrong_data_input_format_lb(self): input_array = [np.array([1, 10, 3]), [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_11(self): + def test__init__creation_wrong_data_input_format_ub(self): input_array = [[1, 10, 3], np.array([2, 11, 4.5])] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_12(self): + def test__init__creation_unequal_length_list_bounds(self): input_array = [[1, 10], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_13(self): + def test__init__creation_equal_input_output_bounds_all(self): input_array = [[2, 11, 4.5], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises(ValueError, match="Invalid entry: both lists are equal."): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection(self, array_type): + def test__init__selection_method_dimensionality_exceeded(self, array_type): input_array = array_type(self.input_array_large) - with pytest.raises(Exception): + with pytest.raises( + Exception, + match="Dimensionality problem: This method is not available for problems with dimensionality > 10: the performance of the method degrades substantially at higher dimensions", + ): HammersleyClass = HammersleySampling( input_array, number_of_samples=None, sampling_type="selection" ) + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_equal_input_output_bounds_one(self, array_type): + input_array = array_type([[0, 0, 0], [0, 1, 1]]) + with pytest.raises( + ValueError, + match="Invalid entry: at least one variable contains the same value for the lower and upper bounds.", + ): + HammersleyClass = HammersleySampling( + input_array, + number_of_samples=5, + sampling_type=None, + ) + @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array]) def test_sample_points_01(self, array_type): @@ -1751,7 +1986,7 @@ class TestCVTSampling: @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_01(self, array_type): + def test__init__selection_right_behaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array) CVTClass = CVTSampling( input_array, @@ -1766,7 +2001,9 @@ def test__init__selection_01(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_02(self, array_type): + def test__init__selection_right_behaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array) CVTClass = CVTSampling( input_array, number_of_samples=6, tolerance=None, sampling_type="selection" @@ -1778,9 +2015,11 @@ def test__init__selection_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_03(self, array_type): + def test__init__selection_zero_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): CVTClass = CVTSampling( input_array, number_of_samples=0, @@ -1790,9 +2029,11 @@ def test__init__selection_03(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_04(self, array_type): + def test__init__selection_negative_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): CVTClass = CVTSampling( input_array, number_of_samples=-1, @@ -1802,9 +2043,12 @@ def test__init__selection_04(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_05(self, array_type): + def test__init__selection_excess_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match="CVT sample size cannot be greater than number of samples in the input data set", + ): CVTClass = CVTSampling( input_array, number_of_samples=101, @@ -1814,9 +2058,9 @@ def test__init__selection_05(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_06(self, array_type): + def test__init__selection_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): CVTClass = CVTSampling( input_array, number_of_samples=1.1, @@ -1826,9 +2070,12 @@ def test__init__selection_06(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__selection_07(self, array_type): + def test__init__selection_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='Pandas dataframe or numpy array required for sampling_type "selection."', + ): CVTClass = CVTSampling( input_array, number_of_samples=None, @@ -1838,9 +2085,11 @@ def test__init__selection_07(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_08(self, array_type): + def test__init__selection_tolerance_too_loose(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="Tolerance must be less than 0.1 to achieve good results" + ): CVTClass = CVTSampling( input_array, number_of_samples=None, @@ -1850,9 +2099,12 @@ def test__init__selection_08(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_09(self, array_type): + def test__init__selection_tolerance_too_tight(self, array_type): input_array = array_type(self.input_array) - with pytest.warns(Warning): + with pytest.warns( + Warning, + match="Tolerance too tight. CVT algorithm may take long time to converge.", + ): CVTClass = CVTSampling( input_array, number_of_samples=None, @@ -1862,7 +2114,7 @@ def test__init__selection_09(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__selection_10(self, array_type): + def test__init__selection_valid_tolerance(self, array_type): input_array = array_type(self.input_array) CVTClass = CVTSampling( input_array, @@ -1872,6 +2124,18 @@ def test__init__selection_10(self, array_type): ) np.testing.assert_array_equal(CVTClass.eps, 0.09) + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_none_tolerance(self, array_type): + input_array = array_type(self.input_array) + CVTClass = CVTSampling( + input_array, + number_of_samples=None, + tolerance=None, + sampling_type="selection", + ) + np.testing.assert_array_equal(CVTClass.eps, 1e-7) + @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) def test__init__selection_11(self, array_type): @@ -1886,17 +2150,18 @@ def test__init__selection_11(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_01(self, array_type): + def test__init__creation_right_behaviour_with_none_samplingtype(self, array_type): input_array = array_type(self.input_array_list) CVTClass = CVTSampling( input_array, number_of_samples=None, tolerance=None, sampling_type=None ) np.testing.assert_array_equal(CVTClass.data, input_array) np.testing.assert_array_equal(CVTClass.number_of_centres, 5) + assert CVTClass.sampling_type == "creation" @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_02(self, array_type): + def test__init__creation_right_behaviour_with_none_no_samples(self, array_type): input_array = array_type(self.input_array_list) CVTClass = CVTSampling( input_array, @@ -1909,7 +2174,9 @@ def test__init__creation_02(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_03(self, array_type): + def test__init__creation_right_behaviour_with_specified_no_samples( + self, array_type + ): input_array = array_type(self.input_array_list) CVTClass = CVTSampling( input_array, number_of_samples=100, tolerance=None, sampling_type="creation" @@ -1919,9 +2186,11 @@ def test__init__creation_03(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_04(self, array_type): + def test__init__creation_zero_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): CVTClass = CVTSampling( input_array, number_of_samples=0, @@ -1931,9 +2200,11 @@ def test__init__creation_04(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_05(self, array_type): + def test__init__creation_negative_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): CVTClass = CVTSampling( input_array, number_of_samples=-1, @@ -1943,9 +2214,9 @@ def test__init__creation_05(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_06(self, array_type): + def test__init__creation_non_integer_no_samples(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises(TypeError, match="number_of_samples must be an integer."): CVTClass = CVTSampling( input_array, number_of_samples=1.1, @@ -1955,9 +2226,12 @@ def test__init__creation_06(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_07(self, array_type): + def test__init__creation_wrong_input_data_type(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='List entry of two elements expected for sampling_type "creation."', + ): CVTClass = CVTSampling( input_array, number_of_samples=None, @@ -1967,9 +2241,11 @@ def test__init__creation_07(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_08(self, array_type): + def test__init__creation_test_tolerance_too_loose(self, array_type): input_array = array_type(self.input_array_list) - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="Tolerance must be less than 0.1 to achieve good results" + ): CVTClass = CVTSampling( input_array, number_of_samples=None, @@ -1979,9 +2255,12 @@ def test__init__creation_08(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_09(self, array_type): + def test__init__creation_tolerance_too_tight(self, array_type): input_array = array_type(self.input_array_list) - with pytest.warns(Warning): + with pytest.warns( + Warning, + match="Tolerance too tight. CVT algorithm may take long time to converge.", + ): CVTClass = CVTSampling( input_array, number_of_samples=None, @@ -1991,7 +2270,7 @@ def test__init__creation_09(self, array_type): @pytest.mark.unit @pytest.mark.parametrize("array_type", [list]) - def test__init__creation_10(self, array_type): + def test__init__creation_valid_tolerance(self, array_type): input_array = array_type(self.input_array_list) CVTClass = CVTSampling( input_array, @@ -2014,63 +2293,91 @@ def test__init__creation_11(self, array_type): np.testing.assert_array_equal(CVTClass.eps, -0.09) @pytest.mark.unit - def test__init__creation_13(self): + def test__init__creation_missing_bounds(self): input_array = [[2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): LHSClass = CVTSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_14(self): + def test__init__creation_wrong_data_input_format_lb(self): input_array = [np.array([1, 10, 3]), [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): CVTClass = CVTSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_15(self): + def test__init__creation_wrong_data_input_format_ub(self): input_array = [[1, 10, 3], np.array([2, 11, 4.5])] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): CVTClass = CVTSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_16(self): + def test__init__creation_unequal_length_list_bounds(self): input_array = [[1, 10], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): CVTClass = CVTSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit - def test__init__creation_17(self): + def test__init__creation_equal_input_output_bounds_all(self): input_array = [[2, 11, 4.5], [2, 11, 4.5]] - with pytest.raises(Exception): + with pytest.raises(ValueError, match="Invalid entry: both lists are equal."): CVTClass = CVTSampling( input_array, number_of_samples=None, sampling_type="creation" ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_selection_01(self, array_type): + def test__init__samplingtype_nonstring(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + TypeError, match="Invalid sampling type entry. Must be of type ." + ): CVTClass = CVTSampling( input_array, number_of_samples=None, tolerance=None, sampling_type=1 ) @pytest.mark.unit @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) - def test__init__creation_selection_02(self, array_type): + def test__init__samplingtype_undefined_string(self, array_type): input_array = array_type(self.input_array) - with pytest.raises(Exception): + with pytest.raises( + ValueError, + match='Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.', + ): CVTClass = CVTSampling( input_array, number_of_samples=None, tolerance=None, sampling_type="jp" ) + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_equal_input_output_bounds_one(self, array_type): + input_array = array_type([[0, 0, 0], [0, 1, 1]]) + with pytest.raises( + ValueError, + match="Invalid entry: at least one variable contains the same value for the lower and upper bounds.", + ): + CVTClass = CVTSampling( + input_array, + number_of_samples=5, + tolerance=None, + sampling_type=None, + ) + @pytest.mark.unit def test_random_sample_selection_01(self): size = (5, 2) @@ -2093,19 +2400,21 @@ def test_random_sample_selection_03(self): assert out_random_points.shape == size @pytest.mark.unit - def test_random_sample_selection_04(self): + def test_random_sample_selection_test_negative(self): size = (5, -1) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="negative dimensions are not allowed"): out_random_points = CVTSampling.random_sample_selection(size[0], size[1]) @pytest.mark.unit - def test_random_sample_selection_05(self): + def test_random_sample_selection_test_float(self): size = (5, 1.1) - with pytest.raises(TypeError): + with pytest.raises( + TypeError, match="'float' object cannot be interpreted as an integer" + ): out_random_points = CVTSampling.random_sample_selection(size[0], size[1]) @pytest.mark.unit - def test_eucl_distance_01(self): + def test_eucl_distance_single_values(self): u = np.array([[3]]) v = np.array([[5]]) expected_output = 2 @@ -2113,7 +2422,7 @@ def test_eucl_distance_01(self): assert expected_output == output @pytest.mark.unit - def test_eucl_distance_02(self): + def test_eucl_distance_1d_arrays(self): u = np.array([[1, 2]]) v = np.array([[3, 4]]) expected_output = 8**0.5 @@ -2121,7 +2430,7 @@ def test_eucl_distance_02(self): assert expected_output == output @pytest.mark.unit - def test_eucl_distance_03(self): + def test_eucl_distance_2d_arrays(self): u = np.array([[1, 2], [3, 4]]) v = np.array([[5, 6], [7, 8]]) expected_output = np.array([32**0.5, 32**0.5]) @@ -2129,7 +2438,7 @@ def test_eucl_distance_03(self): np.testing.assert_array_equal(expected_output, output) @pytest.mark.unit - def test_eucl_distance_04(self): + def test_eucl_distance_1d_2d_arrays(self): u = np.array([[1, 2]]) v = np.array([[5, 6], [7, 8]]) expected_output = np.array([32**0.5, 72**0.5]) @@ -2240,5 +2549,768 @@ def test_sample_points_02(self, array_type): ) +class TestCustomSampling: + input_array = [[x, x + 10, (x + 1) ** 2 + x + 10] for x in range(10)] + input_array_list = [[x, x + 10, (x + 1) ** 2 + x + 10] for x in range(2)] + y = np.array( + [ + [i, j, ((i + 1) ** 2) + ((j + 1) ** 2)] + for i in np.linspace(0, 10, 21) + for j in np.linspace(0, 10, 21) + ] + ) + full_data = {"x1": y[:, 0], "x2": y[:, 1], "y": y[:, 2]} + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_right_behaviour_with_none_no_samples(self, array_type): + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + np.testing.assert_array_equal(CSClass.data, input_array) + np.testing.assert_array_equal(CSClass.number_of_samples, 5) + np.testing.assert_array_equal(CSClass.x_data, np.array(input_array)[:, :-1]) + assert CSClass.dist_vector == ["uniform", "normal"] + assert CSClass.normal_bounds_enforced == False + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_right_behaviour_with_specified_no_samples( + self, array_type + ): + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=6, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + np.testing.assert_array_equal(CSClass.data, input_array) + np.testing.assert_array_equal(CSClass.number_of_samples, 6) + np.testing.assert_array_equal(CSClass.x_data, np.array(input_array)[:, :-1]) + assert CSClass.dist_vector == ["uniform", "normal"] + assert CSClass.normal_bounds_enforced == False + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_right_behaviour_with_bounds_option_true(self, array_type): + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=6, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + strictly_enforce_gaussian_bounds=True, + ) + np.testing.assert_array_equal(CSClass.data, input_array) + np.testing.assert_array_equal(CSClass.number_of_samples, 6) + np.testing.assert_array_equal(CSClass.x_data, np.array(input_array)[:, :-1]) + assert CSClass.dist_vector == ["uniform", "normal"] + assert CSClass.normal_bounds_enforced == True + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_right_behaviour_with_bounds_option_false( + self, array_type + ): + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=6, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + strictly_enforce_gaussian_bounds=False, + ) + np.testing.assert_array_equal(CSClass.data, input_array) + np.testing.assert_array_equal(CSClass.number_of_samples, 6) + np.testing.assert_array_equal(CSClass.x_data, np.array(input_array)[:, :-1]) + assert CSClass.dist_vector == ["uniform", "normal"] + assert CSClass.normal_bounds_enforced == False + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_zero_samples(self, array_type): + input_array = array_type(self.input_array) + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=0, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_negative_no_samples(self, array_type): + input_array = array_type(self.input_array) + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=-1, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_excess_no_samples(self, array_type): + input_array = array_type(self.input_array) + with pytest.raises( + ValueError, + match="Sample size cannot be greater than number of samples in the input data set", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=101, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_non_integer_no_samples(self, array_type): + input_array = array_type(self.input_array) + with pytest.raises(TypeError, match="number_of_samples must be an integer."): + CSClass = CustomSampling( + input_array, + number_of_samples=1.1, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__selection_wrong_input_data_type(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, + match='Pandas dataframe or numpy array required for sampling_type "selection."', + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_list_distributions_length_exceeds_inputs( + self, array_type + ): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, + match="Length of list_of_distributions must equal the number of variables.", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_list_distributions_length_less_than_inputs( + self, array_type + ): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, + match="Length of list_of_distributions must equal the number of variables.", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=["uniform"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_empty_distributions(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises(ValueError, match="list_of_distributions cannot be empty."): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=None, + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_distribution_not_list(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + TypeError, match="Error with list_of_distributions: list required." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=("uniform", "normal"), + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_distribution_entry_not_string(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises(TypeError, match="All values in list must be strings"): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=["uniform", 1.0], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__selection_distribution_not_available(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, + match="list_of_distributions only supports 'random', 'normal' and 'uniform' sampling options.", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="selection", + list_of_distributions=["uniform", "binomial"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_right_hahaviour_with_none_samplingtype(self, array_type): + input_array = array_type(self.input_array_list) + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type=None, + list_of_distributions=["uniform", "normal", "random"], + ) + np.testing.assert_array_equal(CSClass.data, input_array) + np.testing.assert_array_equal(CSClass.number_of_samples, 5) + assert CSClass.dist_vector == ["uniform", "normal", "random"] + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_right_behaviour_with_none_no_samples(self, array_type): + input_array = array_type(self.input_array_list) + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + np.testing.assert_array_equal(CSClass.data, input_array) + np.testing.assert_array_equal(CSClass.number_of_samples, 5) + assert CSClass.dist_vector == ["uniform", "normal", "random"] + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_right_behaviour_with_specified_no_samples( + self, array_type + ): + input_array = array_type(self.input_array_list) + CSClass = CustomSampling( + input_array, + number_of_samples=100, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + np.testing.assert_array_equal(CSClass.data, input_array) + np.testing.assert_array_equal(CSClass.number_of_samples, 100) + assert CSClass.dist_vector == ["uniform", "normal", "random"] + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_zero_samples(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=0, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_negative_no_samples(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, match="number_of_samples must a positive, non-zero integer." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=-1, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_non_integer_no_samples(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises(TypeError, match="number_of_samples must be an integer."): + CSClass = CustomSampling( + input_array, + number_of_samples=1.1, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__creation_wrong_input_data_type(self, array_type): + input_array = array_type(self.input_array) + with pytest.raises( + ValueError, + match='List entry of two elements expected for sampling_type "creation."', + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + def test__init__creation_missing_bounds(self): + input_array = [[2, 11, 4.5]] + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + def test__init__creation_wrong_data_input_format_lb(self): + input_array = [np.array([1, 10, 3]), [2, 11, 4.5]] + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + def test__init__creation_wrong_data_input_format_ub(self): + input_array = [[1, 10, 3], np.array([2, 11, 4.5])] + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + def test__init__creation_unequal_length_list_bounds(self): + input_array = [[1, 10], [2, 11, 4.5]] + with pytest.raises( + ValueError, match="data_input must contain two lists of equal lengths." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + def test__init__creation_equal_input_output_bounds_all(self): + input_array = [[2, 11, 4.5], [2, 11, 4.5]] + with pytest.raises(ValueError, match="Invalid entry: both lists are equal."): + csClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_list_distributions_length_less_than_inputs( + self, array_type + ): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, + match="Length of list_of_distributions must equal the number of variables.", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_list_distributions_length_exceeds_inputs(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, + match="Length of list_of_distributions must equal the number of variables.", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "uniform", "normal", "random"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__selection_empty_distributions(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises(ValueError, match="list_of_distributions cannot be empty."): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=None, + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__selection_distribution_not_list(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + TypeError, match="Error with list_of_distributions: list required." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=("uniform", "normal", "random"), + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__selection_distribution_entry_not_string(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises(TypeError, match="All values in list must be strings"): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", 1.0, "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__selection_distribution_not_available(self, array_type): + input_array = array_type(self.input_array_list) + with pytest.raises( + ValueError, + match="list_of_distributions only supports 'random', 'normal' and 'uniform' sampling options.", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="creation", + list_of_distributions=["uniform", "gaussian", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_equal_input_output_bounds_one(self, array_type): + input_array = array_type([[0, 0, 0], [0, 1, 1]]) + with pytest.raises(Exception): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type=None, + list_of_distributions=["uniform", "normal", "random"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test__init__creation_nonboolean_bounds_option(self, array_type): + input_array = array_type([[0, 0, 0], [1, 1, 1]]) + with pytest.raises( + TypeError, + match="Invalid 'strictly_enforce_gaussian_bounds' entry. Must be boolean.", + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type=None, + list_of_distributions=["uniform", "normal", "random"], + strictly_enforce_gaussian_bounds="False", + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__samplingtype_nonstring(self, array_type): + input_array = array_type(self.input_array) + with pytest.raises( + TypeError, match="Invalid sampling type entry. Must be of type ." + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type=1, + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array, pd.DataFrame]) + def test__init__samplingtype_undefined_string(self, array_type): + input_array = array_type(self.input_array) + with pytest.raises( + ValueError, + match='Invalid sampling type requirement entered. Enter "creation" for sampling from a range or "selection" for selecting samples from a dataset.', + ): + CSClass = CustomSampling( + input_array, + number_of_samples=None, + sampling_type="jp", + list_of_distributions=["uniform", "normal"], + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array]) + def test_generate_from_dist_uniform(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + dist_type = "uniform" + dist_res, scaled_samples = CSClass.generate_from_dist(dist_type) + assert type(scaled_samples) == np.ndarray + assert scaled_samples.shape == (CSClass.number_of_samples,) + assert dist_res.__name__ == dist_type + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array]) + def test_generate_from_dist_normal(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + dist_type = "normal" + dist_res, scaled_samples = CSClass.generate_from_dist(dist_type) + assert type(scaled_samples) == np.ndarray + assert scaled_samples.shape == (CSClass.number_of_samples,) + assert dist_res.__name__ == dist_type + + @pytest.mark.unit + def test_generate_from_dist_normal_unenforced_gaussian_bounds(self): + CSClass = CustomSampling( + [[0], [1]], + number_of_samples=10000, + sampling_type="creation", + list_of_distributions=["normal"], + ) + dist_type = "normal" + dist_res, scaled_samples = CSClass.generate_from_dist(dist_type) + assert dist_res.__name__ == dist_type + assert scaled_samples.min() < 0 + assert scaled_samples.max() > 1 + + @pytest.mark.unit + def test_generate_from_dist_normal_enforced_gaussian_bounds(self): + CSClass = CustomSampling( + [[0], [1]], + number_of_samples=10000, + sampling_type="creation", + list_of_distributions=["normal"], + strictly_enforce_gaussian_bounds=True, + ) + dist_type = "normal" + dist_res, scaled_samples = CSClass.generate_from_dist(dist_type) + assert dist_res.__name__ == dist_type + assert scaled_samples.min() >= 0 + assert scaled_samples.max() <= 1 + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array]) + def test_generate_from_dist_random(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + dist_type = "random" + dist_res, scaled_samples = CSClass.generate_from_dist(dist_type) + assert type(scaled_samples) == np.ndarray + assert scaled_samples.shape == (CSClass.number_of_samples,) + assert dist_res.__name__ == dist_type + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array]) + def test_generate_from_dist_all_types(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["uniform", "normal"], + ) + for dist_type in ["uniform", "random", "uniform"]: + dist_res, scaled_samples = CSClass.generate_from_dist(dist_type) + assert type(scaled_samples) == np.ndarray + assert scaled_samples.shape == (CSClass.number_of_samples,) + assert dist_res.__name__ == dist_type + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array]) + def test_sample_points_01(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["random", "normal"], + ) + unique_sample_points = CSClass.sample_points() + expected_testing = np.array( + [True] * unique_sample_points.shape[0], dtype=bool + ) + out_testing = [ + unique_sample_points[i, :] in input_array + for i in range(unique_sample_points.shape[0]) + ] + np.testing.assert_array_equal( + np.unique(unique_sample_points, axis=0), unique_sample_points + ) + np.testing.assert_array_equal(expected_testing, out_testing) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test_sample_points_02(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array_list) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="creation", + list_of_distributions=["random", "normal", "uniform"], + strictly_enforce_gaussian_bounds=True, + ) + unique_sample_points = CSClass.sample_points() + input_array = np.array(input_array) + for i in range(input_array.shape[1]): + var_range = input_array[:, i] + assert (unique_sample_points[:, i] >= var_range[0]).all() and ( + unique_sample_points[:, i] <= var_range[1] + ).all() + np.testing.assert_array_equal( + np.unique(unique_sample_points, axis=0).shape, + unique_sample_points.shape, + ) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [pd.DataFrame]) + def test_sample_points_03(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.full_data) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["random", "normal"], + ) + unique_sample_points = CSClass.sample_points() + expected_testing = np.array( + [True] * unique_sample_points.shape[0], dtype=bool + ) + unique_sample_points = np.array(unique_sample_points) + out_testing = [ + unique_sample_points[i, :] in np.array(input_array) + for i in range(unique_sample_points.shape[0]) + ] + np.testing.assert_array_equal( + np.unique(unique_sample_points, axis=0), unique_sample_points + ) + np.testing.assert_array_equal(expected_testing, out_testing) + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [list]) + def test_sample_points_with_list_input_creation_mode(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array_list) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="creation", + list_of_distributions=["random", "normal", "uniform"], + ) + unique_sample_points = CSClass.sample_points() + assert len(CSClass.dist_vector) == len(input_array[0]) + assert unique_sample_points.shape[0] == CSClass.number_of_samples + assert unique_sample_points.shape[1] == len(input_array[0]) + assert type(unique_sample_points) == np.ndarray + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [pd.DataFrame]) + def test_sample_points_with_pandas_dataframe_input_selection_mode(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.full_data) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["random", "normal"], + ) + unique_sample_points = CSClass.sample_points() + assert len(CSClass.dist_vector) == input_array.shape[1] - 1 + assert unique_sample_points.shape[1] == input_array.shape[1] + assert type(unique_sample_points) == pd.DataFrame + + @pytest.mark.unit + @pytest.mark.parametrize("array_type", [np.array]) + def test_sample_points_with_numpy_array_input_selection_mode(self, array_type): + for num_samples in [None, 10, 1]: + input_array = array_type(self.input_array) + CSClass = CustomSampling( + input_array, + number_of_samples=num_samples, + sampling_type="selection", + list_of_distributions=["random", "uniform"], + ) + unique_sample_points = CSClass.sample_points() + assert len(CSClass.dist_vector) == input_array.shape[1] - 1 + assert unique_sample_points.shape[1] == input_array.shape[1] + assert type(unique_sample_points) == np.ndarray + + if __name__ == "__main__": pytest.main() From de15ec0fd9bb5373c08379a850e98c089f6ed030 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 19 Dec 2023 11:47:37 -0500 Subject: [PATCH 07/12] Improve model for thickeners (#1304) * Adding beta warning * Adding beta warning to docs * Updating to predicitve thickener * Adding Stokes' Law * Working on updating docs * Finishing docs and fixing bugs * Expanding reference source to avoid typo failures * Fixing typos and referencing equations * Removing unused import * Removing beta warning --- .../unit_models/solid_liquid/thickener0d.rst | 123 ++-- .../solid_liquid/tests/test_thickener.py | 477 ++++++++++------ .../unit_models/solid_liquid/thickener.py | 533 +++++++++++++++--- 3 files changed, 828 insertions(+), 305 deletions(-) diff --git a/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst b/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst index d04a479be5..98c3ed561c 100644 --- a/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst +++ b/docs/reference_guides/model_libraries/generic/unit_models/solid_liquid/thickener0d.rst @@ -1,89 +1,120 @@ Thickener (0D) ============== -.. warning:: - The Thickener model is currently in beta status and will likely change in the next release as a more predictive version is developed. +The ``Thickener0D`` unit model is a predictive model for clarifiers and thickeners based on the following references: -The ``Thickener0D`` unit model is an extension of the :ref:`SLSeparator ` model which adds constraints to estimate the area and height of a vessel required to achieve the desired separation of solid and liquid based on experimental measurements of the settling velocity. This model is based on correlations described in: +[1] R. Burger, F. Concha, K.H. Karlsen, A. Narvaez, Numerical simulation of clarifier-thickener units treating ideal suspensions with a flux density function having two inflection points, Mathematical and Computer Modelling 44 (2006) 255–275, doi:10.1016/j.mcm.2005.11.008 -[1] Coulson & Richardson's Chemical Engineering, Volume 2 Particle Technology & Separation Processes (4th Ed.), Butterworth-Heinemann (2001) +[2] N.G. Barton, C.-H. Li, S.J. Spencer, Control of a surface of discontinuity in continuous thickeners, Journal of the Australian Mathematical Society Series B 33 (1992) 269–289 -Sizing Thickeners and Pinch Point ---------------------------------- - -The approach for sizing the thickener vessel used in this model relies on identifying a pinch point in the thickener which is the limiting condition for the settling velocity of the suspension as described in [1]. The pinch point is described by the condition: - -.. math:: max \left( \frac{(Y - Y_{under})}{u(Y)} \right) - -where :math:`Y` is the mass based liquid-to-solid ratio, :math:`u(Y)` is the settling velocity of the suspension as a function of :math:`Y` and :math:`Y_{under}` is the liquid-solid ratio at the thickener underflow. Correlations exist which can predict :math:`u(Y)` however in many cases it is necessary to measure this experimentally. Additionally, whilst there are techniques for embedding the maximization operation within an equation-oriented model, these approaches tend to be highly non-linear and require the user to provide good values for scaling parameters and thus have not been implemented yet. - -In the current implementation of the model, :math:`Y_{pinch}` and :math:`u_{pinch}` are created as user-defined input variables which should generally be fixed at appropriate values. Users may choose to add a correlation for :math:`u(Y)` if they choose. Degrees of Freedom ------------------ -The ``Thickener0D`` model has 5 degrees of freedom, which are generally chosen from: +The ``Thickener0D`` model has 6 degrees of freedom. Four of these are used to define the solids settling velocity and must be provided by the user. + +* the solid particle size ``particle_size``, +* the maximum achievable solid fraction ``solid_fraction_max``, +* two empirical coefficients for calculating the settling velocity ``v1``, ``C``. -* the liquid recovery fraction or underflow liquid-to-solid ratio, -* the liquid-to-solid ratio and settling velocity at the pinch (critical) point, -* the cross-sectional area of the thickener, -* the depth of the clarification zone, -* the depth of the sedimentation zone or the required settling time (experimental). +Additionally, the user must provide 2 additional degrees of freedom regarding the design and/or operation of the thickener such as: + +* the cross-sectional area of the settler ``area``, +* the total volumetric flowrate or volume fraction of solids at the underflow, or +* the total volumetric flowrate or volume fraction of solids at the overflow. Model Structure --------------- -The ``Thickener0D`` model has the same structure as the :ref:`SLSeparator `. +The ``Thickener0D`` model contains two separators, ``solid_split`` and ``liquid_spit`` to separate the solid and liquid flows. The separations are assumed to be based on total flow with no change in composition, temperature or pressure. The ``Thickener0D`` model has 6 Ports (2 inlet and 4 outlet): + +* solid_inlet, +* liquid_inlet, +* solid_overflow, +* liquid_overflow, +* solid_underflow, +* liquid_underflow. Additional Constraints ---------------------- -The ``Thickener0D`` model adds the following additional constraints beyond those written by the :ref:`SLSeparator `. +The ``Thickener0D`` model adds the following constraints to calculate the split fractions of the solid and liquid stream. + +Total volumetric flowrates at the feed overflow and underflow are calculated as the sum of the volumetric flowrates of the solid and liquid streams at each location. + +.. math:: Q_{x,t} = Q_{solid,x,t} + Q_{liquid,x,t} + +where :math:`Q` represents volumetric flowrate and :math:`x` represents the feed, overflow or underflow point. -The cross-sectional area of the thickener is calculated using: +The flux density at the overflow and underflow are calculated using the following constraint: -.. math:: A = \frac{S_t}{\rho_{liq, t}} \times \frac{(Y_{pinch, t}-Y_{under, t})}{u_{pinch, t}} +If :math:`0 \leqslant \epsilon_{x,t} \leqslant \epsilon_{max}`: -where :math:`A` is the cross-section area of the thickener, :math:`S_t` is the mass flowrate of solids entering the thickener, :math:`\rho_{liq}` is the mass density of the liquid phase, :math:`Y_{pinch}` and :math:`Y_{under}` are the mass-based liquid-to-solid ratios at the pinch point and underflow respectively and :math:`u_{pinch}` is the sedimentation velocity of the suspension at the pinch point (Eqn 5.54, pg. 198 in [1]). +.. math:: F_{x,t} = v_0 \times \epsilon_{x, t} \times(1- \frac{\epsilon_{x,t}}{\epsilon_{max}})^C + v_1 \times \epsilon_{x,t}^2 \times(\epsilon_{max}-\epsilon_{x,t}) -The liquid-solid ratio at the underflow can be calculated using: +otherwise: -.. math:: Y_{under, t} = \frac{L_{under, t}}{S_t} +.. math:: F_{x,t} = 0 -where :math:`L_{under}` is the liquid mass flowrate at the underflow. +where :math:`F` is the flux density, :math:`v_0` is the Stokes velocity of a single particle, :math:`\epsilon` is the solid volume fraction, :math:`\epsilon_{max}` is the maximum attainable solid volume fraction, and :math:`v_1` and :math:`C` are empirical constants [2]. -The total height of the thickener is calculated using: +The solids fraction at the overflow and underflow is described using the following equation (derived from [1]). -.. math:: H = \frac{S_t \tau_{t}}{A\rho_{sol, t}} \times \left( 1+\frac{\rho_{sol}}{\rho_{liq}} \times Y_{avg} \right) + H_{clarified} +.. math:: Q_{feed, t} \times \epsilon_{feed,t} = A \times (F_{overflow,t} + F_{underflow,t}) - Q_{overflow,t} \times (\epsilon_{overflow,t} - \epsilon_{feed,t}) + Q_{underflow,t} \times (\epsilon_{underflow,t} - \epsilon_{feed,t}) -where :math:`H` is the total height of the thickener, :math:`H_{clarified}` is the height of the clarification zone, :math:`\tau` is the empirically measured settling time required to achieve the desired underflow conditions and :math:`Y_avg` is the average liquid-solid ratio in the thickener (assumed to be linear) (Eqn 5.55, pg. 198 in [1]). +where :math:`A` is the cross-sectional area of the thickener (assumed constant in space and time). + +Conservation of solids is enforced using the following constraint: + +.. math:: Q_{feed,t} \times \epsilon_{feed,t} = Q_{overflow,t} \times \epsilon_{overflow,t} + Q_{underflow,t} \times \epsilon_{underflow,t} + +Two constraints are written to define the solid volume fraction at the feed and underflow point (no constraint is written for the overflow point). + +.. math:: Q_{solid,x,t} = \epsilon_{x,t} \times (Q_{solid,x,t} + Q_{liquid,x,t}) + +The solids volume fraction at the overflow and underflow are bounded by the following inequality constraints: + +.. math:: \epsilon_{t,x} \leqslant \epsilon_{max} + +Finally, the Stokes velocity is calculated using Stokes Law: + +.. math:: 18 \times v0_t \times \mu_{liquid,t} = (\rho_{solid,t} - \rho_{liquid,t}) \times g \times d_p^2 + +where :math:`\mu_{liquid}` is the viscosity of the liquid phase, :math:`\rho_{liquid}` and :math:`\rho_{solid}` are the densities of the liquid and solid phases, :math:`g` is the acceleration due to gravity and :math:`d_p` is the solid particle diameter. Variables --------- -The ``Thickener0D`` adds the following variables in addition to those in the :ref:`SLSeparator `. - -===================== ======================== ====== ===================================================================== -Variable Name Index Notes -===================== ======================== ====== ===================================================================== -:math:`A` area None Cross-sectional area of thickener -:math:`H` height None Total height of thickener -:math:`H_{clarified}` height_clarified None Height of clarification zone in thickener (height above feed point) -:math:`u_{pinch}` settling_velocity_pinch time Settling velocity of suspension at pinch point -:math:`Y_{pinch}` liquid_solid_pinch time Liquid-solid ratio at pinch point -:math:`Y_{under}` liquid_solid_underflow time Liquid-solid ratio at underflow -:math:`\tau` settling_time time Settling time in thickener -===================== ======================== ====== ===================================================================== +The ``Thickener0D`` adds the following variables to those contained in the Separators. + +============================= ========================= ====== ======================================================== +Variable Name Index Notes +============================= ========================= ====== ======================================================== +:math:`A` area None Cross-sectional area of thickener +:math:`Q_{feed}` flow_vol_feed time Total volumetric flowrate at feed point +:math:`Q_{overflow}` flow_vol_overflow time Total volumetric flowrate at overflow point +:math:`Q_{underflow}` flow_vol_underflow time Total volumetric flowrate at underflow point +:math:`\epsilon_{feed}` solid_fraction_feed time Volumetric solids fraction at feed point +:math:`\epsilon_{overflow}` solid_fraction_overflow time Volumetric solids fraction at overflow point +:math:`\epsilon_{underflow}` solid_fraction_underflow time Volumetric solids fraction at underflow point +:math:`F_{overflow}` flux_density_overflow time Solids flux density at overflow point +:math:`F_{underflow}` flux_density_underflow time Solids flux density at underflow point +:math:`d_p` particle size time Solid particle size +:math:`v0` v0 time Stokes velocity of isolated particle +:math:`v1` v1 None Empirical parameter in settling velocity correlation +:math:`C` C None Empirical parameter in settling velocity correlation +math:`\epsilon_{max}` solid_fraction_max None Maximum achievable volumetric solids fraction +============================= ========================= ====== ======================================================== .. module:: idaes.models.unit_models.solid_liquid.thickener -SLSeparator Class +Thickener0D Class ----------------- .. autoclass:: Thickener0D :members: -SLSeparatorData Class +Thickener0DData Class --------------------- .. autoclass:: Thickener0DData diff --git a/idaes/models/unit_models/solid_liquid/tests/test_thickener.py b/idaes/models/unit_models/solid_liquid/tests/test_thickener.py index 96d5225de6..61e9ae4f73 100644 --- a/idaes/models/unit_models/solid_liquid/tests/test_thickener.py +++ b/idaes/models/unit_models/solid_liquid/tests/test_thickener.py @@ -14,7 +14,7 @@ Tests for thickener unit model. Authors: Andrew Lee """ - +from math import isnan import pytest from pyomo.environ import ( @@ -27,7 +27,6 @@ Var, ) from pyomo.network import Port -from pyomo.util.check_units import assert_units_consistent, assert_units_equivalent from idaes.core import ( FlowsheetBlock, @@ -39,44 +38,43 @@ StateBlockData, StateBlock, LiquidPhase, + SolidPhase, Component, ) from idaes.models.unit_models.solid_liquid import Thickener0D +from idaes.core.solvers import get_solver +from idaes.core.initialization import ( + BlockTriangularizationInitializer, + InitializationStatus, +) +from idaes.core.util import DiagnosticsToolbox from idaes.models.unit_models.separator import ( - Separator, SeparatorData, EnergySplittingType, ) from idaes.core.util.model_statistics import ( - degrees_of_freedom, number_variables, number_total_constraints, number_unused_variables, ) -from idaes.core.solvers import get_solver -from idaes.core.initialization import ( - BlockTriangularizationInitializer, - InitializationStatus, -) -from idaes.core.util import DiagnosticsToolbox # ----------------------------------------------------------------------------- # Get default solver for testing solver = get_solver() -@declare_process_block_class("TestParameterBlock") -class TestParameterData(PhysicalParameterBlock): +@declare_process_block_class("SolidParameterBlock") +class SolidParameterData(PhysicalParameterBlock): def build(self): """ Callable method for Block construction. """ super().build() - self._state_block_class = TestStateBlock + self._state_block_class = SolidStateBlock # Add Phase objects - self.Liq = LiquidPhase() + self.sol = SolidPhase() # Add Component objects self.a = Component() @@ -95,8 +93,8 @@ def define_metadata(cls, obj): ) -@declare_process_block_class("TestStateBlock", block_class=StateBlock) -class TestStateBlockData(StateBlockData): +@declare_process_block_class("SolidStateBlock", block_class=StateBlock) +class SolidStateBlockData(StateBlockData): def build(self): super().build() @@ -105,6 +103,10 @@ def build(self): initialize=1.0, units=units.kg / units.s, ) + self.flow_vol = Var( + initialize=1.0, + units=units.m**3 / units.s, + ) self.pressure = Var( initialize=101325.0, bounds=(1e3, 1e6), @@ -122,15 +124,19 @@ def build(self): ) self.dens_mass = Param( - initialize=1000, + initialize=2500, units=units.kg / units.m**3, ) if self.config.defined_state is False: - self.summ_mass_frac_eqn = Constraint( + self.sum_mass_frac_eqn = Constraint( expr=sum(self.mass_frac_comp[j] for j in self.component_list) == 1 ) + self.volumetric_flow = Constraint( + expr=self.flow_vol * self.dens_mass == self.flow_mass + ) + def get_material_flow_terms(b, p, j): return b.flow_mass * b.mass_frac_comp[j] @@ -154,61 +160,142 @@ def get_material_flow_basis(b): return MaterialFlowBasis.mass -# ----------------------------------------------------------------------------- -@pytest.mark.unit -def test_beta_logger(caplog): - m = ConcreteModel() - m.fs = FlowsheetBlock(dynamic=False) +@declare_process_block_class("LiquidParameterBlock") +class LiquidParameterData(PhysicalParameterBlock): + def build(self): + """ + Callable method for Block construction. + """ + super().build() + + self._state_block_class = LiquidStateBlock + + # Add Phase objects + self.liq = LiquidPhase() + + # Add Component objects + self.c = Component() + self.d = Component() + + @classmethod + def define_metadata(cls, obj): + obj.add_default_units( + { + "time": units.s, + "length": units.m, + "mass": units.kg, + "amount": units.mol, + "temperature": units.K, + } + ) + + +@declare_process_block_class("LiquidStateBlock", block_class=StateBlock) +class LiquidStateBlockData(StateBlockData): + def build(self): + super().build() + + # Create state variables + self.flow_mass = Var( + initialize=1.0, + units=units.kg / units.s, + ) + self.flow_vol = Var( + initialize=1.0, + units=units.m**3 / units.s, + ) + self.pressure = Var( + initialize=101325.0, + bounds=(1e3, 1e6), + units=units.Pa, + ) + self.temperature = Var( + initialize=298.15, + bounds=(298.15, 323.15), + units=units.K, + ) + self.mass_frac_comp = Var( + self.params.component_list, + initialize=0.1, + units=units.dimensionless, + ) + + self.dens_mass = Param( + initialize=1000, + units=units.kg / units.m**3, + ) + self.visc_d = Param( + initialize=1e-3, + units=units.Pa * units.s, + ) + + if self.config.defined_state is False: + self.sum_mass_frac_eqn = Constraint( + expr=sum(self.mass_frac_comp[j] for j in self.component_list) == 1 + ) + + self.volumetric_flow = Constraint( + expr=self.flow_vol * self.dens_mass == self.flow_mass + ) - m.fs.properties = TestParameterBlock() + def get_material_flow_terms(b, p, j): + return b.flow_mass * b.mass_frac_comp[j] + + def get_enthalpy_flow_terms(b, p): + return ( + b.flow_mass * 42e3 * units.J / units.kg * (b.temperature - 273.15 * units.K) + ) + + def default_material_balance_type(self): + return MaterialBalanceType.componentTotal - m.fs.unit = Thickener0D( - solid_property_package=m.fs.properties, - liquid_property_package=m.fs.properties, - ) - expected = ( - "The Thickener0D model is currently a beta capability and will " - "likely change in the next release as a more predictive version is " - "developed." - ) + def define_state_vars(b): + return { + "flow_mass": b.flow_mass, + "mass_frac_comp": b.mass_frac_comp, + "temperature": b.temperature, + "pressure": b.pressure, + } - assert expected in caplog.text + def get_material_flow_basis(b): + return MaterialFlowBasis.mass +# ----------------------------------------------------------------------------- class TestThickener0DBasic: @pytest.fixture(scope="class") def model(self): m = ConcreteModel() m.fs = FlowsheetBlock(dynamic=False) - m.fs.properties = TestParameterBlock() + m.fs.solid = SolidParameterBlock() + m.fs.liquid = LiquidParameterBlock() m.fs.unit = Thickener0D( - solid_property_package=m.fs.properties, - liquid_property_package=m.fs.properties, + solid_property_package=m.fs.solid, + liquid_property_package=m.fs.liquid, ) - m.fs.unit.solid_inlet.flow_mass.fix(1.33) + m.fs.unit.solid_inlet.flow_mass.fix(0.7 * 7e-6 * 2500) m.fs.unit.solid_inlet.mass_frac_comp[0, "a"].fix(0.2) m.fs.unit.solid_inlet.mass_frac_comp[0, "b"].fix(0.8) m.fs.unit.solid_inlet.temperature.fix(303.15) m.fs.unit.solid_inlet.pressure.fix(101325.0) - m.fs.unit.liquid_inlet.flow_mass.fix(6.65) - m.fs.unit.liquid_inlet.mass_frac_comp[0, "a"].fix(0.4) - m.fs.unit.liquid_inlet.mass_frac_comp[0, "b"].fix(0.6) - m.fs.unit.liquid_inlet.temperature.fix(320) - m.fs.unit.liquid_inlet.pressure.fix(2e5) + m.fs.unit.liquid_inlet.flow_mass.fix(0.3 * 7e-6 * 1000) + m.fs.unit.liquid_inlet.mass_frac_comp[0, "c"].fix(0.3) + m.fs.unit.liquid_inlet.mass_frac_comp[0, "d"].fix(0.7) + m.fs.unit.liquid_inlet.temperature.fix(310) + m.fs.unit.liquid_inlet.pressure.fix(1.5e5) - # Operating conditions based on Example 5.1 (pg 199) - # Coulson & Richardson's Chemical Engineering Vol 2 (4th Ed.) - m.fs.unit.liquid_recovery.fix(0.7) + m.fs.unit.flow_vol_underflow.fix(5e-6) - # Thickener specific parameters - m.fs.unit.height_clarification.fix(1.5) - m.fs.unit.settling_velocity_pinch.fix(0.94e-4) - m.fs.unit.liquid_solid_pinch.fix(3.7) - m.fs.unit.settling_time.fix(200) + # Parameters + m.fs.unit.area.fix(1) + m.fs.unit.v0.fix(1.18e-4) + m.fs.unit.v1.fix(1e-5) + m.fs.unit.C.fix(5) + m.fs.unit.solid_fraction_max.fix(0.9) return m @@ -231,89 +318,32 @@ def test_config(self, model): == MomentumBalanceType.pressureTotal ) - assert model.fs.unit.config.solid_property_package is model.fs.properties - assert model.fs.unit.config.liquid_property_package is model.fs.properties + assert model.fs.unit.config.solid_property_package is model.fs.solid + assert model.fs.unit.config.liquid_property_package is model.fs.liquid assert model.fs.unit.default_initializer is BlockTriangularizationInitializer @pytest.mark.build @pytest.mark.unit def test_build(self, model): - assert isinstance(model.fs.unit.solid_inlet, Port) - assert len(model.fs.unit.solid_inlet.vars) == 4 - assert hasattr(model.fs.unit.solid_inlet, "flow_mass") - assert hasattr(model.fs.unit.solid_inlet, "mass_frac_comp") - assert hasattr(model.fs.unit.solid_inlet, "temperature") - assert hasattr(model.fs.unit.solid_inlet, "pressure") - - assert isinstance(model.fs.unit.solid_outlet, Port) - assert len(model.fs.unit.solid_outlet.vars) == 4 - assert hasattr(model.fs.unit.solid_outlet, "flow_mass") - assert hasattr(model.fs.unit.solid_outlet, "mass_frac_comp") - assert hasattr(model.fs.unit.solid_outlet, "temperature") - assert hasattr(model.fs.unit.solid_outlet, "pressure") + assert isinstance(model.fs.unit.solid_underflow, Port) + assert isinstance(model.fs.unit.solid_overflow, Port) assert isinstance(model.fs.unit.liquid_inlet, Port) - assert len(model.fs.unit.liquid_inlet.vars) == 4 - assert hasattr(model.fs.unit.liquid_inlet, "flow_mass") - assert hasattr(model.fs.unit.liquid_inlet, "mass_frac_comp") - assert hasattr(model.fs.unit.liquid_inlet, "temperature") - assert hasattr(model.fs.unit.liquid_inlet, "pressure") - - assert isinstance(model.fs.unit.retained_liquid_outlet, Port) - assert len(model.fs.unit.retained_liquid_outlet.vars) == 4 - assert hasattr(model.fs.unit.retained_liquid_outlet, "flow_mass") - assert hasattr(model.fs.unit.retained_liquid_outlet, "mass_frac_comp") - assert hasattr(model.fs.unit.retained_liquid_outlet, "temperature") - assert hasattr(model.fs.unit.retained_liquid_outlet, "pressure") - - assert isinstance(model.fs.unit.recovered_liquid_outlet, Port) - assert len(model.fs.unit.recovered_liquid_outlet.vars) == 4 - assert hasattr(model.fs.unit.recovered_liquid_outlet, "flow_mass") - assert hasattr(model.fs.unit.recovered_liquid_outlet, "mass_frac_comp") - assert hasattr(model.fs.unit.recovered_liquid_outlet, "temperature") - assert hasattr(model.fs.unit.recovered_liquid_outlet, "pressure") - - assert isinstance(model.fs.unit.split, SeparatorData) - assert isinstance(model.fs.unit.liquid_recovery, Var) - - assert isinstance(model.fs.unit.area, Var) - assert isinstance(model.fs.unit.height, Var) - assert isinstance(model.fs.unit.height_clarification, Var) - assert isinstance(model.fs.unit.settling_velocity_pinch, Var) - assert isinstance(model.fs.unit.liquid_solid_pinch, Var) - assert isinstance(model.fs.unit.liquid_solid_underflow, Var) - assert isinstance(model.fs.unit.settling_time, Var) - - assert isinstance(model.fs.unit.underflow_sl_constraint, Constraint) - assert isinstance(model.fs.unit.cross_sectional_area_constraint, Constraint) - assert isinstance(model.fs.unit.height_constraint, Constraint) - - assert number_variables(model) == 29 - assert number_total_constraints(model) == 14 - # These are the solid properties, as they do not appear in constraints - assert number_unused_variables(model) == 4 - - @pytest.mark.component - def test_units(self, model): - assert_units_consistent(model) + assert isinstance(model.fs.unit.liquid_underflow, Port) + assert isinstance(model.fs.unit.liquid_overflow, Port) - assert_units_equivalent(model.fs.unit.height_clarification, units.m) - assert_units_equivalent( - model.fs.unit.settling_velocity_pinch, units.m / units.s - ) - assert_units_equivalent(model.fs.unit.settling_time, units.s) + assert isinstance(model.fs.unit.solid_split, SeparatorData) + assert isinstance(model.fs.unit.liquid_split, SeparatorData) - @pytest.mark.unit - def test_dof(self, model): - assert degrees_of_freedom(model) == 0 + assert number_variables(model) == 54 + assert number_total_constraints(model) == 40 + assert number_unused_variables(model) == 0 @pytest.mark.component def test_structural_issues(self, model): dt = DiagnosticsToolbox(model) - dt.report_structural_issues() - dt.display_underconstrained_set() dt.assert_no_structural_warnings() @pytest.mark.component @@ -343,65 +373,60 @@ def test_numerical_issues(self, model): @pytest.mark.skipif(solver is None, reason="Solver not available") @pytest.mark.component def test_solution(self, model): - # Solid outlet - assert pytest.approx(101325.0, rel=1e-8) == value( - model.fs.unit.solid_outlet.pressure[0] - ) - assert pytest.approx(303.15, rel=1e-8) == value( - model.fs.unit.solid_outlet.temperature[0] - ) - assert pytest.approx(0.2, rel=1e-8) == value( - model.fs.unit.solid_outlet.mass_frac_comp[0, "a"] - ) - assert pytest.approx(0.8, rel=1e-8) == value( - model.fs.unit.solid_outlet.mass_frac_comp[0, "b"] - ) - - assert pytest.approx(1.33, rel=1e-8) == value( - model.fs.unit.solid_outlet.flow_mass[0] - ) - - # Retained liquid - assert pytest.approx(2e5, rel=1e-8) == value( - model.fs.unit.retained_liquid_outlet.pressure[0] + assert value(model.fs.unit.solid_fraction_feed[0]) == pytest.approx( + 0.7, rel=1e-5 ) - assert pytest.approx(320, rel=1e-8) == value( - model.fs.unit.retained_liquid_outlet.temperature[0] + assert value(model.fs.unit.solid_fraction_underflow[0]) == pytest.approx( + 0.816954, rel=1e-5 ) - assert pytest.approx(0.4, rel=1e-8) == value( - model.fs.unit.retained_liquid_outlet.mass_frac_comp[0, "a"] + assert value(model.fs.unit.solid_fraction_overflow[0]) == pytest.approx( + 0.407615, rel=1e-5 ) - assert pytest.approx(0.6, rel=1e-8) == value( - model.fs.unit.retained_liquid_outlet.mass_frac_comp[0, "b"] - ) - assert pytest.approx(6.65 * 0.3, rel=1e-8) == value( - model.fs.unit.retained_liquid_outlet.flow_mass[0] + assert value(model.fs.unit.particle_size[0]) == pytest.approx( + 1.20163e-5, rel=1e-5 ) + so = model.fs.unit.solid_split.overflow_state[0].flow_vol + lo = model.fs.unit.liquid_split.overflow_state[0].flow_vol + su = model.fs.unit.solid_split.underflow_state[0].flow_vol + lu = model.fs.unit.liquid_split.underflow_state[0].flow_vol - # Recovered liquid - assert pytest.approx(2e5, rel=1e-8) == value( - model.fs.unit.recovered_liquid_outlet.pressure[0] - ) - assert pytest.approx(320, rel=1e-8) == value( - model.fs.unit.recovered_liquid_outlet.temperature[0] - ) - assert pytest.approx(0.4, rel=1e-8) == value( - model.fs.unit.recovered_liquid_outlet.mass_frac_comp[0, "a"] - ) - assert pytest.approx(0.6, rel=1e-8) == value( - model.fs.unit.recovered_liquid_outlet.mass_frac_comp[0, "b"] + assert value(su / (lu + su)) == pytest.approx( + value(model.fs.unit.solid_fraction_underflow[0]), rel=1e-5 ) - assert pytest.approx(6.65 * 0.7, rel=1e-8) == value( - model.fs.unit.recovered_liquid_outlet.flow_mass[0] + assert value(so / (lo + so)) == pytest.approx( + value(model.fs.unit.solid_fraction_overflow[0]), rel=1e-5 ) - # Thickener Vars - assert pytest.approx(1.5, rel=1e-6) == value( - model.fs.unit.liquid_solid_underflow[0] - ) - assert pytest.approx(31.12766, rel=1e-6) == value(model.fs.unit.area) - model.fs.unit.height.display() - assert pytest.approx(1.536318, rel=1e-6) == value(model.fs.unit.height) + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_conservation(self, model): + sf = model.fs.unit.solid_inlet_state[0] + lf = model.fs.unit.liquid_inlet_state[0] + so = model.fs.unit.solid_split.overflow_state[0] + lo = model.fs.unit.liquid_split.overflow_state[0] + su = model.fs.unit.solid_split.underflow_state[0] + lu = model.fs.unit.liquid_split.underflow_state[0] + + # Solid mass conservation + assert value(sf.flow_mass - so.flow_mass - su.flow_mass) <= 1e-8 + # Liquid mass conservation + assert value(lf.flow_mass - lo.flow_mass - lu.flow_mass) <= 1e-8 + + # Solid volume conservation + assert value(sf.flow_vol - so.flow_vol - su.flow_vol) <= 1e-8 + # Liquid volume conservation + assert value(lf.flow_vol - lo.flow_vol - lu.flow_vol) <= 1e-8 + + assert value(so.temperature) == pytest.approx(303.15, rel=1e-5) + assert value(su.temperature) == pytest.approx(303.15, rel=1e-5) + assert value(so.pressure) == pytest.approx(101325, rel=1e-5) + assert value(su.pressure) == pytest.approx(101325, rel=1e-5) + + assert value(lo.temperature) == pytest.approx(310, rel=1e-5) + assert value(lu.temperature) == pytest.approx(310, rel=1e-5) + assert value(lo.pressure) == pytest.approx(1.5e5, rel=1e-5) + assert value(lu.pressure) == pytest.approx(1.5e5, rel=1e-5) @pytest.mark.ui @pytest.mark.unit @@ -411,11 +436,109 @@ def test_get_performance_contents(self, model): assert perf_dict == { "vars": { "Area": model.fs.unit.area, - "Height": model.fs.unit.height, - "Liquid Recovery": model.fs.unit.liquid_recovery[0], - "Underflow L/S": model.fs.unit.liquid_solid_underflow[0], - "Pinch L/S": model.fs.unit.liquid_solid_pinch[0], - "Critical Settling Velocity": model.fs.unit.settling_velocity_pinch[0], - "Settling Time": model.fs.unit.settling_time[0], - } + "Liquid Recovery": model.fs.unit.liquid_split.split_fraction[ + 0, "overflow" + ], + "Feed Solid Fraction": model.fs.unit.solid_fraction_feed[0], + "Underflow Solid Fraction": model.fs.unit.solid_fraction_underflow[0], + "particle size": model.fs.unit.particle_size[0], + "v0": model.fs.unit.v0[0], + "v1": model.fs.unit.v1, + "C": model.fs.unit.C, + "solid_fraction_max": model.fs.unit.solid_fraction_max, + }, + } + + @pytest.mark.ui + @pytest.mark.component + def test_get_stream_table_contents(self, model): + stable = model.fs.unit._get_stream_table_contents() + + expected = { + "Units": { + "flow_mass": getattr(units.pint_registry, "kg/second"), + "mass_frac_comp a": getattr(units.pint_registry, "dimensionless"), + "mass_frac_comp b": getattr(units.pint_registry, "dimensionless"), + "temperature": getattr(units.pint_registry, "K"), + "pressure": getattr(units.pint_registry, "Pa"), + "mass_frac_comp c": getattr(units.pint_registry, "dimensionless"), + "mass_frac_comp d": getattr(units.pint_registry, "dimensionless"), + }, + "Feed Solid": { + "flow_mass": pytest.approx(0.01225, rel=1e-4), + "mass_frac_comp a": pytest.approx(0.2, rel=1e-4), + "mass_frac_comp b": pytest.approx(0.8, rel=1e-4), + "temperature": pytest.approx(303.15, rel=1e-4), + "pressure": pytest.approx(101325, rel=1e-4), + "mass_frac_comp c": float("nan"), + "mass_frac_comp d": float("nan"), + }, + "Feed Liquid": { + "flow_mass": pytest.approx(0.0021, rel=1e-4), + "mass_frac_comp a": float("nan"), + "mass_frac_comp b": float("nan"), + "temperature": pytest.approx(310, rel=1e-4), + "pressure": pytest.approx(1.5e5, rel=1e-4), + "mass_frac_comp c": pytest.approx(0.3, rel=1e-4), + "mass_frac_comp d": pytest.approx(0.7, rel=1e-4), + }, + "Underflow Solid": { + "flow_mass": pytest.approx(0.010212, rel=1e-4), + "mass_frac_comp a": pytest.approx(0.2, rel=1e-4), + "mass_frac_comp b": pytest.approx(0.8, rel=1e-4), + "temperature": pytest.approx(303.15, rel=1e-4), + "pressure": pytest.approx(101325, rel=1e-4), + "mass_frac_comp c": float("nan"), + "mass_frac_comp d": float("nan"), + }, + "Underflow Liquid": { + "flow_mass": pytest.approx(0.00091523, rel=1e-4), + "mass_frac_comp a": float("nan"), + "mass_frac_comp b": float("nan"), + "temperature": pytest.approx(310, rel=1e-4), + "pressure": pytest.approx(1.5e5, rel=1e-4), + "mass_frac_comp c": pytest.approx(0.3, rel=1e-4), + "mass_frac_comp d": pytest.approx(0.7, rel=1e-4), + }, + "Overflow Solid": { + "flow_mass": pytest.approx(0.0020381, rel=1e-4), + "mass_frac_comp a": pytest.approx(0.2, rel=1e-4), + "mass_frac_comp b": pytest.approx(0.8, rel=1e-4), + "temperature": pytest.approx(303.15, rel=1e-4), + "pressure": pytest.approx(101325, rel=1e-4), + "mass_frac_comp c": float("nan"), + "mass_frac_comp d": float("nan"), + }, + "Overflow Liquid": { + "flow_mass": pytest.approx(0.0011848, rel=1e-4), + "mass_frac_comp a": float("nan"), + "mass_frac_comp b": float("nan"), + "temperature": pytest.approx(310, rel=1e-4), + "pressure": pytest.approx(1.5e5, rel=1e-4), + "mass_frac_comp c": pytest.approx(0.3, rel=1e-4), + "mass_frac_comp d": pytest.approx(0.7, rel=1e-4), + }, } + + stb = stable.to_dict() + for k, d in stb.items(): + if k == "Units": + assert d == expected["Units"] + else: + for i, dd in d.items(): + if not isnan(dd): + assert dd == expected[k][i] + else: + if "Liquid" in k: + assert i in ["mass_frac_comp a", "mass_frac_comp b"] + else: + assert i in ["mass_frac_comp c", "mass_frac_comp d"] + + @pytest.mark.unit + def test_deprecate_initialize(self, model): + with pytest.raises( + NotImplementedError, + match="The Thickener0D unit model does not support the old initialization API. " + "Please use the new API \(InitializerObjects\) instead.", + ): + model.fs.unit.initialize() diff --git a/idaes/models/unit_models/solid_liquid/thickener.py b/idaes/models/unit_models/solid_liquid/thickener.py index 3d86aa204a..3af0f24b1e 100644 --- a/idaes/models/unit_models/solid_liquid/thickener.py +++ b/idaes/models/unit_models/solid_liquid/thickener.py @@ -13,23 +13,46 @@ """ Thickener unit model. -This model extends the SLSeparator unit model by adding constraints that relate -area and vessel height to the liquid recovery fraction. +Unit model is derived from: + +R. Burger, F. Concha, K.H. Karlsen, A. Narvaez, +Numerical simulation of clarifier-thickener units treating ideal +suspensions with a flux density function having two inflection points, +Mathematical and Computer Modelling 44 (2006) 255–275 +doi:10.1016/j.mcm.2005.11.008 + +Settling velocity function from: + +N.G. Barton, C.-H. Li, S.J. Spencer, Control of a surface of discontinuity in continuous thickeners, +Journal of the Australian Mathematical Society Series B 33 (1992) 269–289 """ # Import Python libraries import logging +from pandas import DataFrame # Import Pyomo libraries -from pyomo.environ import Var, units +from pyomo.environ import Expr_if, inequality, units, Var +from pyomo.common.config import ConfigBlock, ConfigValue, In +from pyomo.network import Port # Import IDAES cores from idaes.core import ( declare_process_block_class, + MaterialBalanceType, + MomentumBalanceType, + UnitModelBlockData, + useDefault, ) -from idaes.models.unit_models.solid_liquid.sl_separator import ( - SLSeparatorData, +from idaes.models.unit_models.separator import ( + Separator, + SplittingType, + EnergySplittingType, ) +from idaes.core.initialization import BlockTriangularizationInitializer +from idaes.core.util.config import is_physical_parameter_block +from idaes.core.util.units_of_measurement import report_quantity +from idaes.core.util.constants import Constants as CONST __author__ = "Andrew Lee" @@ -40,13 +63,138 @@ @declare_process_block_class("Thickener0D") -class Thickener0DData(SLSeparatorData): +class Thickener0DData(UnitModelBlockData): """ Thickener0D Unit Model Class """ - CONFIG = SLSeparatorData.CONFIG() - # TODO: Method to calculate pinch settling velocity + CONFIG = ConfigBlock() + CONFIG.declare( + "dynamic", + ConfigValue( + domain=In([False]), + default=False, + description="Dynamic model flag - must be False", + doc="""Indicates whether this model will be dynamic or not, + **default** = False. Flash units do not support dynamic behavior.""", + ), + ) + CONFIG.declare( + "has_holdup", + ConfigValue( + default=False, + domain=In([False]), + description="Holdup construction flag - must be False", + doc="""Indicates whether holdup terms should be constructed or not. + **default** - False. Flash units do not have defined volume, thus + this must be False.""", + ), + ) + CONFIG.declare( + "material_balance_type", + ConfigValue( + default=MaterialBalanceType.useDefault, + domain=In(MaterialBalanceType), + description="Material balance construction flag", + doc="""Indicates what type of mass balance should be constructed, + **default** - MaterialBalanceType.useDefault. + **Valid values:** { + **MaterialBalanceType.useDefault - refer to property package for default + balance type + **MaterialBalanceType.none** - exclude material balances, + **MaterialBalanceType.componentPhase** - use phase component balances, + **MaterialBalanceType.componentTotal** - use total component balances, + **MaterialBalanceType.elementTotal** - use total element balances, + **MaterialBalanceType.total** - use total material balance.}""", + ), + ) + CONFIG.declare( + "momentum_balance_type", + ConfigValue( + default=MomentumBalanceType.pressureTotal, + domain=In(MomentumBalanceType), + description="Momentum balance construction flag", + doc="""Indicates what type of momentum balance should be constructed, + **default** - MomentumBalanceType.pressureTotal. + **Valid values:** { + **MomentumBalanceType.none** - exclude momentum balances, + **MomentumBalanceType.pressureTotal** - single pressure balance for material, + **MomentumBalanceType.pressurePhase** - pressure balances for each phase, + **MomentumBalanceType.momentumTotal** - single momentum balance for material, + **MomentumBalanceType.momentumPhase** - momentum balances for each phase.}""", + ), + ) + CONFIG.declare( + "energy_split_basis", + ConfigValue( + default=EnergySplittingType.equal_temperature, + domain=EnergySplittingType, + description="Type of constraint to write for energy splitting", + doc="""Argument indicating basis to use for splitting energy this is + not used for when ideal_separation == True. + **default** - EnergySplittingType.equal_temperature. + **Valid values:** { + **EnergySplittingType.equal_temperature** - outlet temperatures equal inlet + **EnergySplittingType.equal_molar_enthalpy** - outlet molar enthalpies equal + inlet, + **EnergySplittingType.enthalpy_split** - apply split fractions to enthalpy + flows.}""", + ), + ) + CONFIG.declare( + "solid_property_package", + ConfigValue( + default=useDefault, + domain=is_physical_parameter_block, + description="Property package to use for solid phase", + doc="""Property parameter object used to define solid phase property + calculations, + **default** - useDefault. + **Valid values:** { + **useDefault** - use default package from parent model or flowsheet, + **PropertyParameterObject** - a PropertyParameterBlock object.}""", + ), + ) + CONFIG.declare( + "solid_property_package_args", + ConfigBlock( + implicit=True, + description="Arguments to use for constructing solid phase property packages", + doc="""A ConfigBlock with arguments to be passed to a solid phase property + block(s) and used when constructing these, + **default** - None. + **Valid values:** { + see property package for documentation.}""", + ), + ) + CONFIG.declare( + "liquid_property_package", + ConfigValue( + default=useDefault, + domain=is_physical_parameter_block, + description="Property package to use for liquid phase", + doc="""Property parameter object used to define liquid phase property + calculations, + **default** - useDefault. + **Valid values:** { + **useDefault** - use default package from parent model or flowsheet, + **PropertyParameterObject** - a PropertyParameterBlock object.}""", + ), + ) + CONFIG.declare( + "liquid_property_package_args", + ConfigBlock( + implicit=True, + description="Arguments to use for constructing liquid phase property packages", + doc="""A ConfigBlock with arguments to be passed to a liquid phase property + block(s) and used when constructing these, + **default** - None. + **Valid values:** { + see property package for documentation.}""", + ), + ) + + default_initializer = BlockTriangularizationInitializer def build(self): """ @@ -58,118 +206,339 @@ def build(self): Returns: None """ - logger.warning( - "The Thickener0D model is currently a beta capability and will " - "likely change in the next release as a more predictive version is " - "developed." - ) # Call super().build to setup dynamics super().build() + # Build Solid Phase + # Setup StateBlock argument dict + tmp_dict = dict(**self.config.solid_property_package_args) + tmp_dict["has_phase_equilibrium"] = False + tmp_dict["defined_state"] = True + + self.solid_inlet_state = self.config.solid_property_package.build_state_block( + self.flowsheet().time, doc="Solid properties in separator", **tmp_dict + ) + + # Add solid splitter + self.solid_split = Separator( + property_package=self.config.solid_property_package, + property_package_args=self.config.solid_property_package_args, + outlet_list=["underflow", "overflow"], + split_basis=SplittingType.totalFlow, + ideal_separation=False, + mixed_state_block=self.solid_inlet_state, + has_phase_equilibrium=False, + material_balance_type=self.config.material_balance_type, + momentum_balance_type=self.config.momentum_balance_type, + energy_split_basis=self.config.energy_split_basis, + ) + + # Add solid ports + self.add_port( + name="solid_inlet", + block=self.solid_inlet_state, + doc="Solid inlet to thickener", + ) + self.solid_underflow = Port(extends=self.solid_split.underflow) + self.solid_overflow = Port(extends=self.solid_split.overflow) + + # Build liquid Phase + # Setup StateBlock argument dict + tmp_dict = dict(**self.config.liquid_property_package_args) + tmp_dict["has_phase_equilibrium"] = False + tmp_dict["defined_state"] = True + + self.liquid_inlet_state = self.config.liquid_property_package.build_state_block( + self.flowsheet().time, doc="liquid properties in separator", **tmp_dict + ) + + # Add liquid splitter + self.liquid_split = Separator( + property_package=self.config.liquid_property_package, + property_package_args=self.config.liquid_property_package_args, + outlet_list=["underflow", "overflow"], + split_basis=SplittingType.totalFlow, + ideal_separation=False, + mixed_state_block=self.liquid_inlet_state, + has_phase_equilibrium=False, + material_balance_type=self.config.material_balance_type, + momentum_balance_type=self.config.momentum_balance_type, + energy_split_basis=self.config.energy_split_basis, + ) + + # Add liquid ports + self.add_port( + name="liquid_inlet", + block=self.liquid_inlet_state, + doc="liquid inlet to thickener", + ) + self.liquid_underflow = Port(extends=self.liquid_split.underflow) + self.liquid_overflow = Port(extends=self.liquid_split.overflow) + # Add additional variables and constraints - s_metadata = self.solid_state.params.get_metadata() - # TODO: Add support for molar basis + uom = self.solid_inlet_state.params.get_metadata().derived_units self.area = Var( initialize=1, - units=s_metadata.get_derived_units("area"), + units=uom.AREA, doc="Cross sectional area of thickener", ) - self.height = Var( - initialize=1, - units=s_metadata.get_derived_units("length"), - doc="Total depth of thickener", + # Volumetric Flowrates + self.flow_vol_feed = Var( + self.flowsheet().time, + initialize=0.7, + units=uom.FLOW_VOL, + bounds=(0, None), + doc="Total volumetric flowrate of feed", ) - - self.height_clarification = Var( - initialize=1, - units=s_metadata.get_derived_units("length"), - doc="Depth of clarification zone", + self.flow_vol_overflow = Var( + self.flowsheet().time, + initialize=0.7, + units=uom.FLOW_VOL, + bounds=(0, None), + doc="Total volumetric flowrate of overflow", ) - - self.settling_velocity_pinch = Var( + self.flow_vol_underflow = Var( self.flowsheet().time, - initialize=1, - units=s_metadata.get_derived_units("velocity"), - doc="Settling velocity of suspension at pinch point", + initialize=0.7, + units=uom.FLOW_VOL, + bounds=(0, None), + doc="Total volumetric flowrate of underflow", ) - self.liquid_solid_pinch = Var( + # Solid Fractions + self.solid_fraction_feed = Var( self.flowsheet().time, - initialize=0.2, + initialize=0.7, units=units.dimensionless, - doc="Liquid-solid ratio (mass basis) at pinch point", + bounds=(0, None), + doc="Volume fraction of solids in feed", ) - - self.liquid_solid_underflow = Var( + self.solid_fraction_underflow = Var( self.flowsheet().time, - initialize=0.2, + initialize=0.7, units=units.dimensionless, - doc="Liquid-solid ratio (mass basis) at underflow", + bounds=(0, None), + doc="Volume fraction of solids in underflow", + ) + self.solid_fraction_overflow = Var( + self.flowsheet().time, + initialize=0.7, + units=units.dimensionless, + bounds=(0, None), + doc="Volume fraction of solids in overflow", ) - self.settling_time = Var( + # Flux densities + self.flux_density_underflow = Var( self.flowsheet().time, - initialize=1, - units=s_metadata.get_derived_units("time"), - doc="Settling time of suspension", + initialize=0, + units=uom.VELOCITY, + doc="Kynch flux density in underflow", + ) + self.flux_density_overflow = Var( + self.flowsheet().time, + initialize=0, + units=uom.VELOCITY, + doc="Kynch flux density in overflow", ) - @self.Constraint( - self.flowsheet().time, doc="Constraint to calculate L/S ratio at underflow" + # Parameters + self.particle_size = Var( + self.flowsheet().time, + initialize=1e-5, + units=uom.LENGTH, + doc="Characteristic length of particle", ) - def underflow_sl_constraint(b, t): - return b.liquid_solid_underflow[t] * b.solid_state[ - t - ].flow_mass == units.convert( - b.split.retained_state[t].flow_mass, - to_units=s_metadata.get_derived_units("flow_mass"), + self.v0 = Var( + self.flowsheet().time, + initialize=1e-4, + units=uom.VELOCITY, + doc="Stokes velocity of individual particle", + ) + self.v1 = Var( + initialize=1e-5, + units=uom.VELOCITY, + doc="Superficial velocity of a Darcy type flow of liquid", + ) + self.C = Var( + initialize=5, + units=units.dimensionless, + bounds=(0, None), + doc="Settling velocity exponent", + ) + self.solid_fraction_max = Var( + initialize=0.9, + units=units.dimensionless, + bounds=(0, 1), + doc="Maximum achievable solids volume fraction", + ) + + # --------------------------------------------------------------------------------------------- + # Constraints + @self.Constraint(self.flowsheet().time) + def feed_flowrate(b, t): + return b.flow_vol_feed[t] == ( + b.solid_inlet_state[t].flow_vol + + units.convert(b.liquid_inlet_state[t].flow_vol, to_units=uom.FLOW_VOL) ) - @self.Constraint( - self.flowsheet().time, doc="Constraint to estimate cross-sectional area" - ) - def cross_sectional_area_constraint(b, t): - return b.area * b.liquid_inlet_state[ - t - ].dens_mass * b.settling_velocity_pinch[t] == b.solid_state[t].flow_mass * ( - b.liquid_solid_pinch[t] - b.liquid_solid_underflow[t] + @self.Constraint(self.flowsheet().time) + def overflow_flowrate(b, t): + return b.flow_vol_overflow[t] == ( + b.solid_split.overflow_state[t].flow_vol + + units.convert( + b.liquid_split.overflow_state[t].flow_vol, to_units=uom.FLOW_VOL + ) ) - @self.Constraint( - self.flowsheet().time, doc="Constraint to estimate height of thickener" - ) - def height_constraint(b, t): - return (b.height - b.height_clarification) * b.area * b.solid_state[ - t - ].dens_mass == units.convert( - b.settling_time[t] + @self.Constraint(self.flowsheet().time) + def underflow_flowrate(b, t): + return b.flow_vol_underflow[t] == ( + b.solid_split.underflow_state[t].flow_vol + + units.convert( + b.liquid_split.underflow_state[t].flow_vol, to_units=uom.FLOW_VOL + ) + ) + + # Eqn 2.8 from [1] + @self.Constraint(self.flowsheet().time) + def flux_density_function_overflow(b, t): + u = b.solid_fraction_overflow + return b.flux_density_overflow[t] == Expr_if( + IF=inequality(0, u[t], b.solid_fraction_max), + THEN=b.v0[t] * u[t] * (1 - u[t] / b.solid_fraction_max) ** b.C + + b.v1 * u[t] ** 2 * (b.solid_fraction_max - u[t]), + ELSE=0 * units.m * units.s**-1, + ) + + # Eqn 2.8 from [1] + @self.Constraint(self.flowsheet().time) + def flux_density_function_underflow(b, t): + u = b.solid_fraction_underflow + return b.flux_density_underflow[t] == Expr_if( + IF=inequality(0, u[t], b.solid_fraction_max), + THEN=b.v0[t] * u[t] * (1 - u[t] / b.solid_fraction_max) ** b.C + + b.v1 * u[t] ** 2 * (b.solid_fraction_max - u[t]), + ELSE=0 * units.m * units.s**-1, + ) + + # Modified from 2.23 and 2.33 from [1] + @self.Constraint(self.flowsheet().time) + def solids_continuity(b, t): + return b.flow_vol_feed[t] * b.solid_fraction_feed[t] == ( + b.area * (b.flux_density_overflow[t] + b.flux_density_underflow[t]) + - b.flow_vol_overflow[t] + * (b.solid_fraction_overflow[t] - b.solid_fraction_feed[t]) + + b.flow_vol_underflow[t] + * (b.solid_fraction_underflow[t] - b.solid_fraction_feed[t]) + ) + + @self.Constraint(self.flowsheet().time) + def solids_conservation(b, t): + return b.flow_vol_feed[t] * b.solid_fraction_feed[t] == ( + +b.flow_vol_overflow[t] * b.solid_fraction_overflow[t] + + b.flow_vol_underflow[t] * b.solid_fraction_underflow[t] + ) + + @self.Constraint(self.flowsheet().time) + def maximum_underflow_volume_fraction(b, t): + return b.solid_fraction_underflow[t] <= b.solid_fraction_max + + @self.Constraint(self.flowsheet().time) + def maximum_overflow_volume_fraction(b, t): + return b.solid_fraction_overflow[t] <= b.solid_fraction_max + + @self.Constraint(self.flowsheet().time) + def inlet_volume_fraction(b, t): + return b.solid_inlet_state[t].flow_vol == ( + b.solid_fraction_feed[t] * ( - b.solid_state[t].flow_mass + b.solid_inlet_state[t].flow_vol + units.convert( - b.solid_state[t].dens_mass / b.liquid_inlet_state[t].dens_mass, - to_units=units.dimensionless, + b.liquid_inlet_state[t].flow_vol, to_units=uom.FLOW_VOL ) - * ( - b.liquid_inlet_state[t].flow_mass - + b.split.retained_state[t].flow_mass + ) + ) + + @self.Constraint(self.flowsheet().time) + def underflow_volume_fraction(b, t): + return b.solid_split.underflow_state[t].flow_vol == ( + b.solid_fraction_underflow[t] + * ( + b.solid_split.underflow_state[t].flow_vol + + units.convert( + b.liquid_split.underflow_state[t].flow_vol, + to_units=uom.FLOW_VOL, ) - / 2 - ), - to_units=s_metadata.get_derived_units("mass"), + ) ) - def _get_performance_contents(self, time_point=0): + @self.Constraint(self.flowsheet().time) + def stokes_law(b, t): + # Assuming constant properties, source from feed states + return 18 * b.v0[t] * units.convert( + b.liquid_inlet_state[t].visc_d, to_units=uom.DYNAMIC_VISCOSITY + ) == ( + ( + b.solid_inlet_state[t].dens_mass + - units.convert( + b.liquid_inlet_state[t].dens_mass, to_units=uom.DENSITY_MASS + ) + ) + * units.convert(CONST.acceleration_gravity, to_units=uom.ACCELERATION) + * b.particle_size[t] ** 2 + ) + def _get_performance_contents(self, time_point=0): return { "vars": { "Area": self.area, - "Height": self.height, - "Liquid Recovery": self.liquid_recovery[time_point], - "Underflow L/S": self.liquid_solid_underflow[time_point], - "Pinch L/S": self.liquid_solid_pinch[time_point], - "Critical Settling Velocity": self.settling_velocity_pinch[time_point], - "Settling Time": self.settling_time[time_point], - } + "Liquid Recovery": self.liquid_split.split_fraction[ + time_point, "overflow" + ], + "Feed Solid Fraction": self.solid_fraction_feed[time_point], + "Underflow Solid Fraction": self.solid_fraction_underflow[time_point], + "particle size": self.particle_size[time_point], + "v0": self.v0[time_point], + "v1": self.v1, + "C": self.C, + "solid_fraction_max": self.solid_fraction_max, + }, + } + + def _get_stream_table_contents(self, time_point=0): + stream_attributes = {} + stream_attributes["Units"] = {} + + sblocks = { + "Feed Solid": self.solid_inlet_state, + "Feed Liquid": self.liquid_inlet_state, + "Underflow Solid": self.solid_split.underflow_state, + "Underflow Liquid": self.liquid_split.underflow_state, + "Overflow Solid": self.solid_split.overflow_state, + "Overflow Liquid": self.liquid_split.overflow_state, } + + for n, v in sblocks.items(): + dvars = v[time_point].define_display_vars() + + stream_attributes[n] = {} + + for k in dvars: + for i in dvars[k].keys(): + stream_key = k if i is None else f"{k} {i}" + + quant = report_quantity(dvars[k][i]) + + stream_attributes[n][stream_key] = quant.m + stream_attributes["Units"][stream_key] = quant.u + + return DataFrame.from_dict(stream_attributes, orient="columns") + + def initialize(self, **kwargs): + raise NotImplementedError( + "The Thickener0D unit model does not support the old initialization API. " + "Please use the new API (InitializerObjects) instead." + ) From 6538f374b62096ef87a2ed47051a587daabd9aba Mon Sep 17 00:00:00 2001 From: Ludovico Bianchi Date: Thu, 11 Jan 2024 14:40:18 -0600 Subject: [PATCH 08/12] Fix misspellings detected by typos v1.17 (#1316) --- idaes/core/util/utility_minimization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idaes/core/util/utility_minimization.py b/idaes/core/util/utility_minimization.py index 54a4042f27..4fe060f0aa 100644 --- a/idaes/core/util/utility_minimization.py +++ b/idaes/core/util/utility_minimization.py @@ -659,7 +659,7 @@ def unique(list1): Returns: unique_list """ - # intilize a null list + # initialize a null list unique_list = [] # traverse for all elements From e875d80ace7c4709085d3fa77cc5ac6f6c158dd0 Mon Sep 17 00:00:00 2001 From: Ludovico Bianchi Date: Thu, 18 Jan 2024 18:42:29 -0600 Subject: [PATCH 09/12] Add XFAIL marker to temporarily bypass #1317 known failures (#1319) * Add XFAIL marker to bypass #1317 known failures * Apply xfail marker to correct test --- idaes/core/util/tests/test_model_diagnostics.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 3ea6c2e339..4cfce273f2 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -1798,6 +1798,10 @@ def test_identify_candidates(self, model): model.con5: 1e-05, } + @pytest.mark.xfail( + reason="Known failure. See IDAES/idaes-pse#1317 for details", + strict=True, + ) @pytest.mark.solver @pytest.mark.component def test_solve_candidates_milp(self, model, scip_solver): @@ -1847,6 +1851,7 @@ def test_solve_ids_milp(self, model): model.con5: -1, } + # TODO does this test function have the exact same name as the one above? @pytest.mark.solver @pytest.mark.component def test_solve_ids_milp(self, model, scip_solver): From d81fb5f3e75299a0d7db98f94c9b687718683a91 Mon Sep 17 00:00:00 2001 From: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> Date: Fri, 19 Jan 2024 21:01:13 -0700 Subject: [PATCH 10/12] Add preferred IDAES citation (#1318) * Add citation information to repo/online docs * Add number and pages --------- Co-authored-by: Ludovico Bianchi --- CITATION.cff | 45 +++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 6 ++++++ 2 files changed, 51 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000..6af267310c --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,45 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: + - name: "IDAES contributors" +title: "Institute for Design of Advanced Energy Systems (IDAES): advanced computational algorithms to enable the design and optimization of complex, interacting energy and process systems from individual plant components to the entire electrical grid" +url: "https://github.com/IDAES/idaes-pse" +license-url: "https://github.com/IDAES/idaes-pse/blob/main/LICENSE.md" +preferred-citation: + type: article + authors: + - family-names: "Lee" + given-names: "Andrew" + - family-names: "Ghouse" + given-names: "Jaffer H" + - family-names: "Eslick" + given-names: "John C" + - family-names: "Laird" + given-names: "Carl D" + - family-names: "Siirola" + given-names: "John D" + - family-names: "Zamarripa" + given-names: "Miguel A" + - family-names: "Gunter" + given-names: "Dan" + - family-names: "Shinn" + given-names: "John H" + - family-names: "Dowling" + given-names: "Alexander W" + - family-names: "Bhattacharyya" + given-names: "Debangsu" + - family-names: "Biegler" + given-names: "Lorenz T" + - family-names: "Burgard" + given-names: "Anthony P" + - family-names: "Miller" + given-names: "David C" + title: "The IDAES process modeling framework and model library—Flexibility for process simulation and optimization" + journal: "Journal of Advanced Manufacturing and Processing" + publisher: "Wiley Online Library" + volume: 3 + number: 3 + pages: e10095 + year: 2021 + doi: "doi/10.1002/amp2.10095" + diff --git a/docs/index.rst b/docs/index.rst index f0294d470f..eb53437d50 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,6 +74,12 @@ The IDAES team is comprised of collaborators from the following institutions: * University of Notre Dame * Georgia Tech +Citing IDAES +------------ +If you use IDAES software for your research, please cite us: + +Lee, Andrew, Jaffer H. Ghouse, John C. Eslick, Carl D. Laird, John D. Siirola, Miguel A. Zamarripa, Dan Gunter et al. "The IDAES process modeling framework and model library—Flexibility for process simulation and optimization." Journal of Advanced Manufacturing and Processing 3, no. 3 (2021): e10095. https://doi.org/10.1002/amp2.10095 + Contact us ----------- General, background and overview information is available at the `IDAES website`_. From 690eb5eeecaf3f10523ecbdd221486ea4c14670e Mon Sep 17 00:00:00 2001 From: Ludovico Bianchi Date: Tue, 23 Jan 2024 12:47:47 -0600 Subject: [PATCH 11/12] Remove workaround for tests affected by #1317 (#1320) * Empty commit to test * Remove xfail marker --- idaes/core/util/tests/test_model_diagnostics.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 4cfce273f2..30c9e812fc 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -1798,10 +1798,6 @@ def test_identify_candidates(self, model): model.con5: 1e-05, } - @pytest.mark.xfail( - reason="Known failure. See IDAES/idaes-pse#1317 for details", - strict=True, - ) @pytest.mark.solver @pytest.mark.component def test_solve_candidates_milp(self, model, scip_solver): From f07552d54800fbd7603e22b30f4e97b6e3406067 Mon Sep 17 00:00:00 2001 From: dallan-keylogic <88728506+dallan-keylogic@users.noreply.github.com> Date: Tue, 23 Jan 2024 15:00:58 -0500 Subject: [PATCH 12/12] Fixes error pointed out by Jinliang in SOC interpolation function (#1314) * Fixes error pointed out by Jinliang in interpolation function * add note * fix typo --------- Co-authored-by: Ludovico Bianchi --- .../power_generation/unit_models/soc_submodels/common.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/idaes/models_extra/power_generation/unit_models/soc_submodels/common.py b/idaes/models_extra/power_generation/unit_models/soc_submodels/common.py index fe0736b67f..4dc4fb66d5 100644 --- a/idaes/models_extra/power_generation/unit_models/soc_submodels/common.py +++ b/idaes/models_extra/power_generation/unit_models/soc_submodels/common.py @@ -182,11 +182,12 @@ def _interpolate_2D( phi_bound_1: expression for the value of the quantity to be interpolated at the 1 bound derivative: If True estimate derivative - method: interpolation method currently only CDS is supported Returns: expression for phi at face """ + # TODO add tests to ensure this function works as designed + # Also, user beware, use of CV_Bound enums is untested icu = ic - 1 icd = ic if ic == ifaces.first(): @@ -203,7 +204,7 @@ def _interpolate_2D( if not derivative: cf = faces.at(ic) lambf = (cd - cf) / (cd - cu) - return (1 - lambf) * phi_func(icu) + lambf * phi_func(icd) + return (1 - lambf) * phi_func(icd) + lambf * phi_func(icu) else: # Since we are doing linear interpolation derivative is the slope # between node centers even if they are not evenly spaced