diff --git a/docs/technical_reference/unit_models/ion_exchange_0D.rst b/docs/technical_reference/unit_models/ion_exchange_0D.rst index 8d8cabcf44..55b9ddaf8d 100644 --- a/docs/technical_reference/unit_models/ion_exchange_0D.rst +++ b/docs/technical_reference/unit_models/ion_exchange_0D.rst @@ -34,6 +34,9 @@ At this time, the mass transfer zone is approaching the end of the ion exchange and the regeneration cycle can begin. Fundamental to this model is the assumption that the isotherm between the solute and the resin is favorable, and thus the mass transfer zone is shallow. +.. note:: + If using ``single-use`` configuration for ``regenerant``, the backwashing, regeneration, and rinsing steps are not modeled and all associated costs for these steps are zero. + Isotherm Configurations ^^^^^^^^^^^^^^^^^^^^^^^ @@ -136,7 +139,6 @@ The ion exchange model includes many variables, parameters, and expressions that "Resin bulk density", ":math:`\rho_{b}`", "``resin_bulk_dens``", "None", ":math:`\text{kg/L}`" "Resin surface area per volume", ":math:`a_{s}`", "``resin_surf_per_vol``", "None", ":math:`\text{m}^{-1}`" "Bed porosity", ":math:`\epsilon`", "``bed_porosity``", "None", ":math:`\text{dimensionless}`" - "Regenerant dose per volume of resin", ":math:`C_{regen}`", "``regen_dose``", "None", ":math:`\text{kg/}\text{m}^3`" "Number of cycles before regenerant disposal", ":math:`N_{regen}`", "``regen_recycle``", "None", ":math:`\text{dimensionless}`" "Relative breakthrough concentration at breakthrough time ", ":math:`X`", "``c_norm``", "``target_ion_set``", ":math:`\text{dimensionless}`" "Breakthrough time", ":math:`t_{break}`", "``t_breakthru``", "None", ":math:`\text{s}`" @@ -207,12 +209,9 @@ If ``isotherm`` is set to ``freundlich``, the model includes the following compo **Variables** "Freundlich isotherm exponent for resin/ion system", ":math:`n`", "``freundlich_n``", "None", ":math:`\text{dimensionless}`" - "Bed capacity parameter", ":math:`A`", "``bed_capacity_param``", None, ":math:`\text{dimensionless}`" "Bed volumes at breakthrough", ":math:`BV`", "``bv``", "None", ":math:`\text{dimensionless}`" "Bed volumes at 50% influent conc.", ":math:`BV_{50}`", "``bv_50``", "None", ":math:`\text{dimensionless}`" - "Kinetic fitting parameter", ":math:`r`", "``kinetic_param``", "None", ":math:`\text{dimensionless}`" "Mass transfer coefficient", ":math:`k_T`", "``mass_transfer_coeff``", "None", ":math:`\text{s}^{-1}`" - "Concentration at breakthrough", ":math:`C_{b}`", "``c_breakthru``", "``target_ion_set``", ":math:`\text{kg/}\text{m}^3`" "Average relative breakthrough concentration at breakthrough time", ":math:`X_{avg}`", "``c_norm_avg``", "None", ":math:`\text{dimensionless}`" "Relative breakthrough conc. for trapezoids", ":math:`X_{trap,k}`", "``c_traps``", "``k``", ":math:`\text{dimensionless}`" "Breakthrough times for trapezoids", ":math:`t_{trap,k}`", "``tb_traps``", "``k``", ":math:`\text{s}`" @@ -235,7 +234,6 @@ For either model configuration, the user can fix the following variables: * ``service_flow_rate`` (alternatively, ``vel_bed``) * ``bed_depth`` * ``number_columns`` -* ``regen_dose`` Langmuir DOF @@ -256,7 +254,7 @@ If ``isotherm`` is set to ``freundlich``, the additional variables to fix are: * ``freundlich_n`` * ``bv`` * ``c_norm`` -* one of ``bv_50``, ``kinetic_param``, ``mass_transfer_coeff``, or ``bed_capacity_param`` as determined from Clark model equations +* one of ``bv_50`` or ``mass_transfer_coeff`` as determined from Clark model equations @@ -346,8 +344,6 @@ Equations and Relationships "Breakthrough concentration", ":math:`X = \frac{C_b}{C_0}`" "Bed volumes at breakthrough concentration", ":math:`BV = \frac{t_{break} u_{bed}}{Z}`" "Clark equation with fundamental constants", ":math:`X = \frac{1}{\bigg(1 + (2^{n - 1} - 1)\text{exp}\bigg[\frac{k_T Z (n - 1)}{BV_{50} u_{bed}} (BV_{50} - BV)\bigg]\bigg)^{\frac{1}{n-1}}}`" - "Clark equation for fitting", ":math:`X = \frac{1}{A \text{exp}\big[\frac{-r Z}{u_{bed}} BV\big]^{\frac{1}{n-1}}}`" - "Mass transfer coefficient from Clark equation", ":math:`k_T = \frac{r BV_{50}}{n - 1}`" "Evenly spaced c_norm for trapezoids", ":math:`X_{trap,k} = X_{trap,min} + (k - 1) \frac{X - X_{trap,min}}{n_{trap} - 1}`" "Breakthru time calculation for trapezoids", ":math:`t_{trap,k} = - \log{\frac{X_{trap,k}^{n-1}-1}{A}} / k_T`" "Area of trapezoids", ":math:`A_{trap,k} = \frac{t_{trap,k} - t_{trap,k - 1}}{t_{trap,n_{trap}}} \frac{X_{trap,k} + X_{trap,k - 1}}{2}`" @@ -364,43 +360,40 @@ The following is a list of variables and/or parameters that are created when app :header: "Description", "Symbol", "Variable Name", "Default Value", "Units", "Notes" "Anion exchange resin cost", ":math:`c_{res}`", "``anion_exchange_resin_cost``", "205", ":math:`\text{\$/}\text{ft}^{3}`", "Assumes strong base polystyrenic gel-type Type II. From EPA-WBS cost model." - "Cation exchange resin cost", ":math:`c_{res}`", "``cation_exchange_resin_cost``", "205", ":math:`\text{\$/}\text{ft}^{3}`", "Assumes strong acid polystyrenic gel-type. From EPA-WBS cost model." - "Ion exchange column cost equation intercept", ":math:`C_{col,int}`", "``vessel_intercept``", "10010.86", ":math:`\text{\$}`", "Carbon steel w/ plastic internals. From EPA-WBS cost model." - "Ion exchange column cost equation A coeff", ":math:`C_{col,A}`", "``vessel_A_coeff``", "6e-9", ":math:`\text{\$/}\text{gal}^{3}`", "Carbon steel w/ plastic internals. From EPA-WBS cost model." - "Ion exchange column cost equation B coeff", ":math:`C_{col,B}`", "``vessel_B_coeff``", "-2.284e-4", ":math:`\text{\$/}\text{gal}^{2}`", "Carbon steel w/ plastic internals. From EPA-WBS cost model." - "Ion exchange column cost equation C coeff", ":math:`C_{col,C}`", "``vessel_C_coeff``", "8.3472", ":math:`\text{\$/}\text{gal}`", "Carbon steel w/ plastic internals. From EPA-WBS cost model." - "Backwash/rinse tank cost equation intercept", ":math:`C_{bw,int}`", "``backwash_tank_intercept``", "4717.255", ":math:`\text{\$}`", "Fiberglass tank. From EPA-WBS cost model." - "Backwash/rinse tank cost equation A coeff", ":math:`C_{bw,A}`", "``backwash_tank_A_coeff``", "1e-9", ":math:`\text{\$/}\text{gal}^{3}`", "Fiberglass tank. From EPA-WBS cost model." - "Backwash/rinse tank cost equation B coeff", ":math:`C_{bw,B}`", "``backwash_tank_B_coeff``", "-5.8587e-05", ":math:`\text{\$/}\text{gal}^{2}`", "Fiberglass tank. From EPA-WBS cost model." - "Backwash/rinse tank cost equation C coeff", ":math:`C_{bw,C}`", "``backwash_tank_C_coeff``", "2.2911", ":math:`\text{\$/}\text{gal}`", "Fiberglass tank. From EPA-WBS cost model." - "Regeneration solution tank cost equation intercept", ":math:`C_{regen,int}`", "``regen_tank_intercept``", "4408.327", ":math:`\text{\$}`", "Stainless steel tank. From EPA-WBS cost model." - "Regeneration solution tank cost equation A coeff", ":math:`C_{regen,A}`", "``regen_tank_A_coeff``", "-3.258e-5", ":math:`\text{\$/}\text{gal}^{2}`", "Stainless steel tank. From EPA-WBS cost model." - "Regeneration solution tank cost equation B coeff", ":math:`C_{regen,B}`", "``regen_tank_B_coeff``", "3.846", ":math:`\text{\$/}\text{gal}`", "Stainless steel tank. From EPA-WBS cost model." + "Cation exchange resin cost", ":math:`c_{res}`", "``cation_exchange_resin_cost``", "153", ":math:`\text{\$/}\text{ft}^{3}`", "Assumes strong acid polystyrenic gel-type. From EPA-WBS cost model." + "Regenerant dose per volume of resin", ":math:`D_{regen}`", "``regen_dose``", "300", ":math:`\text{kg/}\text{m}^3`", "Mass of regenerant chemical per cubic meter of resin volume" + "Ion exchange column cost equation A coeff", ":math:`C_{col,A}`", "``vessel_A_coeff``", "1596.499", ":math:`\text{\$}`", "Carbon steel w/ stainless steel internals. From EPA-WBS cost model." + "Ion exchange column cost equation B coeff", ":math:`C_{col,b}`", "``vessel_b_coeff``", "0.459496", ":math:`\text{dimensionless}`", "Carbon steel w/ stainless steel internals. From EPA-WBS cost model." + "Backwash/rinse tank cost equation A coeff", ":math:`C_{bw,A}`", "``backwash_tank_A_coeff``", "308.9371", ":math:`\text{\$}`", "Steel tank. From EPA-WBS cost model." + "Backwash/rinse tank cost equation B coeff", ":math:`C_{bw,b}`", "``backwash_tank_b_coeff``", "0.501467", ":math:`\text{dimensionless}`", "Steel tank. From EPA-WBS cost model." + "Regeneration solution tank cost equation A coeff", ":math:`C_{regen,A}`", "``regen_tank_A_coeff``", "57.02158", ":math:`\text{\$}`", "Stainless steel tank. From EPA-WBS cost model." + "Regeneration solution tank cost equation B coeff", ":math:`C_{regen,b}`", "``regen_tank_b_coeff``", "0.729325", ":math:`\text{dimensionless}`", "Stainless steel tank. From EPA-WBS cost model." "Fraction of resin replaced per year", ":math:`f_{res}`", "``annual_resin_replacement_factor``", "0.05", ":math:`\text{yr}^{-1}`", "Estimated 4-5% per year. From EPA-WBS cost model." "Minimum hazardous waste disposal cost", ":math:`f_{haz,min}`", "``hazardous_min_cost``", "3240", ":math:`\text{\$/}\text{yr}`", "Minimum cost per hazardous waste shipment. From EPA-WBS cost model." "Unit cost for hazardous waste resin disposal", ":math:`f_{haz,res}`", "``hazardous_resin_disposal``", "347.10", ":math:`\text{\$/}\text{ton}`", "From EPA-WBS cost model." "Unit cost for hazardous waste regeneration solution disposal", ":math:`f_{haz,regen}`", "``hazardous_regen_disposal``", "3.64", ":math:`\text{\$/}\text{gal}`", "From EPA-WBS cost model." "Number of cycles the regenerant can be reused before disposal", ":math:`f_{recycle}`", "``regen_recycle``", "1", ":math:`\text{dimensionless}`", "Can optionally be set by the user to investigate more efficient regen regimes." - "Costing factor to account for total installed cost installation of equipment", ":math:`f_{TIC}`", "``total_installed_cost_factor``", "1.65", ":math:`\text{dimensionless}`", "" + "Costing factor to account for total installed cost installation of equipment", ":math:`f_{TIC}`", "``total_installed_cost_factor``", "1.65", ":math:`\text{dimensionless}`", "Costing factor to account for total installed cost of equipment" "Unit cost of NaCl", ":math:`c_{regen}`", "``costing.nacl``", "0.09", ":math:`\text{\$/}\text{kg}`", "Assumes solid NaCl. From CatCost v 1.0.4" "Unit cost of HCl", ":math:`c_{regen}`", "``costing.hcl``", "0.17", ":math:`\text{\$/}\text{kg}`", "Assumes 37% solution HCl. From CatCost v 1.0.4" "Unit cost of NaOH", ":math:`c_{regen}`", "``costing.naoh``", "0.59", ":math:`\text{\$/}\text{kg}`", "Assumes 30% solution NaOH. From iDST" "Unit cost of Methanol (MeOH)", ":math:`c_{regen}`", "``costing.meoh``", "3.395", ":math:`\text{\$/}\text{kg}`", "Assumes 100% pure MeOH. From ICIS" - + Capital Cost Calculations ^^^^^^^^^^^^^^^^^^^^^^^^^ -Capital costs for ion exchange in the ``watertap_costing_package`` are the summation of the total cost of the resin, columns, backwashing tank, and regeneration solution tank: +Capital costs for ion exchange in the ``watertap_costing_package`` are the summation of the +total cost of the resin, columns, backwashing tank, and regeneration solution tank: Resin is costed based on the total volume of resin required for the system, where :math:`c_{res}` is the cost per volume of resin (either cation or anion exchange resin): .. math:: C_{resin} = V_{res,tot} c_{res} -Vessel cost as a function of volume was fit to a polynomial regression of the following form to determine capital cost of each column: +Vessel cost as a function of volume was fit to a power function to determine capital cost of each column: .. math:: - C_{col} = C_{col,A} V_{col}^3 + C_{col,B} V_{col}^2 + C_{col,C} V_{col} + C_{col,int} + C_{col} = C_{col,A} V_{col}^{C_{col,b}} The backwashing tank is assumed to include backwash and rinsing volumes. The total volume of this tank is: @@ -408,22 +401,25 @@ The backwashing tank is assumed to include backwash and rinsing volumes. The tot .. math:: V_{bw} = Q_{bw} t_{bw} + Q_{rinse} t_{rinse} -Backwashing tank cost as a function of volume was fit to a polynomial regression of the following form to determine capital cost of the backwashing tank: +Backwashing tank cost as a function of volume was fit to a power function to determine capital cost of the backwashing tank: .. math:: - C_{bw} = C_{bw,A} V_{bw}^3 + C_{bw,B} V_{bw}^2 + C_{bw,C} V_{bw} + C_{bw,int} + C_{bw} = C_{bw,A} V_{bw}^{C_{bw,b}} -Regeneration tank cost as a function of volume was fit to a polynomial regression of the following form the determine capital cost of the regeneration tank: +Regeneration tank cost as a function of volume was fit to a power function to determine capital cost of the regeneration tank: .. math:: - C_{regen} = C_{regen,A} V_{regen}^2 + C_{regen,B} V_{regen} + C_{regen,int} + C_{regen} = C_{regen,A} V_{regen}^{C_{regen,b}} And the total capital cost for the ion exchange system is the summation of these: .. math:: C_{tot} = ((C_{resin} + C_{col}) (n_{op} + n_{red}) + C_{bw} + C_{regen}) f_{TIC} -A total installed cost (:math:`f_{TIC}`) factor of 1.65 is applied to account for installation costs. +A total installed cost (:math:`f_{TIC}`) factor of 1.65 is applied to account for installation costs. + +.. note:: + If using ``single_use`` option for ``regenerant`` configuration keyword, the capital for the regeneration tank is zero. Operating Cost Calculations ^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -440,10 +436,10 @@ optional model configuration keyword ``regenerant``. Costing data is available f * MeOH If the user does not provide a value for this option, the model defaults to a NaCl regeneration solution. The dose of regenerant needed -is set by the model variable ``regen_dose`` in kg regenerant per cubic meter of resin volume. The mass flow of regenerant solution [kg/yr] is: +is set by the parameter ``regen_dose`` in kg regenerant per cubic meter of resin volume. The mass flow of regenerant solution [kg/yr] is: .. math:: - \dot{m}_{regen} = \frac{C_{regen} V_{res} (n_{op} + n_{red})}{t_{cycle} f_{recycle}} + \dot{m}_{regen} = \frac{D_{regen} V_{res} (n_{op} + n_{red})}{t_{cycle} f_{recycle}} Annual resin replacement cost is: @@ -454,7 +450,7 @@ If the spent resin and regenerant contains hazardous material, the user designat disposal costs are calculated as a function of the annual mass of resin replaced and regenerant consumed: .. math:: - C_{op,haz} = f_{haz,min} + M_{res} (n_{op} + n_{red}) f_{haz,res} + \dot{v}_{regen} f_{haz,regen} + C_{op,haz} = f_{haz,min} + \bigg( M_{res} (n_{op} + n_{red}) f_{res} \bigg) f_{haz,res} + \dot{v}_{regen} f_{haz,regen} Where :math:`M_{res}` is the resin mass for a single bed and :math:`\dot{v}_{regen}` is the volumetric flow of regenerant solution. If ``hazardous_waste`` is set to ``False``, :math:`C_{op,haz} = 0` @@ -464,6 +460,34 @@ The total energy consumed by the unit is the summation of the power required for .. math:: P_{tot} = P_{main} + P_{bw} + P_{regen} + P_{rinse} +If the user chooses ``single_use`` for the ``regenerant`` configuration keyword, there is no cost for regeneration solution: + +.. math:: + \dot{m}_{regen} = \dot{v}_{regen} = 0 + +Instead, the model assumes the entire volume of resin for the operational columns is replaced at the end of each service cycle by calculating the +volumetric "flow" of resin: + +.. math:: + \dot{v}_{resin} = \frac{V_{res, tot}}{t_{break}} + +And then operational cost of replacing the entire bed is: + +.. math:: + C_{op,res} = \dot{v}_{resin} c_{res} + +If ``hazardous_waste`` is set to ``True``, the hazardous waste disposal costs are: + +.. math:: + C_{op,haz} = f_{haz,min} + ( \dot{v}_{resin} \rho_{b} n_{op}) f_{haz,res} + +Otherwise, :math:`C_{op,haz} = 0` as before. + +Lastly, the total energy consumed by the unit for ``single_use`` configuration includes the booster pump, backwashing pump, and rinsing pump: + +.. math:: + P_{tot} = P_{main} + P_{bw} + P_{rinse} + References ---------- diff --git a/watertap/costing/unit_models/ion_exchange.py b/watertap/costing/unit_models/ion_exchange.py index def4444e78..d4c74574d3 100644 --- a/watertap/costing/unit_models/ion_exchange.py +++ b/watertap/costing/unit_models/ion_exchange.py @@ -108,69 +108,45 @@ def build_ion_exhange_cost_param_block(blk): units=pyo.units.USD_2020 / pyo.units.ft**3, doc="Cation exchange resin cost per cubic ft. Assumes strong acid polystyrenic gel-type. From EPA-WBS cost model.", ) - # Ion exchange pressure vessels costed with 3rd order polynomial: - # pv_cost = A * col_vol^3 + B * col_vol^2 + C * col_vol + intercept + # Ion exchange pressure vessels costed with power equation, col_vol in gallons: + # pressure_vessel_cost = A * col_vol ** b - blk.vessel_intercept = pyo.Var( - initialize=10010.86, - units=pyo.units.USD_2020, - doc="Ion exchange pressure vessel cost equation - intercept, Carbon steel w/ plastic internals", - ) blk.vessel_A_coeff = pyo.Var( - initialize=6e-9, - units=pyo.units.USD_2020 / pyo.units.gal**3, - doc="Ion exchange pressure vessel cost equation - A coeff., Carbon steel w/ plastic internals", - ) - blk.vessel_B_coeff = pyo.Var( - initialize=-2.284e-4, - units=pyo.units.USD_2020 / pyo.units.gal**2, - doc="Ion exchange pressure vessel cost equation - B coeff., Carbon steel w/ plastic internals", + initialize=1596.499333, + units=pyo.units.USD_2020, + doc="Ion exchange pressure vessel cost equation - A coeff., Carbon steel w/ stainless steel internals", ) - blk.vessel_C_coeff = pyo.Var( - initialize=8.3472, - units=pyo.units.USD_2020 / pyo.units.gal, - doc="Ion exchange pressure vessel cost equation - C coeff., Carbon steel w/ plastic internals", + blk.vessel_b_coeff = pyo.Var( + initialize=0.459496809, + units=pyo.units.dimensionless, + doc="Ion exchange pressure vessel cost equation - b coeff., Carbon steel w/ stainless steel internals", ) - # Ion exchange backwash/rinse tank costed with 3rd order polynomial: - # pv_cost = A * tank_vol^3 + B * tank_vol^2 + C * tank_vol + intercept + + # Ion exchange backwash/rinse tank costed with power equation, tank_vol in gallons: + # bw_tank_cost = A * tank_vol ** b blk.backwash_tank_A_coeff = pyo.Var( - initialize=1e-9, - units=pyo.units.USD_2020 / pyo.units.gal**3, - doc="Ion exchange backwash tank cost equation - A coeff., Fiberglass tank", - ) - blk.backwash_tank_B_coeff = pyo.Var( - initialize=-5.8587e-05, - units=pyo.units.USD_2020 / pyo.units.gal**2, - doc="Ion exchange backwash tank cost equation - B coeff., Fiberglass tank", - ) - blk.backwash_tank_C_coeff = pyo.Var( - initialize=2.2911, - units=pyo.units.USD_2020 / pyo.units.gal, - doc="Ion exchange backwash tank cost equation - C coeff., Fiberglass tank", - ) - blk.backwash_tank_intercept = pyo.Var( - initialize=4717.255, + initialize=308.9371309, units=pyo.units.USD_2020, - doc="Ion exchange backwash tank cost equation - intercept, Fiberglass tank", + doc="Ion exchange backwash tank cost equation - A coeff., Steel tank", ) - # Ion exchange regeneration solution tank costed with 2nd order polynomial: - # regen_tank_cost = A * tank_vol^2 + B * tank_vol + intercept - - blk.regen_tank_intercept = pyo.Var( - initialize=4408.327, - units=pyo.units.USD_2020, - doc="Ion exchange regen tank cost equation - intercept. Stainless steel", + blk.backwash_tank_b_coeff = pyo.Var( + initialize=0.501467571, + units=pyo.units.dimensionless, + doc="Ion exchange backwash tank cost equation - b coeff., Steel tank", ) + # Ion exchange regeneration solution tank costed with power equation, tank_vol in gallons: + # regen_tank_cost = A * tank_vol ** b + blk.regen_tank_A_coeff = pyo.Var( - initialize=-3.258e-5, - units=pyo.units.USD_2020 / pyo.units.gal**2, + initialize=57.02158923, + units=pyo.units.USD_2020, doc="Ion exchange regen tank cost equation - A coeff. Stainless steel", ) - blk.regen_tank_B_coeff = pyo.Var( - initialize=3.846, - units=pyo.units.USD_2020 / pyo.units.gal, - doc="Ion exchange regen tank cost equation - B coeff. Stainless steel", + blk.regen_tank_b_coeff = pyo.Var( + initialize=0.729325391, + units=pyo.units.dimensionless, + doc="Ion exchange regen tank cost equation - b coeff. Stainless steel", ) blk.annual_resin_replacement_factor = pyo.Var( initialize=0.05, @@ -230,23 +206,20 @@ def cost_ion_exchange(blk): tot_num_col = blk.unit_model.number_columns + blk.unit_model.number_columns_redund col_vol_gal = pyo.units.convert(blk.unit_model.col_vol_per, to_units=pyo.units.gal) bed_vol_ft3 = pyo.units.convert(blk.unit_model.bed_vol, to_units=pyo.units.ft**3) - bed_mass_ton = pyo.units.convert( - blk.unit_model.bed_vol * blk.unit_model.resin_bulk_dens, - to_units=pyo.units.ton, - ) - bw_tank_vol = pyo.units.convert( - ( - blk.unit_model.bw_flow * blk.unit_model.t_bw - + blk.unit_model.rinse_flow * blk.unit_model.t_rinse - ), - to_units=pyo.units.gal, + + ix_type = blk.unit_model.ion_exchange_type + blk.regen_soln_dens = pyo.Param( + initialize=1000, + units=pyo.units.kg / pyo.units.m**3, + mutable=True, + doc="Density of regeneration solution", ) - regen_tank_vol = pyo.units.convert( - blk.unit_model.regen_tank_vol, - to_units=pyo.units.gal, + blk.regen_dose = pyo.Param( + initialize=300, + units=pyo.units.kg / pyo.units.m**3, + mutable=True, + doc="Regenerant dose required for regeneration per volume of resin [kg regenerant/m3 resin]", ) - ix_type = blk.unit_model.ion_exchange_type - blk.capital_cost_vessel = pyo.Var( initialize=1e5, domain=pyo.NonNegativeReals, @@ -277,15 +250,15 @@ def cost_ion_exchange(blk): units=blk.costing_package.base_currency / blk.costing_package.base_period, doc="Operating cost for hazardous waste disposal", ) - blk.regen_soln_flow = pyo.Var( + blk.flow_mass_regen_soln = pyo.Var( initialize=1, - bounds=(0, None), + domain=pyo.NonNegativeReals, units=pyo.units.kg / pyo.units.year, doc="Regeneration solution flow", ) blk.total_pumping_power = pyo.Var( initialize=1, - bounds=(0, None), + domain=pyo.NonNegativeReals, units=pyo.units.kilowatt, doc="Total pumping power required", ) @@ -299,10 +272,10 @@ def cost_ion_exchange(blk): blk.capital_cost_vessel_constraint = pyo.Constraint( expr=blk.capital_cost_vessel == pyo.units.convert( - ion_exchange_params.vessel_intercept - + ion_exchange_params.vessel_A_coeff * col_vol_gal**3 - + ion_exchange_params.vessel_B_coeff * col_vol_gal**2 - + ion_exchange_params.vessel_C_coeff * col_vol_gal, + ( + ion_exchange_params.vessel_A_coeff + * (col_vol_gal / pyo.units.gallon) ** ion_exchange_params.vessel_b_coeff + ), to_units=blk.costing_package.base_currency, ) ) @@ -312,25 +285,69 @@ def cost_ion_exchange(blk): resin_cost * bed_vol_ft3, to_units=blk.costing_package.base_currency ) ) - blk.capital_cost_backwash_tank_constraint = pyo.Constraint( - expr=blk.capital_cost_backwash_tank - == pyo.units.convert( - ion_exchange_params.backwash_tank_intercept - + ion_exchange_params.backwash_tank_A_coeff * bw_tank_vol**3 - + ion_exchange_params.backwash_tank_B_coeff * bw_tank_vol**2 - + ion_exchange_params.backwash_tank_C_coeff * bw_tank_vol, - to_units=blk.costing_package.base_currency, + if blk.unit_model.config.regenerant == "single_use": + blk.capital_cost_regen_tank.fix(0) + blk.flow_mass_regen_soln.fix(0) + blk.flow_vol_resin = pyo.Var( + initialize=1e5, + bounds=(0, None), + units=pyo.units.m**3 / blk.costing_package.base_period, + doc="Volumetric flow of resin per cycle", # assumes you are only replacing the operational columns, t_cycle = t_breakthru ) - ) - blk.capital_cost_regen_tank_constraint = pyo.Constraint( - expr=blk.capital_cost_regen_tank - == pyo.units.convert( - ion_exchange_params.regen_tank_intercept - + ion_exchange_params.regen_tank_A_coeff * regen_tank_vol**2 - + ion_exchange_params.regen_tank_B_coeff * regen_tank_vol, - to_units=blk.costing_package.base_currency, + blk.single_use_resin_replacement_cost = pyo.Var( + initialize=1e5, + bounds=(0, None), + units=blk.costing_package.base_currency / blk.costing_package.base_period, + doc="Operating cost for using single-use resin (i.e., no regeneration)", ) - ) + + blk.flow_vol_resin_constraint = pyo.Constraint( + expr=blk.flow_vol_resin + == pyo.units.convert( + blk.unit_model.bed_vol_tot / blk.unit_model.t_breakthru, + to_units=pyo.units.m**3 / blk.costing_package.base_period, + ) + ) + blk.mass_flow_resin = pyo.units.convert( + blk.flow_vol_resin * blk.unit_model.resin_bulk_dens, + to_units=pyo.units.ton / blk.costing_package.base_period, + ) + else: + blk.backwash_tank_vol = pyo.Expression( + expr=pyo.units.convert( + ( + blk.unit_model.bw_flow * blk.unit_model.t_bw + + blk.unit_model.rinse_flow * blk.unit_model.t_rinse + ), + to_units=pyo.units.gal, + ) + ) + + blk.regeneration_tank_vol = pyo.Expression( + expr=pyo.units.convert( + blk.unit_model.regen_tank_vol, + to_units=pyo.units.gal, + ) + ) + blk.capital_cost_backwash_tank_constraint = pyo.Constraint( + expr=blk.capital_cost_backwash_tank + == pyo.units.convert( + ion_exchange_params.backwash_tank_A_coeff + * (blk.backwash_tank_vol / pyo.units.gallon) + ** ion_exchange_params.backwash_tank_b_coeff, + to_units=blk.costing_package.base_currency, + ) + ) + blk.capital_cost_regen_tank_constraint = pyo.Constraint( + expr=blk.capital_cost_regen_tank + == pyo.units.convert( + ion_exchange_params.regen_tank_A_coeff + * (blk.regeneration_tank_vol / pyo.units.gallon) + ** ion_exchange_params.regen_tank_b_coeff, + to_units=blk.costing_package.base_currency, + ) + ) + blk.costing_package.add_cost_factor(blk, "TIC") blk.capital_cost_constraint = pyo.Constraint( expr=blk.capital_cost @@ -345,66 +362,105 @@ def cost_ion_exchange(blk): ) ) if blk.unit_model.config.hazardous_waste: - blk.regen_dens = 1000 * pyo.units.kg / pyo.units.m**3 - blk.regen_soln_vol_flow = pyo.units.convert( - blk.regen_soln_flow / blk.regen_dens, - to_units=pyo.units.gal / pyo.units.year, + + if blk.unit_model.config.regenerant == "single_use": + blk.operating_cost_hazardous_constraint = pyo.Constraint( + expr=blk.operating_cost_hazardous + == pyo.units.convert( + (blk.mass_flow_resin * ion_exchange_params.hazardous_resin_disposal) + + ion_exchange_params.hazardous_min_cost, + to_units=blk.costing_package.base_currency + / blk.costing_package.base_period, + ) + ) + else: + bed_mass_ton = pyo.units.convert( + blk.unit_model.bed_vol * blk.unit_model.resin_bulk_dens, + to_units=pyo.units.ton, + ) + blk.operating_cost_hazardous_constraint = pyo.Constraint( + expr=blk.operating_cost_hazardous + == pyo.units.convert( + ( + bed_mass_ton + * tot_num_col + * ion_exchange_params.hazardous_resin_disposal + ) + * ion_exchange_params.annual_resin_replacement_factor + + pyo.units.convert( + blk.flow_mass_regen_soln / blk.regen_soln_dens, + to_units=pyo.units.gal / pyo.units.year, + ) + * ion_exchange_params.hazardous_regen_disposal + + ion_exchange_params.hazardous_min_cost, + to_units=blk.costing_package.base_currency + / blk.costing_package.base_period, + ) + ) + else: + blk.operating_cost_hazardous.fix(0) + if blk.unit_model.config.regenerant == "single_use": + + blk.single_use_resin_replacement_cost_constraint = pyo.Constraint( + expr=blk.single_use_resin_replacement_cost + == pyo.units.convert( + blk.flow_vol_resin * resin_cost, + to_units=blk.costing_package.base_currency + / blk.costing_package.base_period, + ) + ) + + blk.fixed_operating_cost_constraint = pyo.Constraint( + expr=blk.fixed_operating_cost + == blk.single_use_resin_replacement_cost + blk.operating_cost_hazardous ) - blk.operating_cost_hazardous_constraint = pyo.Constraint( - expr=blk.operating_cost_hazardous + + else: + blk.fixed_operating_cost_constraint = pyo.Constraint( + expr=blk.fixed_operating_cost == pyo.units.convert( ( - +bed_mass_ton - * tot_num_col - * ion_exchange_params.hazardous_resin_disposal - ) - * ion_exchange_params.annual_resin_replacement_factor - + blk.regen_soln_vol_flow * ion_exchange_params.hazardous_regen_disposal - + ion_exchange_params.hazardous_min_cost, + ( + bed_vol_ft3 + * tot_num_col + * ion_exchange_params.annual_resin_replacement_factor + * resin_cost + ) + ), to_units=blk.costing_package.base_currency / blk.costing_package.base_period, ) + + blk.operating_cost_hazardous ) - else: - blk.operating_cost_hazardous.fix(0) - blk.fixed_operating_cost_constraint = pyo.Constraint( - expr=blk.fixed_operating_cost - == pyo.units.convert( - ( + + blk.flow_mass_regen_soln_constraint = pyo.Constraint( + expr=blk.flow_mass_regen_soln + == pyo.units.convert( ( - bed_vol_ft3 - * tot_num_col - * ion_exchange_params.annual_resin_replacement_factor - * resin_cost + (blk.regen_dose * blk.unit_model.bed_vol * tot_num_col) + / (blk.unit_model.t_cycle) ) - ), - to_units=blk.costing_package.base_currency - / blk.costing_package.base_period, + / ion_exchange_params.regen_recycle, + to_units=pyo.units.kg / pyo.units.year, + ) ) - + blk.operating_cost_hazardous - ) - blk.regen_soln_flow_constr = pyo.Constraint( - expr=blk.regen_soln_flow - == pyo.units.convert( - ( - (blk.unit_model.regen_dose * blk.unit_model.bed_vol * tot_num_col) - / (blk.unit_model.t_cycle) - ) - / ion_exchange_params.regen_recycle, - to_units=pyo.units.kg / pyo.units.year, + blk.costing_package.cost_flow( + blk.flow_mass_regen_soln, blk.unit_model.config.regenerant ) - ) - blk.total_pumping_power_constr = pyo.Constraint( - expr=blk.total_pumping_power - == ( + if blk.unit_model.config.regenerant == "single_use": + power_expr = blk.unit_model.main_pump_power + else: + power_expr = ( blk.unit_model.main_pump_power + blk.unit_model.regen_pump_power + blk.unit_model.bw_pump_power + blk.unit_model.rinse_pump_power ) + + blk.total_pumping_power_constr = pyo.Constraint( + expr=blk.total_pumping_power == power_expr ) - blk.costing_package.cost_flow(blk.regen_soln_flow, blk.unit_model.config.regenerant) blk.costing_package.cost_flow(blk.total_pumping_power, "electricity") diff --git a/watertap/examples/flowsheets/ion_exchange/ion_exchange_demo.py b/watertap/examples/flowsheets/ion_exchange/ion_exchange_demo.py index 16a5ea4237..5e6b3edadb 100644 --- a/watertap/examples/flowsheets/ion_exchange/ion_exchange_demo.py +++ b/watertap/examples/flowsheets/ion_exchange/ion_exchange_demo.py @@ -199,7 +199,6 @@ def set_operating_conditions(m, flow_in=0.05, conc_mass_in=0.1, solver=None): ix.resin_bulk_dens.fix() ix.bed_porosity.fix() ix.dimensionless_time.fix() - ix.regen_dose.fix() def initialize_system(m): diff --git a/watertap/examples/flowsheets/ion_exchange/tests/test_ion_exchange_demo.py b/watertap/examples/flowsheets/ion_exchange/tests/test_ion_exchange_demo.py index a3beaa56c3..d3c2e96f57 100644 --- a/watertap/examples/flowsheets/ion_exchange/tests/test_ion_exchange_demo.py +++ b/watertap/examples/flowsheets/ion_exchange/tests/test_ion_exchange_demo.py @@ -111,6 +111,7 @@ def test_build_model(self, ix_0D): @pytest.mark.component def test_specific_operating_conditions(self, ix_0D): + m = ix_0D ixf.set_operating_conditions(m) ixf.initialize_system(m) @@ -138,19 +139,97 @@ def test_specific_operating_conditions(self, ix_0D): ) ) == pytest.approx(0.000211438, rel=1e-3) - assert value(m.fs.ion_exchange.number_columns) == pytest.approx(4, rel=1e-3) - assert value(m.fs.ion_exchange.bed_depth) == pytest.approx(1.7, rel=1e-3) - assert value(m.fs.ion_exchange.t_breakthru) == pytest.approx( - 56759.75759, rel=1e-3 - ) - assert value(m.fs.ion_exchange.partition_ratio) == pytest.approx( - 235.99899, rel=1e-3 - ) + results_dict = { + "resin_diam": 0.0007, + "resin_bulk_dens": 0.7, + "resin_surf_per_vol": 4285.714, + "c_norm": {"Ca_2+": 0.47307}, + "bed_vol_tot": 11.999, + "bed_depth": 1.7, + "bed_porosity": 0.5, + "col_height": 3.488, + "col_diam": 1.498964, + "col_height_to_diam_ratio": 2.3274, + "number_columns": 4, + "t_breakthru": 56759.7575, + "ebct": 240.0, + "vel_bed": 0.00708, + "service_flow_rate": 15, + "N_Re": 4.9583, + "N_Sc": {"Ca_2+": 1086.956}, + "N_Sh": {"Ca_2+": 26.296358}, + "N_Pe_particle": 0.1078, + "N_Pe_bed": 261.8677, + "resin_max_capacity": 3, + "resin_eq_capacity": 1.6857, + "resin_unused_capacity": 1.3142, + "langmuir": {"Ca_2+": 0.7}, + "mass_removed": {"Ca_2+": 7079.9696}, + "num_transfer_units": 35.54838, + "dimensionless_time": 1, + "partition_ratio": 235.998, + "fluid_mass_transfer_coeff": {"Ca_2+": 3.4560e-05}, + "pressure_drop": 9.4501, + "bed_vol": 3, + "t_rinse": 1200.0, + "t_waste": 3600.0, + "regen_pump_power": 1.3574, + "regen_tank_vol": 29.9999, + "bw_flow": 0.00980392, + "bed_expansion_frac": 0.463950, + "rinse_flow": 0.04999, + "t_cycle": 60359.75756, + "bw_pump_power": 0.798485720, + "rinse_pump_power": 4.072277, + "bed_expansion_h": 0.788715, + "main_pump_power": 4.072277, + "col_vol_per": 6.15655, + "col_vol_tot": 24.6262, + "t_contact": 120.0, + "vel_inter": 0.01416, + "bv_calc": 236.4989, + "lh": 0.0, + "separation_factor": {"Ca_2+": 1.428571}, + "rate_coeff": {"Ca_2+": 0.000211597}, + "HTU": {"Ca_2+": 0.047822}, + } + for v, r in results_dict.items(): + ixv = getattr(m.fs.ion_exchange, v) + if ixv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(ixv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(ixv) - assert value(m.fs.costing.specific_energy_consumption) == pytest.approx( - 0.057245, rel=1e-3 - ) - assert value(m.fs.costing.LCOW) == pytest.approx(0.191008, rel=1e-3) + sys_cost_results = { + "aggregate_capital_cost": 810841.861208, + "aggregate_fixed_operating_cost": 4099.25715, + "aggregate_variable_operating_cost": 0.0, + "aggregate_flow_electricity": 10.300, + "aggregate_flow_NaCl": 2352713.226, + "aggregate_flow_costs": { + "electricity": 6320.57182, + "NaCl": 214194.7689, + }, + "total_capital_cost": 810841.8612, + "total_operating_cost": 226888.3196, + "aggregate_direct_capital_cost": 405420.9305, + "maintenance_labor_chemical_operating_cost": 24325.25583, + "total_fixed_operating_cost": 28424.5129, + "total_variable_operating_cost": 198463.8066, + "total_annualized_cost": 307972.50579, + "annual_water_production": 1419950.29103, + "LCOW": 0.21688, + "specific_energy_consumption": 0.057231, + } + + for v, r in sys_cost_results.items(): + mv = getattr(m.fs.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) @pytest.mark.component def test_optimization(self, ix_0D): @@ -188,33 +267,148 @@ def test_optimization(self, ix_0D): results = solver.solve(m) assert_optimal_termination(results) assert degrees_of_freedom(m) == 0 - assert value(m.fs.ion_exchange.number_columns) == 7 - assert value(m.fs.ion_exchange.bed_depth) == pytest.approx(1.38976, rel=1e-3) - assert value(m.fs.ion_exchange.t_breakthru) == pytest.approx( - 133134.2829, rel=1e-3 - ) - assert value(m.fs.ion_exchange.dimensionless_time) == pytest.approx( - 1.33210077, rel=1e-3 - ) - assert value(m.fs.costing.specific_energy_consumption) == pytest.approx( - 0.039173, rel=1e-3 - ) - assert value(m.fs.costing.LCOW) == pytest.approx(0.112747, rel=1e-3) + + results_dict = { + "resin_diam": 0.0007, + "resin_bulk_dens": 0.7, + "resin_surf_per_vol": 4285.71428, + "c_norm": {"Ca_2+": 0.989774}, + "bed_vol_tot": 12, + "bed_depth": 1.63866, + "bed_porosity": 0.5, + "col_height": 3.398, + "col_diam": 1.3655, + "col_height_to_diam_ratio": 2.4890, + "number_columns": 5, + "t_breakthru": 133431.6785, + "ebct": 240.0, + "vel_bed": 0.0068277, + "service_flow_rate": 15, + "N_Re": 4.77944, + "N_Sc": {"Ca_2+": 1086.9565}, + "N_Sh": {"Ca_2+": 25.96987}, + "N_Pe_particle": 0.105942, + "N_Pe_bed": 248.007270, + "resin_max_capacity": 3, + "resin_eq_capacity": 2.97846, + "resin_unused_capacity": 0.0215398, + "langmuir": {"Ca_2+": 0.7}, + "mass_removed": {"Ca_2+": 12509.53260}, + "num_transfer_units": 35.10703, + "dimensionless_time": 1.33210, + "partition_ratio": 416.98442, + "fluid_mass_transfer_coeff": {"Ca_2+": 3.41318e-05}, + "pressure_drop": 8.78589, + "bed_vol": 2.39999, + "t_rinse": 1200.0, + "t_waste": 3600.0, + "regen_pump_power": 1.262012, + "regen_tank_vol": 29.9999, + "bw_flow": 0.010170, + "bed_expansion_frac": 0.46395, + "rinse_flow": 0.05, + "t_cycle": 137031.6785, + "bw_pump_power": 0.77014, + "rinse_pump_power": 3.7860, + "bed_expansion_h": 0.7602, + "main_pump_power": 3.7860, + "col_vol_per": 4.9780, + "col_vol_tot": 24.8904, + "t_contact": 120.0, + "vel_inter": 0.01365, + "bv_calc": 555.96532, + "lh": 11.6591, + "separation_factor": {"Ca_2+": 1.428571}, + "rate_coeff": {"Ca_2+": 0.00020897}, + "HTU": {"Ca_2+": 0.046676}, + } + + for v, r in results_dict.items(): + ixv = getattr(m.fs.ion_exchange, v) + if ixv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(ixv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(ixv) + + sys_cost_results = { + "aggregate_capital_cost": 847088.1118, + "aggregate_fixed_operating_cost": 3935.286, + "aggregate_variable_operating_cost": 0.0, + "aggregate_flow_electricity": 9.60422, + "aggregate_flow_NaCl": 994870.919, + "aggregate_flow_costs": { + "electricity": 5893.3472, + "NaCl": 90574.6370, + }, + "total_capital_cost": 847088.111, + "total_operating_cost": 116169.116137, + "aggregate_direct_capital_cost": 423544.0559, + "maintenance_labor_chemical_operating_cost": 25412.6433, + "total_fixed_operating_cost": 29347.93021, + "total_variable_operating_cost": 86821.18591, + "total_annualized_cost": 200877.9273, + "annual_water_production": 1419985.4904, + "LCOW": 0.14146, + "specific_energy_consumption": 0.05336, + } + + for v, r in sys_cost_results.items(): + mv = getattr(m.fs.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) @pytest.mark.unit def test_main_fun(self): m = ixf.main() assert degrees_of_freedom(m) == 0 - assert value(m.fs.ion_exchange.number_columns) == 7 - assert value(m.fs.ion_exchange.bed_depth) == pytest.approx(1.38976, rel=1e-3) - assert value(m.fs.ion_exchange.t_breakthru) == pytest.approx( - 133134.2829, rel=1e-3 - ) - assert value(m.fs.ion_exchange.dimensionless_time) == pytest.approx( - 1.33210077, rel=1e-3 - ) - assert value(m.fs.costing.specific_energy_consumption) == pytest.approx( - 0.039173, rel=1e-3 - ) - assert value(m.fs.costing.LCOW) == pytest.approx(0.112747, rel=1e-3) + results_dict = { + "c_norm": {"Ca_2+": 0.989774}, + "bed_vol_tot": 12, + "bed_depth": 1.63866, + "number_columns": 5, + "t_breakthru": 133431.678, + "ebct": 240, + "resin_eq_capacity": 2.9784, + "mass_removed": {"Ca_2+": 12509.532}, + "num_transfer_units": 35.1070, + "dimensionless_time": 1.3321, + "partition_ratio": 416.984, + "fluid_mass_transfer_coeff": {"Ca_2+": 3.413e-05}, + "bv_calc": 555.96, + "lh": 11.6590, + "separation_factor": {"Ca_2+": 1.42857}, + "rate_coeff": {"Ca_2+": 0.00020897}, + "HTU": {"Ca_2+": 0.04667}, + } + + for v, r in results_dict.items(): + ixv = getattr(m.fs.ion_exchange, v) + if ixv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(ixv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(ixv) + + sys_cost_results = { + "aggregate_capital_cost": 847088.11, + "aggregate_flow_electricity": 9.604, + "aggregate_flow_NaCl": 994870.919, + "total_capital_cost": 847088.1118, + "total_operating_cost": 116169.116, + "total_annualized_cost": 200877.927, + "annual_water_production": 1419985.4904, + "LCOW": 0.14145, + "specific_energy_consumption": 0.05336, + } + for v, r in sys_cost_results.items(): + mv = getattr(m.fs.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) diff --git a/watertap/unit_models/ion_exchange_0D.py b/watertap/unit_models/ion_exchange_0D.py index a45d713e8f..c790548d47 100644 --- a/watertap/unit_models/ion_exchange_0D.py +++ b/watertap/unit_models/ion_exchange_0D.py @@ -20,7 +20,6 @@ Param, Suffix, log, - exp, units as pyunits, ) from pyomo.common.config import ConfigBlock, ConfigValue, In @@ -102,7 +101,7 @@ class RegenerantChem(StrEnum): H2SO4 = "H2SO4" NaCl = "NaCl" MeOH = "MeOH" - none = "none" + single_use = "single_use" class IsothermType(StrEnum): @@ -302,6 +301,9 @@ def build(self): prop_in = self.process_flow.properties_in[0] + self.add_inlet_port(name="inlet", block=self.process_flow) + self.add_outlet_port(name="outlet", block=self.process_flow) + tmp_dict = dict(**self.config.property_package_args) tmp_dict["has_phase_equilibrium"] = False tmp_dict["parameters"] = self.config.property_package @@ -315,10 +317,12 @@ def build(self): regen = self.regeneration_stream[0] - self.add_inlet_port(name="inlet", block=self.process_flow) - self.add_outlet_port(name="outlet", block=self.process_flow) self.add_outlet_port(name="regen", block=self.regeneration_stream) + for j in inerts: + self.process_flow.mass_transfer_term[:, "Liq", j].fix(0) + regen.get_material_flow_terms("Liq", j).fix(0) + # ==========PARAMETERS========== self.underdrain_h = Param( @@ -378,31 +382,11 @@ def build(self): doc="Sherwood equation exponent C", ) - # Bed expansion is calculated as a fraction of the bed_depth - # These coefficients are used to calculate that fraction (bed_expansion_frac) as a function of backwash rate (bw_rate, m/hr) - # bed_expansion_frac = bed_expansion_A + bed_expansion_B * bw_rate + bed_expansion_C * bw_rate**2 - # Default is for strong-base type I acrylic anion exchanger resin (A-850, Purolite), @20C - # Data extracted from MWH Chap 16, Figure 16-15 and fit with Excel - - self.bed_expansion_frac_A = Param( - initialize=-1.23e-2, + self.number_columns_redund = Param( + initialize=1, mutable=True, units=pyunits.dimensionless, - doc="Bed expansion fraction eq intercept", - ) - - self.bed_expansion_frac_B = Param( - initialize=1.02e-1, - mutable=True, - units=pyunits.hr / pyunits.m, - doc="Bed expansion fraction equation B parameter", - ) - - self.bed_expansion_frac_C = Param( - initialize=-1.35e-3, - mutable=True, - units=pyunits.hr**2 / pyunits.m**2, - doc="Bed expansion fraction equation C parameter", + doc="Number of redundant columns for ion exchange process", ) # Pressure drop (psi/m of resin bed depth) is a function of loading rate (vel_bed) in m/hr @@ -438,8 +422,6 @@ def build(self): doc="Pump efficiency", ) - # Rinse, Regen, Backwashing params - self.t_regen = Param( initialize=1800, mutable=True, @@ -447,6 +429,41 @@ def build(self): doc="Regeneration time", ) + self.service_to_regen_flow_ratio = Param( + initialize=3, + mutable=True, + units=pyunits.dimensionless, + doc="Ratio of service flow rate to regeneration flow rate", + ) + + # Bed expansion is calculated as a fraction of the bed_depth + # These coefficients are used to calculate that fraction (bed_expansion_frac) as a function of backwash rate (bw_rate, m/hr) + # bed_expansion_frac = bed_expansion_A + bed_expansion_B * bw_rate + bed_expansion_C * bw_rate**2 + # Default is for strong-base type I acrylic anion exchanger resin (A-850, Purolite), @20C + # Data extracted from MWH Chap 16, Figure 16-15 and fit with Excel + + self.bed_expansion_frac_A = Param( + initialize=-1.23e-2, + mutable=True, + units=pyunits.dimensionless, + doc="Bed expansion fraction eq intercept", + ) + + self.bed_expansion_frac_B = Param( + initialize=1.02e-1, + mutable=True, + units=pyunits.hr / pyunits.m, + doc="Bed expansion fraction equation B parameter", + ) + + self.bed_expansion_frac_C = Param( + initialize=-1.35e-3, + mutable=True, + units=pyunits.hr**2 / pyunits.m**2, + doc="Bed expansion fraction equation C parameter", + ) + # Rinse, Regen, Backwashing params + self.rinse_bv = Param( initialize=5, mutable=True, @@ -467,19 +484,6 @@ def build(self): doc="Backwash time", ) - self.service_to_regen_flow_ratio = Param( - initialize=3, - mutable=True, - units=pyunits.dimensionless, - doc="Ratio of service flow rate to regeneration flow rate", - ) - - self.number_columns_redund = Param( - initialize=1, - mutable=True, - units=pyunits.dimensionless, - doc="Number of redundant columns for ion exchange process", - ) # ==========VARIABLES========== # COMMON TO LANGMUIR + FREUNDLICH @@ -504,13 +508,6 @@ def build(self): doc="Resin surface area per volume", ) - self.regen_dose = Var( - initialize=300, - units=pyunits.kg / pyunits.m**3, - bounds=(0, None), - doc="Regenerant dose required for regeneration per volume of resin [kg regenerant/m3 resin]", - ) - self.c_norm = Var( self.target_ion_set, initialize=0.5, @@ -571,13 +568,6 @@ def build(self): doc="Breakthrough time", ) - self.t_contact = Var( - initialize=120, - bounds=(100, None), - units=pyunits.s, - doc="Resin contact time", - ) - self.ebct = Var( initialize=520, bounds=(90, None), @@ -594,12 +584,6 @@ def build(self): doc="Superficial velocity through bed", ) - self.vel_inter = Var( - initialize=0.01, - units=pyunits.m / pyunits.s, - doc="Interstitial velocity through bed", - ) - self.service_flow_rate = Var( initialize=10, bounds=(1, 40), @@ -757,14 +741,6 @@ def build(self): doc="Sum of trapezoid areas", ) - self.c_breakthru = Var( - self.target_ion_set, - initialize=0.5, - bounds=(0, None), - units=pyunits.kg / pyunits.m**3, - doc="Breakthrough concentration of target ion", - ) - self.freundlich_n = Var( initialize=1.5, bounds=(0, None), @@ -793,50 +769,19 @@ def build(self): doc="Bed volumes of feed at 50 percent breakthrough", ) - self.bed_capacity_param = Var( - initialize=1, - bounds=(0, None), - units=pyunits.dimensionless, - doc="Bed capacity fitting parameter for Clark model (A)", - ) - - self.kinetic_param = Var( - initialize=1e-5, - bounds=(0, None), - units=pyunits.s**-1, - doc="Kinetic fitting parameter for Clark model (r)", - ) - # ==========EXPRESSIONS========== - @self.Expression(doc="Bed expansion fraction from backwashing") - def bed_expansion_frac(b): + @self.Expression(doc="Pressure drop") + def pressure_drop(b): + vel_bed = pyunits.convert(b.vel_bed, to_units=pyunits.m / pyunits.hr) return ( - b.bed_expansion_frac_A - + b.bed_expansion_frac_B * b.bw_rate - + b.bed_expansion_frac_C * b.bw_rate**2 - ) # for 20C - - @self.Expression(doc="Bed expansion from backwashing") - def bed_expansion_h(b): - return b.bed_expansion_frac * b.bed_depth + b.p_drop_A + b.p_drop_B * vel_bed + b.p_drop_C * vel_bed**2 + ) * b.bed_depth # for 20C; @self.Expression(doc="Total bed volume") def bed_vol(b): return b.bed_vol_tot / b.number_columns - @self.Expression(doc="Backwashing flow rate") - def bw_flow(b): - return ( - pyunits.convert(b.bw_rate, to_units=pyunits.m / pyunits.s) - * (b.bed_vol / b.bed_depth) - * b.number_columns - ) - - @self.Expression(doc="Rinse flow rate") - def rinse_flow(b): - return b.vel_bed * (b.bed_vol / b.bed_depth) * b.number_columns - @self.Expression(doc="Rinse time") def t_rinse(b): return b.ebct * b.rinse_bv @@ -845,36 +790,57 @@ def t_rinse(b): def t_waste(b): return b.t_regen + b.t_bw + b.t_rinse - @self.Expression(doc="Cycle time") - def t_cycle(b): - return b.t_breakthru + b.t_waste + if self.config.regenerant == RegenerantChem.single_use: + self.t_regen.set_value(0) + self.service_to_regen_flow_ratio.set_value(0) - @self.Expression(doc="Volume per column") - def col_vol_per(b): - return b.col_height * (b.bed_vol / b.bed_depth) + if self.config.regenerant != RegenerantChem.single_use: - @self.Expression(doc="Total column volume required") - def col_vol_tot(b): - return b.number_columns * b.col_vol_per + # If resin is not single use, add regeneration - @self.Expression( - doc="Bed volumes at breakthrough", - ) - def bv_calc(b): - return (b.vel_bed * b.t_breakthru) / b.bed_depth + @self.Expression(doc="Regen pump power") + def regen_pump_power(b): + return pyunits.convert( + ( + b.pressure_drop + * ( + prop_in.flow_vol_phase["Liq"] + / b.service_to_regen_flow_ratio + ) + ) + / b.pump_efficiency, + to_units=pyunits.kilowatts, + ) + + @self.Expression(doc="Regen tank volume") + def regen_tank_vol(b): + return ( + prop_in.flow_vol_phase["Liq"] / b.service_to_regen_flow_ratio + ) * b.t_regen - @self.Expression(doc="Regen tank volume") - def regen_tank_vol(b): + @self.Expression(doc="Backwashing flow rate") + def bw_flow(b): return ( - prop_in.flow_vol_phase["Liq"] / b.service_to_regen_flow_ratio - ) * b.t_regen + pyunits.convert(b.bw_rate, to_units=pyunits.m / pyunits.s) + * (b.bed_vol / b.bed_depth) + * b.number_columns + ) - @self.Expression(doc="Pressure drop") - def pressure_drop(b): - vel_bed = pyunits.convert(b.vel_bed, to_units=pyunits.m / pyunits.hr) + @self.Expression(doc="Bed expansion fraction from backwashing") + def bed_expansion_frac(b): return ( - b.p_drop_A + b.p_drop_B * vel_bed + b.p_drop_C * vel_bed**2 - ) * b.bed_depth # for 20C; + b.bed_expansion_frac_A + + b.bed_expansion_frac_B * b.bw_rate + + b.bed_expansion_frac_C * b.bw_rate**2 + ) # for 20C + + @self.Expression(doc="Rinse flow rate") + def rinse_flow(b): + return b.vel_bed * (b.bed_vol / b.bed_depth) * b.number_columns + + @self.Expression(doc="Cycle time") + def t_cycle(b): + return b.t_breakthru + b.t_waste @self.Expression(doc="Backwash pump power") def bw_pump_power(b): @@ -890,17 +856,31 @@ def rinse_pump_power(b): to_units=pyunits.kilowatts, ) - @self.Expression(doc="Rinse pump power") - def regen_pump_power(b): - return pyunits.convert( - ( - b.pressure_drop - * (prop_in.flow_vol_phase["Liq"] / b.service_to_regen_flow_ratio) - ) - / b.pump_efficiency, - to_units=pyunits.kilowatts, + @self.Constraint( + self.target_ion_set, doc="Mass transfer for regeneration stream" + ) + def eq_mass_transfer_regen(b, j): + return ( + regen.get_material_flow_terms("Liq", j) + == -b.process_flow.mass_transfer_term[0, "Liq", j] ) + @self.Constraint( + doc="Isothermal assumption for regen stream", + ) + def eq_isothermal_regen_stream(b): + return prop_in.temperature == regen.temperature + + @self.Constraint( + doc="Isobaric assumption for regen stream", + ) + def eq_isobaric_regen_stream(b): + return prop_in.pressure == regen.pressure + + @self.Expression(doc="Bed expansion from backwashing") + def bed_expansion_h(b): + return b.bed_expansion_frac * b.bed_depth + @self.Expression(doc="Main pump power") def main_pump_power(b): return pyunits.convert( @@ -908,8 +888,30 @@ def main_pump_power(b): to_units=pyunits.kilowatts, ) + @self.Expression(doc="Volume per column") + def col_vol_per(b): + return b.col_height * (b.bed_vol / b.bed_depth) + + @self.Expression(doc="Total column volume required") + def col_vol_tot(b): + return b.number_columns * b.col_vol_per + + @self.Expression(doc="Contact time") + def t_contact(b): + return b.ebct * b.bed_porosity + + @self.Expression(doc="Interstitial velocity") + def vel_inter(b): + return b.vel_bed / b.bed_porosity + if self.config.isotherm == IsothermType.langmuir: + @self.Expression( + doc="Bed volumes at breakthrough", + ) + def bv_calc(b): + return (b.vel_bed * b.t_breakthru) / b.bed_depth + @self.Expression(doc="Left hand side of constant pattern sol'n") def lh(b): return b.num_transfer_units * (b.dimensionless_time - 1) @@ -939,55 +941,8 @@ def HTU(b, j): * b.rate_coeff[j] ) - if self.config.isotherm == IsothermType.freundlich: - - @self.Expression( - self.target_ion_set, doc="Removed total mass of ion at resin exhaustion" - ) # Croll et al (2023), Eq.16 - def mass_removed_total(b, j): - return (prop_in.flow_mass_phase_comp["Liq", j] / b.kinetic_param) * log( - b.bed_capacity_param + 1 - ) - - @self.Expression( - self.target_ion_set, doc="Freundlich base coeff estimation" - ) - def freundlich_k(b, j): - mass_bed = pyunits.convert( - b.bed_vol_tot * b.resin_bulk_dens, to_units=pyunits.kg - ) - return b.mass_removed_total[j] / ( - mass_bed - * prop_in.conc_mass_phase_comp["Liq", j] ** (1 / b.freundlich_n) - ) - # ==========CONSTRAINTS========== - @self.Constraint( - self.target_ion_set, doc="Mass transfer for regeneration stream" - ) - def eq_mass_transfer_regen(b, j): - return ( - regen.get_material_flow_terms("Liq", j) - == -b.process_flow.mass_transfer_term[0, "Liq", j] - ) - - @self.Constraint( - doc="Isothermal assumption for regen stream", - ) - def eq_isothermal_regen_stream(b): - return prop_in.temperature == regen.temperature - - @self.Constraint( - doc="Isobaric assumption for regen stream", - ) - def eq_isobaric_regen_stream(b): - return prop_in.pressure == regen.pressure - - for j in inerts: - self.process_flow.mass_transfer_term[:, "Liq", j].fix(0) - self.regeneration_stream[0].get_material_flow_terms("Liq", j).fix(0) - # =========== DIMENSIONLESS =========== @self.Constraint(doc="Reynolds number") @@ -1021,21 +976,13 @@ def eq_Pe_p(b): # Eq. 3.313, Inglezakis + Poulopoulos, for downflow # =========== RESIN & COLUMN =========== - @self.Constraint(doc="Interstitial velocity") - def eq_vel_inter(b): - return b.vel_inter == b.vel_bed / b.bed_porosity - @self.Constraint(doc="Resin bead surface area per volume") def eq_resin_surf_per_vol(b): return b.resin_surf_per_vol == (6 * (1 - b.bed_porosity)) / b.resin_diam @self.Constraint(doc="Empty bed contact time") def eq_ebct(b): - return b.ebct == b.bed_depth / b.vel_bed - - @self.Constraint(doc="Contact time") - def eq_t_contact(b): - return b.t_contact == b.ebct * b.bed_porosity + return b.ebct * b.vel_bed == b.bed_depth @self.Constraint(doc="Service flow rate") def eq_service_flow_rate(b): @@ -1067,7 +1014,7 @@ def eq_bed_design(b): def eq_col_height_to_diam_ratio(b): return b.col_height_to_diam_ratio * b.col_diam == b.col_height - # =========== MASS BALANCE =========== + # =========== ISOTHERM SPECIFIC =========== if self.config.isotherm == IsothermType.langmuir: @@ -1157,20 +1104,9 @@ def eq_constant_pattern_soln( if self.config.isotherm == IsothermType.freundlich: - @self.Constraint(self.target_ion_set, doc="Breakthrough concentration") - def eq_c_breakthru(b, j): - return ( - b.c_norm[j] - == b.c_breakthru[j] / prop_in.conc_mass_phase_comp["Liq", j] - ) - - @self.Constraint( - doc="Mass transfer coefficient from Clark equation (kT)", - ) # Croll et al (2023), Eq.19 - def eq_mass_transfer_coeff(b): - return b.mass_transfer_coeff * (b.freundlich_n - 1) == ( - b.kinetic_param * b.bv_50 - ) + @self.Expression(self.target_ion_set, doc="Breakthrough concentration") + def c_breakthru(b, j): + return b.c_norm[j] * prop_in.conc_mass_phase_comp["Liq", j] @self.Constraint(doc="Bed volumes at breakthrough") def eq_bv(b): @@ -1179,34 +1115,17 @@ def eq_bv(b): @self.Constraint( self.target_ion_set, doc="Clark equation with fundamental constants" ) # Croll et al (2023), Eq.9 - def eq_clark_1(b, j): - c0 = prop_in.conc_mass_phase_comp["Liq", j] - cb = b.c_breakthru[j] - denom = ( - 1 - + (2 ** (b.freundlich_n - 1) - 1) - * exp( - ( - (b.mass_transfer_coeff * b.bed_depth * (b.freundlich_n - 1)) - / (b.bv_50 * b.vel_bed) - ) - * (b.bv_50 - b.bv) - ) - ) ** (1 / (b.freundlich_n - 1)) - return c0 == denom * cb - - @self.Constraint( - self.target_ion_set, doc="Clark equation for fitting" - ) # Croll et al (2023), Eq.12 - def eq_clark_2(b, j): - c0 = prop_in.conc_mass_phase_comp["Liq", j] - cb = b.c_breakthru[j] - denom = ( - 1 - + b.bed_capacity_param - * exp((-b.kinetic_param * b.bed_depth * b.bv) / b.vel_bed) - ) ** (1 / (b.freundlich_n - 1)) - return c0 == denom * cb + def eq_clark(b, j): + left_side = ( + (b.mass_transfer_coeff * b.bed_depth * (b.freundlich_n - 1)) + / (b.bv_50 * b.vel_bed) + ) * (b.bv_50 - b.bv) + + right_side = log( + ((1 / b.c_norm[j]) ** (b.freundlich_n - 1) - 1) + / (2 ** (b.freundlich_n - 1) - 1) + ) + return left_side - right_side == 0 @self.Constraint( self.target_ion_set, @@ -1218,16 +1137,22 @@ def eq_c_traps(b, j, k): (b.c_norm[j] - b.c_trap_min) / (b.num_traps - 1) ) - # b.ebct == b.bed_depth / b.vel_bed @self.Constraint( self.trap_index, doc="Breakthru time calc for trapezoids", ) def eq_tb_traps(b, k): - x = 1 / b.c_traps[k] - return b.tb_traps[k] == (1 / -b.kinetic_param) * log( - (x ** (b.freundlich_n - 1) - 1) / b.bed_capacity_param + bv_traps = (b.tb_traps[k] * b.vel_bed) / b.bed_depth + left_side = ( + (b.mass_transfer_coeff * b.bed_depth * (b.freundlich_n - 1)) + / (b.bv_50 * b.vel_bed) + ) * (b.bv_50 - bv_traps) + + right_side = log( + ((1 / b.c_traps[k]) ** (b.freundlich_n - 1) - 1) + / (2 ** (b.freundlich_n - 1) - 1) ) + return left_side - right_side == 0 @self.Constraint(self.trap_index, doc="Area of trapezoids") def eq_traps(b, k): @@ -1286,6 +1211,7 @@ def initialize_build( hold_state=True, ) init_log.info("Initialization Step 1a Complete.") + # --------------------------------------------------------------------- # Initialize other state blocks # Set state_args from inlet state @@ -1306,7 +1232,7 @@ def initialize_build( state_args_out = deepcopy(state_args) for p, j in self.process_flow.properties_out.phase_component_set: - if j == self.config.target_ion: + if j in self.target_ion_set: state_args_out["flow_mol_phase_comp"][(p, j)] = ( state_args["flow_mol_phase_comp"][(p, j)] * 1e-3 ) @@ -1411,18 +1337,9 @@ def calculate_scaling_factors(self): if iscale.get_scaling_factor(self.ebct) is None: iscale.set_scaling_factor(self.ebct, 1e-2) - if iscale.get_scaling_factor(self.t_contact) is None: - iscale.set_scaling_factor(self.t_contact, 1e-2) - if iscale.get_scaling_factor(self.vel_bed) is None: iscale.set_scaling_factor(self.vel_bed, 1e3) - if iscale.get_scaling_factor(self.vel_inter) is None: - iscale.set_scaling_factor(self.vel_inter, 1e3) - - if iscale.get_scaling_factor(self.regen_dose) is None: - iscale.set_scaling_factor(self.regen_dose, 1e-2) - # unique scaling for isotherm type if isotherm == IsothermType.langmuir: if iscale.get_scaling_factor(self.resin_max_capacity) is None: @@ -1454,21 +1371,15 @@ def calculate_scaling_factors(self): if iscale.get_scaling_factor(self.freundlich_n) is None: iscale.set_scaling_factor(self.freundlich_n, 0.1) - if iscale.get_scaling_factor(self.c_breakthru) is None: - iscale.set_scaling_factor(self.c_breakthru, 1e4) - - if iscale.get_scaling_factor(self.kinetic_param) is None: - iscale.set_scaling_factor(self.kinetic_param, 1e7) - - if iscale.get_scaling_factor(self.bed_capacity_param) is None: - iscale.set_scaling_factor(self.bed_capacity_param, 0.1) - if iscale.get_scaling_factor(self.mass_transfer_coeff) is None: - iscale.set_scaling_factor(self.mass_transfer_coeff, 1e4) + iscale.set_scaling_factor(self.mass_transfer_coeff, 10) if iscale.get_scaling_factor(self.bv_50) is None: iscale.set_scaling_factor(self.bv_50, 1e-5) + if iscale.get_scaling_factor(self.bv) is None: + iscale.set_scaling_factor(self.bv, 1e-5) + if iscale.get_scaling_factor(self.tb_traps) is None: sf = iscale.get_scaling_factor(self.t_breakthru) iscale.set_scaling_factor(self.tb_traps, sf) @@ -1504,23 +1415,17 @@ def calculate_scaling_factors(self): iscale.constraint_scaling_transform(c, sf) if isotherm == IsothermType.freundlich: - for ind, c in self.eq_clark_2.items(): - if iscale.get_scaling_factor(c) is None: - iscale.constraint_scaling_transform(c, 1e6) - for ind, c in self.eq_clark_1.items(): + for ind, c in self.eq_clark.items(): if iscale.get_scaling_factor(c) is None: - iscale.constraint_scaling_transform(c, 1e6) + iscale.constraint_scaling_transform(c, 1e-2) for ind, c in self.eq_traps.items(): if iscale.get_scaling_factor(c) is None: iscale.constraint_scaling_transform(c, 1e2) - for ind, c in self.eq_vel_inter.items(): - sf = iscale.get_scaling_factor(self.vel_inter) - iscale.constraint_scaling_transform(c, sf) - def _get_stream_table_contents(self, time_point=0): + return create_stream_table_dataframe( { "Feed Inlet": self.inlet, @@ -1537,11 +1442,9 @@ def _get_performance_contents(self, time_point=0): var_dict = {} var_dict["Breakthrough Time"] = self.t_breakthru var_dict["EBCT"] = self.ebct - var_dict["Contact Time"] = self.t_contact var_dict[f"Relative Breakthrough Conc. [{target_ion}]"] = self.c_norm[ target_ion ] - var_dict["Regen Dose"] = self.regen_dose var_dict["Number Columns"] = self.number_columns var_dict["Bed Volume Total"] = self.bed_vol_tot var_dict["Bed Depth"] = self.bed_depth @@ -1568,15 +1471,10 @@ def _get_performance_contents(self, time_point=0): f"Fluid Mass Transfer Coeff. [{target_ion}]" ] = self.fluid_mass_transfer_coeff[target_ion] elif self.config.isotherm == IsothermType.freundlich: - var_dict[f"Breakthrough Conc. [{target_ion}]"] = self.c_breakthru[ - target_ion - ] var_dict[f"BV at Breakthrough"] = self.bv var_dict[f"BV at 50% Breakthrough"] = self.bv_50 var_dict[f"Freundlich n"] = self.freundlich_n var_dict[f"Clark Mass Transfer Coeff."] = self.mass_transfer_coeff - var_dict[f"Clark Bed Capacity Param."] = self.bed_capacity_param - var_dict[f"Clark Kinetic Param."] = self.kinetic_param return {"vars": var_dict} diff --git a/watertap/unit_models/tests/test_ion_exchange_0D.py b/watertap/unit_models/tests/test_ion_exchange_0D.py index 55793d698a..518befae5b 100644 --- a/watertap/unit_models/tests/test_ion_exchange_0D.py +++ b/watertap/unit_models/tests/test_ion_exchange_0D.py @@ -85,6 +85,10 @@ def IX_lang(self): hold_state=True, ) + ix.process_flow.properties_in[0].flow_mass_phase_comp[...] + ix.process_flow.properties_out[0].flow_mass_phase_comp[...] + ix.regeneration_stream[0].flow_mass_phase_comp[...] + ix.service_flow_rate.fix(15) ix.langmuir[target_ion].fix(0.9) ix.resin_max_capacity.fix(3) @@ -94,7 +98,6 @@ def IX_lang(self): ix.resin_diam.fix() ix.resin_bulk_dens.fix() ix.bed_porosity.fix() - ix.regen_dose.fix() return m @@ -137,8 +140,6 @@ def test_default_build(self, IX_lang): # test unit objects ix_params = [ - "underdrain_h", - "distributor_h", "Pe_p_A", "Pe_p_exp", "Sh_A", @@ -148,16 +149,18 @@ def test_default_build(self, IX_lang): "bed_expansion_frac_A", "bed_expansion_frac_B", "bed_expansion_frac_C", + "bw_rate", + "distributor_h", + "number_columns_redund", "p_drop_A", "p_drop_B", "p_drop_C", "pump_efficiency", - "t_regen", "rinse_bv", - "bw_rate", - "t_bw", "service_to_regen_flow_ratio", - "number_columns_redund", + "t_bw", + "t_regen", + "underdrain_h", ] for p in ix_params: @@ -166,38 +169,35 @@ def test_default_build(self, IX_lang): assert isinstance(param, Param) ix_vars = [ - "resin_diam", - "resin_bulk_dens", - "resin_surf_per_vol", - "regen_dose", - "c_norm", - "col_height_to_diam_ratio", - "bed_vol_tot", + "N_Pe_bed", + "N_Pe_particle", + "N_Re", + "N_Sc", + "N_Sh", "bed_depth", "bed_porosity", - "col_height", + "bed_vol_tot", + "c_norm", "col_diam", - "number_columns", - "t_breakthru", - "t_contact", + "col_height", + "col_height_to_diam_ratio", + "dimensionless_time", "ebct", - "vel_bed", - "vel_inter", - "service_flow_rate", - "N_Re", - "N_Sc", - "N_Sh", - "N_Pe_particle", - "N_Pe_bed", - "resin_max_capacity", - "resin_eq_capacity", - "resin_unused_capacity", + "fluid_mass_transfer_coeff", "langmuir", "mass_removed", "num_transfer_units", - "dimensionless_time", + "number_columns", "partition_ratio", - "fluid_mass_transfer_coeff", + "resin_bulk_dens", + "resin_diam", + "resin_eq_capacity", + "resin_max_capacity", + "resin_surf_per_vol", + "resin_unused_capacity", + "service_flow_rate", + "t_breakthru", + "vel_bed", ] for v in ix_vars: @@ -206,9 +206,9 @@ def test_default_build(self, IX_lang): assert isinstance(var, Var) # test statistics - assert number_variables(m) == 69 - assert number_total_constraints(m) == 42 - assert number_unused_variables(m) == 12 + assert number_variables(m) == 70 + assert number_total_constraints(m) == 44 + assert number_unused_variables(m) == 10 @pytest.mark.unit def test_dof(self, IX_lang): @@ -244,52 +244,100 @@ def test_solve(self, IX_lang): # Check for optimal solution assert_optimal_termination(results) + @pytest.mark.requires_idaes_solver @pytest.mark.component - def test_solution(self, IX_lang): + def test_mass_balance(self, IX_lang): m = IX_lang + ix = m.fs.ix - target_ion = ix.config.target_ion + target = ix.config.target_ion + pf = ix.process_flow + prop_in = pf.properties_in[0] + prop_out = pf.properties_out[0] + regen = ix.regeneration_stream[0] + + assert value(prop_in.flow_mass_phase_comp["Liq", target]) == pytest.approx( + value(prop_out.flow_mass_phase_comp["Liq", target]) + + value(regen.flow_mass_phase_comp["Liq", target]), + rel=1e-3, + ) + + assert value(prop_in.flow_mass_phase_comp["Liq", "H2O"]) == pytest.approx( + value(prop_out.flow_mass_phase_comp["Liq", "H2O"]), + rel=1e-3, + ) + assert -1 * value(pf.mass_transfer_term[0, "Liq", target]) == pytest.approx( + value(regen.flow_mol_phase_comp["Liq", target]), rel=1e-3 + ) + + @pytest.mark.component + def test_solution(self, IX_lang): + m = IX_lang + + # results for all Var and Expressions on unit model results_dict = { - "resin_max_capacity": 3, - "resin_eq_capacity": 1.5547810762853227, - "resin_unused_capacity": 1.4452189237146773, "resin_diam": 0.0007, "resin_bulk_dens": 0.7, - "langmuir": 0.9, - "num_transfer_units": 35.54838294744622, - "dimensionless_time": 1, - "resin_surf_per_vol": 4285.714285714286, - "col_height_to_diam_ratio": 1.0408526790314099, - "bed_vol_tot": 120.00000000000003, + "resin_surf_per_vol": 4285.714, + "c_norm": {"Ca_2+": 0.4919}, + "bed_vol_tot": 120.0, "bed_depth": 1.7, "bed_porosity": 0.5, - "col_height": 3.488715, - "col_diam": 3.3517855795370646, + "col_height": 3.488, + "col_diam": 3.351, + "col_height_to_diam_ratio": 1.0408, "number_columns": 8, - "partition_ratio": 217.66935067994518, - "fluid_mass_transfer_coeff": 3.456092786557271e-05, - "t_breakthru": 52360.64416318684, - "t_contact": 120.0, - "mass_removed": 65300.80520398353, - "vel_bed": 0.007083333333333333, - "vel_inter": 0.014166666666666666, + "t_breakthru": 52360.644, + "ebct": 240.0, + "vel_bed": 0.007083, "service_flow_rate": 15, - "N_Re": 4.958333333333333, - "N_Sc": 1086.9565217391305, - "N_Sh": 26.29635815858793, - "N_Pe_particle": 0.10782790064157834, - "N_Pe_bed": 261.86775870097597, - "c_norm": 0.4919290557789296, - "regen_dose": 300, + "N_Re": 4.958, + "N_Sc": {"Ca_2+": 1086.9565}, + "N_Sh": {"Ca_2+": 26.296}, + "N_Pe_particle": 0.10782, + "N_Pe_bed": 261.8677, + "resin_max_capacity": 3, + "resin_eq_capacity": 1.554, + "resin_unused_capacity": 1.445, + "langmuir": {"Ca_2+": 0.9}, + "mass_removed": {"Ca_2+": 65300.8052}, + "num_transfer_units": 35.5483, + "dimensionless_time": 1, + "partition_ratio": 217.669, + "fluid_mass_transfer_coeff": {"Ca_2+": 3.45609e-05}, + "pressure_drop": 9.450, + "bed_vol": 15.0, + "t_rinse": 1200.0, + "t_waste": 3600.0, + "regen_pump_power": 13.574, + "regen_tank_vol": 300.0, + "bw_flow": 0.09803, + "bed_expansion_frac": 0.46395, + "rinse_flow": 0.5, + "t_cycle": 55960.6441, + "bw_pump_power": 7.984, + "rinse_pump_power": 40.722, + "bed_expansion_h": 0.788, + "main_pump_power": 40.722, + "col_vol_per": 30.7827, + "col_vol_tot": 246.2622, + "t_contact": 120.0, + "vel_inter": 0.01416, + "bv_calc": 218.169, + "lh": 0.0, + "separation_factor": {"Ca_2+": 1.11111}, + "rate_coeff": {"Ca_2+": 0.00021159}, + "HTU": {"Ca_2+": 0.04782}, } - for v, val in results_dict.items(): - var = getattr(ix, v) - if var.is_indexed(): - assert pytest.approx(val, rel=1e-3) == value(var[target_ion]) + for v, r in results_dict.items(): + ixv = getattr(m.fs.ix, v) + if ixv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(ixv[i]) else: - assert pytest.approx(val, rel=1e-3) == value(var) + assert pytest.approx(r, rel=1e-3) == value(ixv) @pytest.mark.component def test_costing(self, IX_lang): @@ -301,25 +349,58 @@ def test_costing(self, IX_lang): m.fs.costing.cost_process() m.fs.costing.add_LCOW(ix.process_flow.properties_out[0].flow_vol_phase["Liq"]) m.fs.costing.add_specific_energy_consumption( - ix.process_flow.properties_out[0].flow_vol_phase["Liq"] + ix.process_flow.properties_out[0].flow_vol_phase["Liq"], name="SEC" ) results = solver.solve(m, tee=True) assert_optimal_termination(results) - assert pytest.approx(2.0 * 8894349.86900 / 1.65, rel=1e-3) == value( - m.fs.costing.aggregate_capital_cost - ) - assert pytest.approx(2288575.0472, rel=1e-3) == value( - m.fs.costing.total_operating_cost - ) - assert pytest.approx(17788699.7380 / 1.65, rel=1e-3) == value( - m.fs.costing.total_capital_cost - ) - assert pytest.approx(0.2370983, rel=1e-3) == value(m.fs.costing.LCOW) - assert pytest.approx(0.0572452, rel=1e-3) == value( - m.fs.costing.specific_energy_consumption - ) + sys_cost_results = { + "aggregate_capital_cost": 3993072.469, + "aggregate_fixed_operating_cost": 36893.314, + "aggregate_variable_operating_cost": 0.0, + "aggregate_flow_electricity": 103.00, + "aggregate_flow_NaCl": 22838957.969, + "aggregate_flow_costs": { + "electricity": 63205.718, + "NaCl": 2079295.202, + }, + "total_capital_cost": 3993072.4698, + "total_operating_cost": 2084936.317, + "LCOW": 0.17495, + "SEC": 0.0572, + } + + for v, r in sys_cost_results.items(): + mv = getattr(m.fs.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) + + ix_cost_results = { + "capital_cost": 3993072.4698, + "fixed_operating_cost": 36893.314, + "capital_cost_vessel": 101131.881, + "capital_cost_resin": 81985.1430, + "capital_cost_regen_tank": 215778.261, + "capital_cost_backwash_tank": 132704.7555, + "operating_cost_hazardous": 0, + "flow_mass_regen_soln": 22838957.969, + "total_pumping_power": 103.00465, + "backwash_tank_vol": 174042.7639, + "regeneration_tank_vol": 79251.61570, + "direct_capital_cost": 1996536.234, + } + + for v, r in ix_cost_results.items(): + mv = getattr(m.fs.ix.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) class TestIonExchangeFreundlich: @@ -347,7 +428,6 @@ def IX_fr(self): } m.fs.ix = ix = IonExchange0D(**ix_config) - # c0 = pyunits.convert(c0, to_units=pyunits.kg / pyunits.m**3) ix.process_flow.properties_in.calculate_state( var_args={ ("flow_vol_phase", "Liq"): 0.5, @@ -358,11 +438,14 @@ def IX_fr(self): hold_state=True, ) + ix.process_flow.properties_in[0].flow_mass_phase_comp[...] + ix.process_flow.properties_out[0].flow_mass_phase_comp[...] + ix.regeneration_stream[0].flow_mass_phase_comp[...] + ix.freundlich_n.fix(1.2) ix.bv_50.fix(20000) ix.bv.fix(18000) ix.resin_bulk_dens.fix(0.72) - ix.regen_dose.fix() ix.bed_porosity.fix() ix.vel_bed.fix(6.15e-3) ix.resin_diam.fix(6.75e-4) @@ -393,7 +476,7 @@ def test_config(self, IX_fr): assert m.fs.ix.config.momentum_balance_type is MomentumBalanceType.pressureTotal @pytest.mark.unit - def test_default_build(self, IX_fr): + def test_fr_build(self, IX_fr): m = IX_fr ix = m.fs.ix # test ports and variables @@ -412,8 +495,6 @@ def test_default_build(self, IX_fr): # test unit objects ix_params = [ - "underdrain_h", - "distributor_h", "Pe_p_A", "Pe_p_exp", "Sh_A", @@ -423,17 +504,19 @@ def test_default_build(self, IX_fr): "bed_expansion_frac_A", "bed_expansion_frac_B", "bed_expansion_frac_C", + "bw_rate", + "c_trap_min", + "distributor_h", + "number_columns_redund", "p_drop_A", "p_drop_B", "p_drop_C", "pump_efficiency", - "t_regen", "rinse_bv", - "bw_rate", - "t_bw", "service_to_regen_flow_ratio", - "number_columns_redund", - "c_trap_min", + "t_bw", + "t_regen", + "underdrain_h", ] for p in ix_params: @@ -442,40 +525,34 @@ def test_default_build(self, IX_fr): assert isinstance(param, Param) ix_vars = [ - "resin_diam", - "resin_bulk_dens", - "resin_surf_per_vol", - "regen_dose", - "c_norm", - "bed_vol_tot", + "N_Pe_bed", + "N_Pe_particle", + "N_Re", + "N_Sc", + "N_Sh", "bed_depth", "bed_porosity", - "col_height", + "bed_vol_tot", + "bv", + "bv_50", + "c_norm", + "c_norm_avg", + "c_traps", "col_diam", + "col_height", "col_height_to_diam_ratio", - "number_columns", - "t_breakthru", - "t_contact", "ebct", - "vel_bed", - "vel_inter", + "freundlich_n", + "mass_transfer_coeff", + "number_columns", + "resin_bulk_dens", + "resin_diam", + "resin_surf_per_vol", "service_flow_rate", - "N_Re", - "N_Sc", - "N_Sh", - "N_Pe_particle", - "N_Pe_bed", - "c_traps", + "t_breakthru", "tb_traps", "traps", - "c_norm_avg", - "c_breakthru", - "freundlich_n", - "mass_transfer_coeff", - "bv", - "bv_50", - "bed_capacity_param", - "kinetic_param", + "vel_bed", ] for v in ix_vars: @@ -484,9 +561,9 @@ def test_default_build(self, IX_fr): assert isinstance(var, Var) # test statistics - assert number_variables(m) == 82 - assert number_total_constraints(m) == 52 - assert number_unused_variables(m) == 13 + assert number_variables(m) == 80 + assert number_total_constraints(m) == 51 + assert number_unused_variables(m) == 11 @pytest.mark.unit def test_dof(self, IX_fr): @@ -525,77 +602,34 @@ def test_solve(self, IX_fr): @pytest.mark.requires_idaes_solver @pytest.mark.component - def test_solution(self, IX_fr): + def test_mass_balance(self, IX_fr): m = IX_fr + ix = m.fs.ix - target_ion = ix.config.target_ion - results_dict = { - "resin_diam": 0.0006749999999999999, - "resin_bulk_dens": 0.72, - "resin_surf_per_vol": 4444.444444444445, - "regen_dose": 300, - "c_norm": {"Cl_-": 0.25}, - "bed_vol_tot": 120.00000000000001, - "bed_depth": 1.476, - "bed_porosity": 0.5, - "col_height": 3.1607902, - "col_diam": 2.5435630784033814, - "col_height_to_diam_ratio": 1.242662400172933, - "number_columns": 16, - "t_breakthru": 4320000.0, - "t_contact": 120.00000000000001, - "ebct": 240.00000000000003, - "vel_bed": 0.006149999999999999, - "vel_inter": 0.012299999999999998, - "service_flow_rate": 15, - "N_Re": 4.151249999999999, - "N_Sc": {"Cl_-": 999.9999999999998}, - "N_Sh": {"Cl_-": 24.083093218519274}, - "N_Pe_particle": 0.09901383248136636, - "N_Pe_bed": 216.51024702592113, - "c_traps": { - 0: 0, - 1: 0.01, - 2: 0.06999999999999999, - 3: 0.13, - 4: 0.19, - 5: 0.25, - }, - "tb_traps": { - 0: 0, - 1: 3344557.580567041, - 2: 3825939.1495324974, - 3: 4034117.3864088757, - 4: 4188551.111889635, - 5: 4320000.000000004, - }, - "traps": { - 1: 0.003871015718248886, - 2: 0.0044572367496801485, - 3: 0.004818940668434689, - 4: 0.005719767610398479, - 5: 0.00669415633895397, - }, - "c_norm_avg": {"Cl_-": 0.02556111708571618}, - "c_breakthru": {"Cl_-": 2.500000002016729e-07}, - "freundlich_n": 1.2, - "mass_transfer_coeff": 0.159346300525143, - "bv": 18000, - "bv_50": 20000, - "bed_capacity_param": 311.9325370632754, - "kinetic_param": 1.5934630052514297e-06, - } + target = ix.config.target_ion + pf = ix.process_flow + prop_in = pf.properties_in[0] + prop_out = pf.properties_out[0] + regen = ix.regeneration_stream[0] + + assert value(prop_in.flow_mass_phase_comp["Liq", target]) == pytest.approx( + value(prop_out.flow_mass_phase_comp["Liq", target]) + + value(regen.flow_mass_phase_comp["Liq", target]), + rel=1e-3, + ) - for k, v in results_dict.items(): - var = getattr(ix, k) - if isinstance(v, dict): - for i, u in v.items(): - assert pytest.approx(u, rel=1e-3) == value(var[i]) - else: - assert pytest.approx(v, rel=1e-3) == value(var) + assert value(prop_in.flow_mass_phase_comp["Liq", "H2O"]) == pytest.approx( + value(prop_out.flow_mass_phase_comp["Liq", "H2O"]), + rel=1e-3, + ) + assert -1 * value(pf.mass_transfer_term[0, "Liq", target]) == pytest.approx( + value(regen.flow_mol_phase_comp["Liq", target]), rel=1e-3 + ) + + @pytest.mark.requires_idaes_solver @pytest.mark.component - def test_costing(self, IX_fr): + def test_solution(self, IX_fr): m = IX_fr ix = m.fs.ix @@ -604,26 +638,64 @@ def test_costing(self, IX_fr): m.fs.costing.cost_process() m.fs.costing.add_LCOW(ix.process_flow.properties_out[0].flow_vol_phase["Liq"]) m.fs.costing.add_specific_energy_consumption( - ix.process_flow.properties_out[0].flow_vol_phase["Liq"] + ix.process_flow.properties_out[0].flow_vol_phase["Liq"], name="SEC" ) ix.initialize() results = solver.solve(m, tee=True) assert_optimal_termination(results) - assert pytest.approx(2.0 * 9701947.4187 / 1.65, rel=1e-3) == value( - m.fs.costing.aggregate_capital_cost - ) - assert pytest.approx(1219532.1263, rel=1e-3) == value( - m.fs.costing.total_operating_cost - ) - assert pytest.approx(19403894.837 / 1.65, rel=1e-3) == value( - m.fs.costing.total_capital_cost - ) - assert pytest.approx(0.168688, rel=1e-3) == value(m.fs.costing.LCOW) - assert pytest.approx(0.04382530, rel=1e-3) == value( - m.fs.costing.specific_energy_consumption - ) + sys_cost_results = { + "aggregate_capital_cost": 5116213.693, + "aggregate_fixed_operating_cost": 323306.067, + "aggregate_variable_operating_cost": 0.0, + "aggregate_flow_electricity": 78.865, + "aggregate_flow_NaOH": 279183.597, + "aggregate_flow_costs": { + "electricity": 48393.3348, + "NaOH": 555415.521, + }, + "total_capital_cost": 5116213.693, + "total_operating_cost": 1020220.449, + "aggregate_direct_capital_cost": 2558106.846, + "maintenance_labor_chemical_operating_cost": 153486.410, + "total_fixed_operating_cost": 476792.478, + "total_variable_operating_cost": 543427.970, + "total_annualized_cost": 1531841.818, + "LCOW": 0.1078691, + "SEC": 0.043814, + } + + for v, r in sys_cost_results.items(): + mv = getattr(m.fs.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) + + ix_cost_results = { + "capital_cost": 5116213.6933, + "fixed_operating_cost": 323306.0679, + "capital_cost_vessel": 75000.3204, + "capital_cost_resin": 54924.687, + "capital_cost_regen_tank": 215778.261, + "capital_cost_backwash_tank": 133603.4529, + "operating_cost_hazardous": 276620.0837, + "flow_mass_regen_soln": 279183.597, + "total_pumping_power": 78.865, + "backwash_tank_vol": 176401.0669, + "regeneration_tank_vol": 79251.615, + "direct_capital_cost": 2558106.846, + } + + for v, r in ix_cost_results.items(): + mv = getattr(m.fs.ix.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) class TestIonExchangeInert: @@ -646,6 +718,7 @@ def IX_inert(self): ix_config = { "property_package": m.fs.properties, "target_ion": target_ion, + "regenerant": "single_use", "isotherm": "freundlich", } m.fs.ix = ix = IonExchange0D(**ix_config) @@ -661,11 +734,14 @@ def IX_inert(self): hold_state=True, ) + ix.process_flow.properties_in[0].flow_mass_phase_comp[...] + ix.process_flow.properties_out[0].flow_mass_phase_comp[...] + ix.regeneration_stream[0].flow_mass_phase_comp[...] + ix.freundlich_n.fix(1.2) ix.bv_50.fix(20000) ix.bv.fix(18000) ix.resin_bulk_dens.fix(0.72) - ix.regen_dose.fix() ix.bed_porosity.fix() ix.vel_bed.fix(6.15e-3) ix.resin_diam.fix(6.75e-4) @@ -685,7 +761,7 @@ def test_config(self, IX_inert): assert not m.fs.ix.config.has_holdup assert m.fs.ix.config.property_package is m.fs.properties assert not m.fs.ix.config.hazardous_waste - assert m.fs.ix.config.regenerant is RegenerantChem.NaCl + assert m.fs.ix.config.regenerant is RegenerantChem.single_use assert isinstance(m.fs.ix.ion_exchange_type, IonExchangeType) assert m.fs.ix.ion_exchange_type is IonExchangeType.anion assert isinstance(m.fs.ix.config.isotherm, IsothermType) @@ -696,7 +772,7 @@ def test_config(self, IX_inert): assert m.fs.ix.config.momentum_balance_type is MomentumBalanceType.pressureTotal @pytest.mark.unit - def test_default_build(self, IX_inert): + def test_inert_build(self, IX_inert): m = IX_inert ix = m.fs.ix # test ports and variables @@ -715,8 +791,6 @@ def test_default_build(self, IX_inert): # test unit objects ix_params = [ - "underdrain_h", - "distributor_h", "Pe_p_A", "Pe_p_exp", "Sh_A", @@ -726,17 +800,19 @@ def test_default_build(self, IX_inert): "bed_expansion_frac_A", "bed_expansion_frac_B", "bed_expansion_frac_C", + "bw_rate", + "c_trap_min", + "distributor_h", + "number_columns_redund", "p_drop_A", "p_drop_B", "p_drop_C", "pump_efficiency", - "t_regen", "rinse_bv", - "bw_rate", - "t_bw", "service_to_regen_flow_ratio", - "number_columns_redund", - "c_trap_min", + "t_bw", + "t_regen", + "underdrain_h", ] for p in ix_params: @@ -745,40 +821,34 @@ def test_default_build(self, IX_inert): assert isinstance(param, Param) ix_vars = [ - "resin_diam", - "resin_bulk_dens", - "resin_surf_per_vol", - "regen_dose", - "c_norm", - "bed_vol_tot", + "N_Pe_bed", + "N_Pe_particle", + "N_Re", + "N_Sc", + "N_Sh", "bed_depth", "bed_porosity", - "col_height", + "bed_vol_tot", + "bv", + "bv_50", + "c_norm", + "c_norm_avg", + "c_traps", "col_diam", + "col_height", "col_height_to_diam_ratio", - "number_columns", - "t_breakthru", - "t_contact", "ebct", - "vel_bed", - "vel_inter", + "freundlich_n", + "mass_transfer_coeff", + "number_columns", + "resin_bulk_dens", + "resin_diam", + "resin_surf_per_vol", "service_flow_rate", - "N_Re", - "N_Sc", - "N_Sh", - "N_Pe_particle", - "N_Pe_bed", - "c_traps", + "t_breakthru", "tb_traps", "traps", - "c_norm_avg", - "c_breakthru", - "freundlich_n", - "mass_transfer_coeff", - "bv", - "bv_50", - "bed_capacity_param", - "kinetic_param", + "vel_bed", ] for v in ix_vars: @@ -788,8 +858,8 @@ def test_default_build(self, IX_inert): # test statistics assert number_variables(m) == 90 - assert number_total_constraints(m) == 56 - assert number_unused_variables(m) == 15 + assert number_total_constraints(m) == 57 + assert number_unused_variables(m) == 12 @pytest.mark.unit def test_dof(self, IX_inert): @@ -825,76 +895,122 @@ def test_solve(self, IX_inert): assert_optimal_termination(results) @pytest.mark.component - def test_solution(self, IX_inert): + def test_mass_balance(self, IX_inert): m = IX_inert + ix = m.fs.ix + target = ix.config.target_ion + inert = "Ca_2+" + pf = ix.process_flow + prop_in = pf.properties_in[0] + prop_out = pf.properties_out[0] + regen = ix.regeneration_stream[0] + + assert value(prop_in.flow_mass_phase_comp["Liq", target]) == pytest.approx( + value(prop_out.flow_mass_phase_comp["Liq", target]) + + value(regen.flow_mass_phase_comp["Liq", target]), + rel=1e-3, + ) + + assert value(prop_in.flow_mass_phase_comp["Liq", "H2O"]) == pytest.approx( + value(prop_out.flow_mass_phase_comp["Liq", "H2O"]), + rel=1e-3, + ) + + assert -1 * value(pf.mass_transfer_term[0, "Liq", target]) == pytest.approx( + value(regen.flow_mol_phase_comp["Liq", target]), rel=1e-3 + ) + + assert value(prop_in.flow_mass_phase_comp["Liq", inert]) == pytest.approx( + value(prop_out.flow_mass_phase_comp["Liq", inert]), + rel=1e-3, + ) + + assert value(pf.mass_transfer_term[0, "Liq", inert]) == 0 + + @pytest.mark.component + def test_solution(self, IX_inert): + m = IX_inert + results_dict = { - "resin_diam": 0.0006749999999999999, + "resin_diam": 0.000675, "resin_bulk_dens": 0.72, - "resin_surf_per_vol": 4444.444444444445, - "regen_dose": 300, + "resin_surf_per_vol": 4444.44, "c_norm": {"Cl_-": 0.25}, - "bed_vol_tot": 120.0, - "bed_depth": 1.4759999999999998, + "bed_vol_tot": 120, + "bed_depth": 1.476, "bed_porosity": 0.5, - "col_height": 3.1607901999999997, - "col_diam": 2.543563078403381, - "col_height_to_diam_ratio": 1.242662400172933, + "col_height": 3.1607902, + "col_diam": 2.5435, + "col_height_to_diam_ratio": 1.2426, "number_columns": 16, "t_breakthru": 4320000.0, - "t_contact": 120.0, "ebct": 240.0, - "vel_bed": 0.006149999999999999, - "vel_inter": 0.012299999999999998, + "vel_bed": 0.00615, "service_flow_rate": 15, - "N_Re": 4.151249999999999, - "N_Sc": {"Cl_-": 999.9999999999998}, - "N_Sh": {"Cl_-": 24.083093218519274}, - "N_Pe_particle": 0.09901383248136636, - "N_Pe_bed": 216.5102470259211, + "N_Re": 4.15125, + "N_Sc": {"Cl_-": 1000}, + "N_Sh": {"Cl_-": 24.0830}, + "N_Pe_particle": 0.0990, + "N_Pe_bed": 216.5102, "c_traps": { 0: 0, 1: 0.01, - 2: 0.06999999999999999, + 2: 0.07, 3: 0.13, 4: 0.19, 5: 0.25, }, "tb_traps": { 0: 0, - 1: 3344557.5805670363, - 2: 3825939.149532493, - 3: 4034117.3864088715, - 4: 4188551.1118896315, - 5: 4320000.000000001, + 1: 3344557.580, + 2: 3825939.149, + 3: 4034117.386, + 4: 4188551.111, + 5: 4320000.0, }, "traps": { - 1: 0.0038710157182488838, - 2: 0.004457236749680154, - 3: 0.004818940668434693, - 4: 0.005719767610398498, - 5: 0.0066941563389539965, + 1: 0.00387, + 2: 0.00445724, + 3: 0.00481894, + 4: 0.00571976, + 5: 0.00669, }, - "c_norm_avg": {"Cl_-": 0.025561117085716227}, - "c_breakthru": {"Cl_-": 2.5000000016147893e-07}, + "c_norm_avg": {"Cl_-": 0.02556}, "freundlich_n": 1.2, - "mass_transfer_coeff": 0.159346300525143, + "mass_transfer_coeff": 0.1593, "bv": 18000, "bv_50": 20000, - "bed_capacity_param": 311.93253706327357, - "kinetic_param": 1.59346300525143e-06, + "pressure_drop": 7.1513, + "bed_vol": 7.5, + "t_rinse": 1200.0, + "t_waste": 1800.0, + "bw_flow": 0.1129, + "bed_expansion_frac": 0.4639, + "rinse_flow": 0.5, + "t_cycle": 4321800.0, + "bw_pump_power": 6.9595, + "rinse_pump_power": 30.816, + "bed_expansion_h": 0.68479, + "main_pump_power": 30.816, + "col_vol_per": 16.060, + "col_vol_tot": 256.97, + "t_contact": 120.0, + "vel_inter": 0.0123, + "c_breakthru": {"Cl_-": 2.50e-07}, } - for k, v in results_dict.items(): - var = getattr(ix, k) - if isinstance(v, dict): - for i, u in v.items(): - assert pytest.approx(u, rel=1e-3) == value(var[i]) + for v, r in results_dict.items(): + ixv = getattr(m.fs.ix, v) + if ixv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(ixv[i]) else: - assert pytest.approx(v, rel=1e-3) == value(var) + assert pytest.approx(r, rel=1e-3) == value(ixv) @pytest.mark.component def test_costing(self, IX_inert): + m = IX_inert ix = m.fs.ix @@ -903,23 +1019,57 @@ def test_costing(self, IX_inert): m.fs.costing.cost_process() m.fs.costing.add_LCOW(ix.process_flow.properties_out[0].flow_vol_phase["Liq"]) m.fs.costing.add_specific_energy_consumption( - ix.process_flow.properties_out[0].flow_vol_phase["Liq"] + ix.process_flow.properties_out[0].flow_vol_phase["Liq"], name="SEC" ) ix.initialize() results = solver.solve(m, tee=True) assert_optimal_termination(results) - assert pytest.approx(2.0 * 9701947.4187 / 1.65, rel=1e-3) == value( - m.fs.costing.aggregate_capital_cost - ) - assert pytest.approx(465913.6619, rel=1e-3) == value( - m.fs.costing.total_operating_cost - ) - assert pytest.approx(19403894.837 / 1.65, rel=1e-3) == value( - m.fs.costing.total_capital_cost - ) - assert pytest.approx(0.115619, rel=1e-3) == value(m.fs.costing.LCOW) - assert pytest.approx(0.04382530, rel=1e-3) == value( - m.fs.costing.specific_energy_consumption - ) + sys_cost_results = { + "aggregate_capital_cost": 4485122.81, + "aggregate_fixed_operating_cost": 6419597.45, + "aggregate_variable_operating_cost": 0.0, + "aggregate_flow_electricity": 30.82, + "aggregate_flow_costs": {"electricity": 18909.78}, + "total_capital_cost": 4485122.81, + "total_operating_cost": 6571169.94, + "aggregate_direct_capital_cost": 2242561.40, + "maintenance_labor_chemical_operating_cost": 134553.68, + "total_fixed_operating_cost": 6554151.13, + "total_variable_operating_cost": 17018.80, + "total_annualized_cost": 7019682.22, + "LCOW": 0.49431179, + "SEC": 0.017120, + } + + for v, r in sys_cost_results.items(): + mv = getattr(m.fs.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv) + + ix_cost_results = { + "capital_cost": 4485122.81, + "fixed_operating_cost": 6419597.45, + "capital_cost_vessel": 75000.32, + "capital_cost_resin": 54924.68, + "capital_cost_regen_tank": 0, + "capital_cost_backwash_tank": 33836.27, + "operating_cost_hazardous": 0, + "flow_mass_regen_soln": 0, + "total_pumping_power": 30.81, + "flow_vol_resin": 876.599, + "single_use_resin_replacement_cost": 6419597.454, + "direct_capital_cost": 2242561.40, + } + + for v, r in ix_cost_results.items(): + mv = getattr(m.fs.ix.costing, v) + if mv.is_indexed(): + for i, s in r.items(): + assert pytest.approx(s, rel=1e-3) == value(mv[i]) + else: + assert pytest.approx(r, rel=1e-3) == value(mv)