def define_default_scaling_factors(b): """ Method to set default scaling factors for the property package. Scaling factors are based on the default initial value for each variable provided in the state_bounds config argument. """ # Get bounds and initial values from config args units = b.get_metadata().derived_units state_bounds = b.config.state_bounds if state_bounds is None: return try: f_bounds = state_bounds["flow_mol"] if len(f_bounds) == 4: f_init = pyunits.convert_value(f_bounds[1], from_units=f_bounds[3], to_units=units["flow_mole"]) else: f_init = f_bounds[1] except KeyError: f_init = 1 try: p_bounds = state_bounds["pressure"] if len(p_bounds) == 4: p_init = pyunits.convert_value(p_bounds[1], from_units=p_bounds[3], to_units=units["pressure"]) else: p_init = p_bounds[1] except KeyError: p_init = 1 try: t_bounds = state_bounds["temperature"] if len(t_bounds) == 4: t_init = pyunits.convert_value(t_bounds[1], from_units=t_bounds[3], to_units=units["temperature"]) else: t_init = t_bounds[1] except KeyError: t_init = 1 # Set default scaling factors b.set_default_scaling("flow_mol", 1 / f_init) b.set_default_scaling("flow_mol_phase", 1 / f_init) b.set_default_scaling("flow_mol_comp", 1 / f_init) b.set_default_scaling("flow_mol_phase_comp", 1 / f_init) b.set_default_scaling("pressure", 1 / p_init) b.set_default_scaling("temperature", 1 / t_init)
def get_bounds_from_config(b, state, base_units): """ Method to take a 3- or 4-tuple state definition config argument and return tuples for the bounds and default value of the Var object. Expects the form (lower, default, upper, units) where units is optional Args: b - StateBlock on which the state vars are to be constructed state - name of state var as a string (to be matched with config dict) base_units - base units of state var to be used if conversion required Returns: bounds - 2-tuple of state var bounds in base units default_val - default value of state var in base units """ try: var_config = b.params.config.state_bounds[state] except (KeyError, TypeError): # State definition missing return (None, None), None if len(var_config) == 4: # Units provided, need to convert values bounds = (pyunits.convert_value(var_config[0], from_units=var_config[3], to_units=base_units), pyunits.convert_value(var_config[2], from_units=var_config[3], to_units=base_units)) default_val = pyunits.convert_value(var_config[1], from_units=var_config[3], to_units=base_units) else: bounds = (var_config[0], var_config[2]) default_val = var_config[1] return bounds, default_val
def test_conservation(self, btx): assert abs(value(btx.fs.unit.inlet_1.flow_mol[0] - btx.fs.unit.outlet_1.flow_mol[0])) <= 1e-6 assert abs(value(btx.fs.unit.inlet_2.flow_mol[0] - btx.fs.unit.outlet_2.flow_mol[0])) <= 1e-6 shell = value( btx.fs.unit.outlet_1.flow_mol[0] * (btx.fs.unit.shell.properties_in[0].enth_mol - btx.fs.unit.shell.properties_out[0].enth_mol)) tube = pyunits.convert_value(value( btx.fs.unit.outlet_2.flow_mol[0] * (btx.fs.unit.tube.properties_in[0].enth_mol - btx.fs.unit.tube.properties_out[0].enth_mol)), from_units=pyunits.kJ/pyunits.s, to_units=pyunits.J/pyunits.s) assert abs(shell + tube) <= 1e-6
def build(self): super(ComponentData, self).build() # If the component_list does not exist, add reference to new Component # The IF is mostly for backwards compatability, to allow for old-style # property packages where the component_list already exists but we # need to add new Component objects if not self.config._component_list_exists: if not self.config._electrolyte: self.__add_to_component_list() else: self._add_to_electrolyte_component_list() base_units = self.parent_block().get_metadata().default_units if isinstance(base_units["mass"], _PyomoUnit): # Backwards compatability check p_units = (base_units["mass"] / base_units["length"] / base_units["time"]**2) else: # Backwards compatability check p_units = None # Create Param for molecular weight if provided if "mw" in self.config.parameter_data: if isinstance(self.config.parameter_data["mw"], tuple): mw_init = pyunits.convert_value( self.config.parameter_data["mw"][0], from_units=self.config.parameter_data["mw"][1], to_units=base_units["mass"] / base_units["amount"]) else: _log.debug("{} no units provided for parameter mw - assuming " "default units".format(self.name)) mw_init = self.config.parameter_data["mw"] self.mw = Param(initialize=mw_init, units=base_units["mass"] / base_units["amount"]) # Create Vars for common parameters param_dict = { "pressure_crit": p_units, "temperature_crit": base_units["temperature"], "omega": None } for p, u in param_dict.items(): if p in self.config.parameter_data: self.add_component(p, Var(units=u)) set_param_from_config(self, p)
def set_param_value(b, param, units, config=None, index=None): """ Utility method to set parameter value from a config block. This allows for converting units if required. This method directly sets the value of the parameter. Args: b - block on which parameter and config block are defined param - name of parameter as str. Used to find param and config arg units - units of param object (used if conversion required) config - (optional) config block to get parameter data from. If unset, assumes b.config. index - (optional) used for pure component properties where a single property may have multiple parameters associated with it. Returns: None """ if config is None: config = b.config if index is None: param_obj = getattr(b, param) p_data = config.parameter_data[param] else: param_obj = getattr(b, param+"_"+index) p_data = config.parameter_data[param][index] if isinstance(p_data, tuple): if units is None and p_data[1] is None: param_obj.value = p_data[0] else: param_obj.value = pyunits.convert_value( p_data[0], from_units=p_data[1], to_units=units) else: _log.debug("{} no units provided for parameter {} - assuming default " "units".format(b.name, param)) param_obj.value = p_data
def build(self): # build always starts by calling super().build() # This triggers a lot of boilerplate in the background for you super().build() # this creates blank scaling factors, which are populated later self.scaling_factor = Suffix(direction=Suffix.EXPORT) # Next, get the base units of measurement from the property definition units_meta = self.config.property_package.get_metadata().get_derived_units # check the optional config arg 'chemical_additives' common_msg = "The 'chemical_additives' dict MUST contain a dict of 'parameter_data' for " + \ "each chemical name. That 'parameter_data' dict MUST contain 'mw_chem', " + \ "'moles_salt_per_mole_additive', and 'mw_salt' as keys. Users are also " + \ "required to provide the values for the molecular weights and the units " + \ "within a tuple arg. Example format provided below.\n\n" + \ "{'chem_name_1': \n" + \ " {'parameter_data': \n" + \ " {'mw_additive': (value, units), \n" + \ " 'moles_salt_per_mole_additive': value, \n" + \ " 'mw_salt': (value, units)} \n" + \ " }, \n" + \ "}\n\n" mw_adds = {} mw_salts = {} molar_rat = {} for j in self.config.chemical_additives: if type(self.config.chemical_additives[j]) != dict: raise ConfigurationError("\n Did not provide a 'dict' for chemical \n" + common_msg) if 'parameter_data' not in self.config.chemical_additives[j]: raise ConfigurationError("\n Did not provide a 'parameter_data' for chemical \n" + common_msg) if 'mw_additive' not in self.config.chemical_additives[j]['parameter_data']: raise ConfigurationError("\n Did not provide a 'mw_additive' for chemical \n" + common_msg) if 'moles_salt_per_mole_additive' not in self.config.chemical_additives[j]['parameter_data']: raise ConfigurationError("\n Did not provide a 'moles_salt_per_mole_additive' for chemical \n" + common_msg) if 'mw_salt' not in self.config.chemical_additives[j]['parameter_data']: raise ConfigurationError("\n Did not provide a 'mw_salt' for chemical \n" + common_msg) if type(self.config.chemical_additives[j]['parameter_data']['mw_additive']) != tuple: raise ConfigurationError("\n Did not provide a tuple for 'mw_additive' \n" + common_msg) if type(self.config.chemical_additives[j]['parameter_data']['mw_salt']) != tuple: raise ConfigurationError("\n Did not provide a tuple for 'mw_salt' \n" + common_msg) if not isinstance(self.config.chemical_additives[j]['parameter_data']['moles_salt_per_mole_additive'], (int,float)): raise ConfigurationError("\n Did not provide a number for 'moles_salt_per_mole_additive' \n" + common_msg) #Populate temp dicts for parameter and variable setting mw_adds[j] = pyunits.convert_value(self.config.chemical_additives[j]['parameter_data']['mw_additive'][0], from_units=self.config.chemical_additives[j]['parameter_data']['mw_additive'][1], to_units=pyunits.kg/pyunits.mol) mw_salts[j] = pyunits.convert_value(self.config.chemical_additives[j]['parameter_data']['mw_salt'][0], from_units=self.config.chemical_additives[j]['parameter_data']['mw_salt'][1], to_units=pyunits.kg/pyunits.mol) molar_rat[j] = self.config.chemical_additives[j]['parameter_data']['moles_salt_per_mole_additive'] # Add unit variables # Linear relationship between TSS (mg/L) and Turbidity (NTU) # TSS (mg/L) = Turbidity (NTU) * slope + intercept # Default values come from the following paper: # H. Rugner, M. Schwientek,B. Beckingham, B. Kuch, P. Grathwohl, # Environ. Earth Sci. 69 (2013) 373-380. DOI: 10.1007/s12665-013-2307-1 self.slope = Var( self.flowsheet().config.time, initialize=1.86, bounds=(1e-8, 10), domain=NonNegativeReals, units=pyunits.mg/pyunits.L, doc='Slope relation between TSS (mg/L) and Turbidity (NTU)') self.intercept = Var( self.flowsheet().config.time, initialize=0, bounds=(0, 10), domain=NonNegativeReals, units=pyunits.mg/pyunits.L, doc='Intercept relation between TSS (mg/L) and Turbidity (NTU)') self.initial_turbidity_ntu = Var( self.flowsheet().config.time, initialize=50, bounds=(0, 10000), domain=NonNegativeReals, units=pyunits.dimensionless, doc='Initial measured Turbidity (NTU) from Jar Test') self.final_turbidity_ntu = Var( self.flowsheet().config.time, initialize=1, bounds=(0, 10000), domain=NonNegativeReals, units=pyunits.dimensionless, doc='Final measured Turbidity (NTU) from Jar Test') self.chemical_doses = Var( self.flowsheet().config.time, self.config.chemical_additives.keys(), initialize=0, bounds=(0, 100), domain=NonNegativeReals, units=pyunits.mg/pyunits.L, doc='Dosages of the set of chemical additives') self.chemical_mw = Param( self.config.chemical_additives.keys(), mutable=True, initialize=mw_adds, domain=NonNegativeReals, units=pyunits.kg/pyunits.mol, doc='Molecular weights of the set of chemical additives') self.salt_mw = Param( self.config.chemical_additives.keys(), mutable=True, initialize=mw_salts, domain=NonNegativeReals, units=pyunits.kg/pyunits.mol, doc='Molecular weights of the produced salts from chemical additives') self.salt_from_additive_mole_ratio = Param( self.config.chemical_additives.keys(), mutable=True, initialize=molar_rat, domain=NonNegativeReals, units=pyunits.mol/pyunits.mol, doc='Moles of the produced salts from 1 mole of chemical additives') # Build control volume for feed side self.control_volume = ControlVolume0DBlock(default={ "dynamic": False, "has_holdup": False, "property_package": self.config.property_package, "property_package_args": self.config.property_package_args}) self.control_volume.add_state_blocks( has_phase_equilibrium=False) self.control_volume.add_material_balances( balance_type=self.config.material_balance_type, has_mass_transfer=True) # NOTE: This checks for if an energy_balance_type is defined if hasattr(self.config, "energy_balance_type"): self.control_volume.add_energy_balances( balance_type=self.config.energy_balance_type, has_enthalpy_transfer=False) self.control_volume.add_momentum_balances( balance_type=self.config.momentum_balance_type, has_pressure_change=False) # Add ports self.add_inlet_port(name='inlet', block=self.control_volume) self.add_outlet_port(name='outlet', block=self.control_volume) # Check _phase_component_set for required items if ('Liq', 'TDS') not in self.config.property_package._phase_component_set: raise ConfigurationError( "Coagulation-Flocculation model MUST contain ('Liq','TDS') as a component, but " "the property package has only specified the following components {}" .format([p for p in self.config.property_package._phase_component_set])) if ('Liq', 'Sludge') not in self.config.property_package._phase_component_set: raise ConfigurationError( "Coagulation-Flocculation model MUST contain ('Liq','Sludge') as a component, but " "the property package has only specified the following components {}" .format([p for p in self.config.property_package._phase_component_set])) if ('Liq', 'TSS') not in self.config.property_package._phase_component_set: raise ConfigurationError( "Coagulation-Flocculation model MUST contain ('Liq','TSS') as a component, but " "the property package has only specified the following components {}" .format([p for p in self.config.property_package._phase_component_set])) # -------- Add constraints --------- # Adds isothermal constraint if no energy balance present if not hasattr(self.config, "energy_balance_type"): @self.Constraint(self.flowsheet().config.time, doc="Isothermal condition") def eq_isothermal(self, t): return (self.control_volume.properties_out[t].temperature == self.control_volume.properties_in[t].temperature) # Constraint for tss loss rate based on measured final turbidity self.tss_loss_rate = Var( self.flowsheet().config.time, initialize=1, bounds=(0, 100), domain=NonNegativeReals, units=units_meta('mass')*units_meta('time')**-1, doc='Mass per time loss rate of TSS based on the measured final turbidity') @self.Constraint(self.flowsheet().config.time, doc="Constraint for the loss rate of TSS to be used in mass_transfer_term") def eq_tss_loss_rate(self, t): tss_out = pyunits.convert(self.slope[t]*self.final_turbidity_ntu[t] + self.intercept[t], to_units=units_meta('mass')*units_meta('length')**-3) input_rate = self.control_volume.properties_in[t].flow_mass_phase_comp['Liq','TSS'] exit_rate = self.control_volume.properties_out[t].flow_vol_phase['Liq']*tss_out return (self.tss_loss_rate[t] == input_rate - exit_rate) # Constraint for tds gain rate based on 'chemical_doses' and 'chemical_additives' if self.config.chemical_additives: self.tds_gain_rate = Var( self.flowsheet().config.time, initialize=0, bounds=(0, 100), domain=NonNegativeReals, units=units_meta('mass')*units_meta('time')**-1, doc='Mass per time gain rate of TDS based on the chemicals added for coagulation') @self.Constraint(self.flowsheet().config.time, doc="Constraint for the loss rate of TSS to be used in mass_transfer_term") def eq_tds_gain_rate(self, t): sum = 0 for j in self.config.chemical_additives.keys(): chem_dose = pyunits.convert(self.chemical_doses[t, j], to_units=units_meta('mass')*units_meta('length')**-3) chem_dose = chem_dose/self.chemical_mw[j] * \ self.salt_from_additive_mole_ratio[j] * \ self.salt_mw[j]*self.control_volume.properties_out[t].flow_vol_phase['Liq'] sum = sum+chem_dose return (self.tds_gain_rate[t] == sum) # Add constraints for mass transfer terms @self.Constraint(self.flowsheet().config.time, self.config.property_package.phase_list, self.config.property_package.component_list, doc="Mass transfer term") def eq_mass_transfer_term(self, t, p, j): if (p, j) == ('Liq', 'TSS'): return self.control_volume.mass_transfer_term[t, p, j] == -self.tss_loss_rate[t] elif (p, j) == ('Liq', 'Sludge'): return self.control_volume.mass_transfer_term[t, p, j] == self.tss_loss_rate[t] elif (p, j) == ('Liq', 'TDS'): if self.config.chemical_additives: return self.control_volume.mass_transfer_term[t, p, j] == self.tds_gain_rate[t] else: return self.control_volume.mass_transfer_term[t, p, j] == 0.0 else: return self.control_volume.mass_transfer_term[t, p, j] == 0.0
def initialize( self, state_args_1=None, state_args_2=None, outlvl=idaeslog.NOTSET, solver="ipopt", optarg={"tol": 1e-6}, duty=None, ): """ Heat exchanger initialization method. Args: state_args_1 : a dict of arguments to be passed to the property initialization for the hot side (see documentation of the specific property package) (default = {}). state_args_2 : a dict of arguments to be passed to the property initialization for the cold side (see documentation of the specific property package) (default = {}). outlvl : sets output level of initialization routine optarg : solver options dictionary object (default={'tol': 1e-6}) solver : str indicating which solver to use during initialization (default = 'ipopt') duty : an initial guess for the amount of heat transfered. This should be a tuple in the form (value, units), (default = (1000 J/s)) Returns: None """ # Set solver options init_log = idaeslog.getInitLogger(self.name, outlvl, tag="unit") solve_log = idaeslog.getSolveLogger(self.name, outlvl, tag="unit") hot_side = getattr(self, self.config.hot_side_name) cold_side = getattr(self, self.config.cold_side_name) opt = SolverFactory(solver) opt.options = optarg flags1 = hot_side.initialize(outlvl=outlvl, optarg=optarg, solver=solver, state_args=state_args_1) init_log.info_high("Initialization Step 1a (hot side) Complete.") flags2 = cold_side.initialize(outlvl=outlvl, optarg=optarg, solver=solver, state_args=state_args_2) init_log.info_high("Initialization Step 1b (cold side) Complete.") # --------------------------------------------------------------------- # Solve unit without heat transfer equation # if costing block exists, deactivate if hasattr(self, "costing"): self.costing.deactivate() self.heat_transfer_equation.deactivate() # Get side 1 and side 2 heat units, and convert duty as needed s1_units = hot_side.heat.get_units() s2_units = cold_side.heat.get_units() if duty is None: # Assume 1000 J/s and check for unitless properties if s1_units is None and s2_units is None: # Backwards compatability for unitless properties s1_duty = -1000 s2_duty = 1000 else: s1_duty = pyunits.convert_value(-1000, from_units=pyunits.W, to_units=s1_units) s2_duty = pyunits.convert_value(1000, from_units=pyunits.W, to_units=s2_units) else: # Duty provided with explicit units s1_duty = -pyunits.convert_value( duty[0], from_units=duty[1], to_units=s1_units) s2_duty = pyunits.convert_value(duty[0], from_units=duty[1], to_units=s2_units) cold_side.heat.fix(s2_duty) for i in hot_side.heat: hot_side.heat[i].value = s1_duty with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: res = opt.solve(self, tee=slc.tee) init_log.info_high("Initialization Step 2 {}.".format( idaeslog.condition(res))) cold_side.heat.unfix() self.heat_transfer_equation.activate() # --------------------------------------------------------------------- # Solve unit with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: res = opt.solve(self, tee=slc.tee) init_log.info_high("Initialization Step 3 {}.".format( idaeslog.condition(res))) # --------------------------------------------------------------------- # Release Inlet state hot_side.release_state(flags1, outlvl=outlvl) cold_side.release_state(flags2, outlvl=outlvl) init_log.info("Initialization Completed, {}".format( idaeslog.condition(res))) # if costing block exists, activate and initialize if hasattr(self, "costing"): self.costing.activate() costing.initialize(self.costing)
def initialize( self, hot_side_state_args=None, cold_side_state_args=None, outlvl=idaeslog.NOTSET, solver=None, optarg=None, duty=None, ): """ Heat exchanger initialization method. Args: hot_side_state_args : a dict of arguments to be passed to the property initialization for the hot side (see documentation of the specific property package) (default = None). cold_side_state_args : a dict of arguments to be passed to the property initialization for the cold side (see documentation of the specific property package) (default = None). outlvl : sets output level of initialization routine optarg : solver options dictionary object (default=None, use default solver options) solver : str indicating which solver to use during initialization (default = None, use default solver) duty : an initial guess for the amount of heat transfered. This should be a tuple in the form (value, units), (default = (1000 J/s)) Returns: None """ # Set solver options init_log = idaeslog.getInitLogger(self.name, outlvl, tag="unit") solve_log = idaeslog.getSolveLogger(self.name, outlvl, tag="unit") hot_side = self.hot_side cold_side = self.cold_side # Create solver opt = get_solver(solver, optarg) flags1 = hot_side.initialize(outlvl=outlvl, optarg=optarg, solver=solver, state_args=hot_side_state_args) init_log.info_high("Initialization Step 1a (hot side) Complete.") flags2 = cold_side.initialize(outlvl=outlvl, optarg=optarg, solver=solver, state_args=cold_side_state_args) init_log.info_high("Initialization Step 1b (cold side) Complete.") # --------------------------------------------------------------------- # Solve unit without heat transfer equation # if costing block exists, deactivate if hasattr(self, "costing"): self.costing.deactivate() self.energy_balance_constraint.deactivate() self.effectiveness_correlation.deactivate() self.effectiveness.fix(0.68) # Get side 1 and side 2 heat units, and convert duty as needed s1_units = hot_side.heat.get_units() s2_units = cold_side.heat.get_units() if duty is None: # Assume 1000 J/s and check for unitless properties if s1_units is None and s2_units is None: # Backwards compatability for unitless properties s1_duty = -1000 s2_duty = 1000 else: s1_duty = pyunits.convert_value(-1000, from_units=pyunits.W, to_units=s1_units) s2_duty = pyunits.convert_value(1000, from_units=pyunits.W, to_units=s2_units) else: # Duty provided with explicit units s1_duty = -pyunits.convert_value( duty[0], from_units=duty[1], to_units=s1_units) s2_duty = pyunits.convert_value(duty[0], from_units=duty[1], to_units=s2_units) cold_side.heat.fix(s2_duty) for i in hot_side.heat: hot_side.heat[i].value = s1_duty with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: res = opt.solve(self, tee=slc.tee) init_log.info_high("Initialization Step 2 {}.".format( idaeslog.condition(res))) cold_side.heat.unfix() self.energy_balance_constraint.activate() for t in self.effectiveness: calculate_variable_from_constraint( self.effectiveness[t], self.effectiveness_correlation[t]) # --------------------------------------------------------------------- # Solve unit with new effectiveness factor with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: res = opt.solve(self, tee=slc.tee) init_log.info_high("Initialization Step 3 {}.".format( idaeslog.condition(res))) self.effectiveness_correlation.activate() self.effectiveness.unfix() # --------------------------------------------------------------------- # Final solve of full modelr with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: res = opt.solve(self, tee=slc.tee) init_log.info_high("Initialization Step 4 {}.".format( idaeslog.condition(res))) # --------------------------------------------------------------------- # Release Inlet state hot_side.release_state(flags1, outlvl=outlvl) cold_side.release_state(flags2, outlvl=outlvl) init_log.info("Initialization Completed, {}".format( idaeslog.condition(res))) # if costing block exists, activate and initialize if hasattr(self, "costing"): self.costing.activate() costing.initialize(self.costing)
def build(self): # build always starts by calling super().build() # This triggers a lot of boilerplate in the background for you super().build() # this creates blank scaling factors, which are populated later self.scaling_factor = Suffix(direction=Suffix.EXPORT) # Next, get the base units of measurement from the property definition units_meta = self.config.property_package.get_metadata( ).get_derived_units # Check configs for errors common_msg = ( "The 'chemical_mapping_data' dict MUST contain a dict of names that map \n" + "to each chemical name in the property package for boron and borate. \n" + "Optionally, user may provide names that also map to protons and hydroxide, \n" + "as well as to the cation from the caustic additive. The 'caustic_additive' \n" + "must be a dict that contains molecular weight and charge.\n\n" + "Example:\n" + "-------\n" + "{'boron_name': 'B[OH]3', #[is required]\n" + " 'borate_name': 'B[OH]4_-', #[is required]\n" + " 'proton_name': 'H_+', #[is OPTIONAL]\n" + " 'hydroxide_name': 'OH_-', #[is OPTIONAL]\n" + " 'caustic_additive': \n" + " {'additive_name': 'NaOH', #[is OPTIONAL]\n" + " 'cation_name': 'Na_+', #[is required]\n" + " 'mw_additive': (40, pyunits.g/pyunits.mol), #[is required]\n" + " 'moles_cation_per_additive': 1, #[is required]\n" + " }, \n" + "}\n\n") if (type(self.config.chemical_mapping_data) != dict or self.config.chemical_mapping_data == {}): raise ConfigurationError( "\n\n Did not provide a 'dict' for 'chemical_mapping_data' \n" + common_msg) if ("boron_name" not in self.config.chemical_mapping_data or "borate_name" not in self.config.chemical_mapping_data or "caustic_additive" not in self.config.chemical_mapping_data): raise ConfigurationError( "\n\n Missing some required information in 'chemical_mapping_data' \n" + common_msg) if ("mw_additive" not in self.config.chemical_mapping_data["caustic_additive"] or "moles_cation_per_additive" not in self.config.chemical_mapping_data["caustic_additive"] or "cation_name" not in self.config.chemical_mapping_data["caustic_additive"]): raise ConfigurationError( "\n\n Missing some required information in 'chemical_mapping_data' \n" + common_msg) if (type(self.config.chemical_mapping_data["caustic_additive"] ["mw_additive"]) != tuple): raise ConfigurationError( "\n Did not provide a tuple for 'mw_additive' \n" + common_msg) # Assign name IDs locally for reference later when building constraints self.boron_name_id = self.config.chemical_mapping_data["boron_name"] self.borate_name_id = self.config.chemical_mapping_data["borate_name"] if "proton_name" in self.config.chemical_mapping_data: self.proton_name_id = self.config.chemical_mapping_data[ "proton_name"] else: self.proton_name_id = None if "hydroxide_name" in self.config.chemical_mapping_data: self.hydroxide_name_id = self.config.chemical_mapping_data[ "hydroxide_name"] else: self.hydroxide_name_id = None if "cation_name" in self.config.chemical_mapping_data[ "caustic_additive"]: self.cation_name_id = self.config.chemical_mapping_data[ "caustic_additive"]["cation_name"] else: self.cation_name_id = None if "additive_name" in self.config.chemical_mapping_data[ "caustic_additive"]: self.caustic_chem_name = self.config.chemical_mapping_data[ "caustic_additive"]["additive_name"] else: self.caustic_chem_name = None # Cross reference and check given names with set of valid names if self.boron_name_id not in self.config.property_package.component_list: raise ConfigurationError( "\n Given 'boron_name' {" + self.boron_name_id + "} does not match " + "any species name from the property package \n{}".format( [c for c in self.config.property_package.component_list])) if self.borate_name_id not in self.config.property_package.component_list: raise ConfigurationError( "\n Given 'borate_name' {" + self.borate_name_id + "} does not match " + "any species name from the property package \n{}".format( [c for c in self.config.property_package.component_list])) if self.proton_name_id != None: if self.proton_name_id not in self.config.property_package.component_list: raise ConfigurationError( "\n Given 'proton_name' {" + self.proton_name_id + "} does not match " + "any species name from the property package \n{}".format([ c for c in self.config.property_package.component_list ])) if self.hydroxide_name_id != None: if (self.hydroxide_name_id not in self.config.property_package.component_list): raise ConfigurationError( "\n Given 'hydroxide_name' {" + self.hydroxide_name_id + "} does not match " + "any species name from the property package \n{}".format([ c for c in self.config.property_package.component_list ])) if self.cation_name_id != None: if self.cation_name_id not in self.config.property_package.component_list: raise ConfigurationError( "\n Given 'cation_name' {" + self.cation_name_id + "} does not match " + "any species name from the property package \n{}".format([ c for c in self.config.property_package.component_list ])) # check for existence of inherent reactions # This is to ensure that no degeneracy could be introduced # in the system of equations (may not need this explicit check) if hasattr(self.config.property_package, "inherent_reaction_idx"): raise ConfigurationError( "\n Property Package CANNOT contain 'inherent_reactions' \n") # cation set reference cation_set = self.config.property_package.cation_set # anion set reference anion_set = self.config.property_package.anion_set # Add param to store all charges of ions for convenience self.ion_charge = Param( anion_set | cation_set, initialize=1, mutable=True, units=pyunits.dimensionless, doc="Ion charge", ) # Loop through full set and try to assign charge for j in self.config.property_package.component_list: if j in anion_set or j in cation_set: self.ion_charge[ j] = self.config.property_package.get_component( j).config.charge # Add unit variables and parameters mw_add = pyunits.convert_value( self.config.chemical_mapping_data["caustic_additive"] ["mw_additive"][0], from_units=self.config.chemical_mapping_data["caustic_additive"] ["mw_additive"][1], to_units=pyunits.kg / pyunits.mol, ) self.caustic_mw = Param( mutable=True, initialize=mw_add, domain=NonNegativeReals, units=pyunits.kg / pyunits.mol, doc="Molecular weight of the caustic additive", ) self.additive_molar_ratio = Param( mutable=True, initialize=self.config.chemical_mapping_data["caustic_additive"] ["moles_cation_per_additive"], domain=NonNegativeReals, units=pyunits.dimensionless, doc="Moles of cation per moles of caustic additive", ) self.caustic_dose_rate = Var( self.flowsheet().config.time, initialize=0, bounds=(0, None), domain=NonNegativeReals, units=pyunits.kg / pyunits.s, doc="Dosage rate of the set of caustic additive", ) # Reaction parameters self.Kw_0 = Param( mutable=True, initialize=60.91, domain=NonNegativeReals, units=pyunits.mol**2 / pyunits.m**6, doc="Water dissociation pre-exponential constant", ) self.dH_w = Param( mutable=True, initialize=55830, domain=NonNegativeReals, units=pyunits.J / pyunits.mol, doc="Water dissociation enthalpy", ) self.Ka_0 = Param( mutable=True, initialize=0.000163, domain=NonNegativeReals, units=pyunits.mol / pyunits.m**3, doc="Boron dissociation pre-exponential constant", ) self.dH_a = Param( mutable=True, initialize=13830, domain=NonNegativeReals, units=pyunits.J / pyunits.mol, doc="Boron dissociation enthalpy", ) # molarity vars (for approximate boron speciation) # Used to establish the mass transfer rates by # first solving a coupled equilibrium system # # ENE: [H+] = [OH-] + [A-] + (Alk - n*[base]) # MB: TB = [HA] + [A-] # rw: Kw = [H+][OH-] # ra: Ka[HA] = [H+][A-] # # Alk = sum(n*Anions) - sum(n*Cations) (from props) # [base] = (Dose/MW) # TB = [HA]_inlet + [A-]_inlet (from props) # NOTE: These variables are internal to the unit model # and are used to establish what the mass transfer # constraints need to be in order to achieve a specific # pH and boron speciation at the exit of the unit. self.conc_mol_H = Var( self.flowsheet().config.time, initialize=1e-4, bounds=(0, None), domain=NonNegativeReals, units=pyunits.mol / pyunits.m**3, doc="Resulting molarity of protons", ) self.conc_mol_OH = Var( self.flowsheet().config.time, initialize=1e-4, bounds=(0, None), domain=NonNegativeReals, units=pyunits.mol / pyunits.m**3, doc="Resulting molarity of hydroxide", ) self.conc_mol_Boron = Var( self.flowsheet().config.time, initialize=1e-2, bounds=(0, None), domain=NonNegativeReals, units=pyunits.mol / pyunits.m**3, doc="Resulting molarity of Boron", ) self.conc_mol_Borate = Var( self.flowsheet().config.time, initialize=1e-2, bounds=(0, None), domain=NonNegativeReals, units=pyunits.mol / pyunits.m**3, doc="Resulting molarity of Borate", ) # Variables for volume and retention time self.reactor_volume = Var( initialize=1, bounds=(0, None), domain=NonNegativeReals, units=pyunits.m**3, doc="Volume of the reactor", ) self.reactor_retention_time = Var( self.flowsheet().config.time, initialize=500, bounds=(0, None), domain=NonNegativeReals, units=pyunits.s, doc="Hydraulic retention time of the reactor", ) # Build control volume for feed side self.control_volume = ControlVolume0DBlock( default={ "dynamic": False, "has_holdup": False, "property_package": self.config.property_package, "property_package_args": self.config.property_package_args, "reaction_package": None, "reaction_package_args": None, }) self.control_volume.add_state_blocks(has_phase_equilibrium=False) self.control_volume.add_material_balances( balance_type=self.config.material_balance_type, has_mass_transfer=True, has_rate_reactions=False, has_equilibrium_reactions=False, ) # NOTE: This checks for if an energy_balance_type is defined if hasattr(self.config, "energy_balance_type"): self.control_volume.add_energy_balances( balance_type=self.config.energy_balance_type, has_enthalpy_transfer=False, ) self.control_volume.add_momentum_balances( balance_type=self.config.momentum_balance_type, has_pressure_change=False) # Add ports self.add_inlet_port(name="inlet", block=self.control_volume) self.add_outlet_port(name="outlet", block=self.control_volume) # -------- Add constraints --------- # Adds isothermal constraint if no energy balance present if not hasattr(self.config, "energy_balance_type"): @self.Constraint(self.flowsheet().config.time, doc="Isothermal condition") def eq_isothermal(self, t): return (self.control_volume.properties_out[t].temperature == self.control_volume.properties_in[t].temperature) # Constraints for volume and retention time @self.Constraint( self.flowsheet().config.time, doc="Reactor volume constraint", ) def eq_reactor_volume(self, t): Q = pyunits.convert( self.control_volume.properties_out[t].flow_vol_phase["Liq"], to_units=pyunits.m**3 / pyunits.s, ) return self.reactor_volume == Q * self.reactor_retention_time[t] # Constraints for mass transfer terms @self.Constraint( self.flowsheet().config.time, doc="Electroneutrality condition", ) def eq_electroneutrality(self, t): ResIons = 0 for j in self.ion_charge: conc = self.control_volume.properties_out[ t].conc_mol_phase_comp["Liq", j] if (j == self.boron_name_id or j == self.borate_name_id or j == self.proton_name_id or j == self.hydroxide_name_id): ResIons += 0.0 else: ResIons += -self.ion_charge[j] * conc conc_mol_H = pyunits.convert( self.conc_mol_H[t], to_units=units_meta("amount") * units_meta("length")**-3, ) conc_mol_OH = pyunits.convert( self.conc_mol_OH[t], to_units=units_meta("amount") * units_meta("length")**-3, ) conc_mol_Borate = pyunits.convert( self.conc_mol_Borate[t], to_units=units_meta("amount") * units_meta("length")**-3, ) return conc_mol_H == conc_mol_OH + conc_mol_Borate + ResIons @self.Constraint( self.flowsheet().config.time, doc="Total boron balance", ) def eq_total_boron(self, t): inlet_Boron = self.control_volume.properties_in[ t].conc_mol_phase_comp["Liq", self.boron_name_id] inlet_Borate = self.control_volume.properties_in[ t].conc_mol_phase_comp["Liq", self.borate_name_id] conc_mol_Borate = pyunits.convert( self.conc_mol_Borate[t], to_units=units_meta("amount") * units_meta("length")**-3, ) conc_mol_Boron = pyunits.convert( self.conc_mol_Boron[t], to_units=units_meta("amount") * units_meta("length")**-3, ) return inlet_Boron + inlet_Borate == conc_mol_Borate + conc_mol_Boron @self.Constraint( self.flowsheet().config.time, doc="Water dissociation", ) def eq_water_dissociation(self, t): return (self.Kw_0 * exp(-self.dH_w / Constants.gas_constant / self.control_volume.properties_out[t].temperature) ) == self.conc_mol_H[t] * self.conc_mol_OH[t] @self.Constraint( self.flowsheet().config.time, doc="Boron dissociation", ) def eq_boron_dissociation(self, t): return (self.Ka_0 * exp(-self.dH_a / Constants.gas_constant / self.control_volume.properties_out[t].temperature) ) * self.conc_mol_Boron[t] == self.conc_mol_H[ t] * self.conc_mol_Borate[t] # Add constraints for mass transfer terms @self.Constraint( self.flowsheet().config.time, self.config.property_package.phase_list, self.config.property_package.component_list, doc="Mass transfer term", ) def eq_mass_transfer_term(self, t, p, j): map = { self.boron_name_id: self.conc_mol_Boron[t], self.borate_name_id: self.conc_mol_Borate[t], self.proton_name_id: self.conc_mol_H[t], self.hydroxide_name_id: self.conc_mol_OH[t], } if (j == self.boron_name_id or j == self.borate_name_id or j == self.proton_name_id or j == self.hydroxide_name_id): c_out = pyunits.convert( map[j], to_units=units_meta("amount") * units_meta("length")**-3, ) input_rate = self.control_volume.properties_in[ t].flow_mol_phase_comp[p, j] exit_rate = ( self.control_volume.properties_out[t].flow_vol_phase[p] * c_out) loss_rate = input_rate - exit_rate return self.control_volume.mass_transfer_term[t, p, j] == -loss_rate elif j == self.cation_name_id: dose_rate = pyunits.convert( self.caustic_dose_rate[t] / self.caustic_mw * self.additive_molar_ratio, to_units=units_meta("amount") / units_meta("time"), ) return self.control_volume.mass_transfer_term[t, p, j] == dose_rate else: return self.control_volume.mass_transfer_term[t, p, j] == 0.0