Coverage for backend/ahuora-builder/src/ahuora_builder/custom/salt/crystallizer.py: 65%
208 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1"""Generic indirect-heated evaporator / crystallizer unit model for IDAES.
3This unit is intended for a *process-side* property package with liquid,
4vapor, and solid phases named ``Liq``, ``Vap``, and ``Sol``. It assumes the
5process state block exposes:
7* ``flow_mass_phase_comp``,
8* ``temperature``,
9* ``pressure``, and
10* ``get_enthalpy_flow_terms(phase)``.
12The unit also contains a *utility side* with an inlet and outlet state pair.
13This is aimed at steam / condensate service and works naturally with
14IDAES Helmholtz/IAPWS pressure-enthalpy states on ``flow_mol``, ``pressure``,
15and ``enth_mol`` state variables.
17Model structure
18---------------
191. One brine/feed inlet.
202. One concentrate outlet that contains the liquid + solid phases.
213. One vapor outlet that contains the boiled-off vapor phase.
224. Phase equilibrium is enforced directly on both process-side outlet states by
23 the process property package, while unit-level balances couple the outlets.
245. One utility inlet and one utility outlet linked by enthalpy drop.
26The model does *not* include an explicit UA/LMTD heat-transfer equation.
27Instead, it represents an ideal indirect heater where the utility-side
28enthalpy decrease exactly matches the process-side heat duty. To close the
29model, the user must provide one thermal specification, for example by fixing
30``heat_duty[t]``, fixing a utility-outlet state variable (such as outlet
31enthalpy or vapor fraction), or adding an external UA/LMTD-style relation in
32an enclosing flowsheet.
33"""
35from copy import deepcopy
37from pyomo.common.config import Bool, ConfigBlock, ConfigValue
38from pyomo.environ import Constraint, Expression, Param, Set, Var, check_optimal_termination
39from pyomo.environ import units as pyunits
40from pyomo.core.base.component import Component
41import pyomo.environ as pyo
43from idaes.core import UnitModelBlockData, declare_process_block_class, useDefault
44from idaes.core.util.config import is_physical_parameter_block
45from idaes.core.util.initialization import fix_state_vars, revert_state_vars
46from idaes.core.util.model_statistics import degrees_of_freedom
47from idaes.core.util.tables import create_stream_table_dataframe
48from idaes.core.util.exceptions import ConfigurationError, InitializationError
49import idaes.core.util.scaling as iscale
50import idaes.logger as idaeslog
51from ahuora_builder.state_args import extract_state_args
53try: # WaterTAP installs a compatible convenience wrapper.
54 from watertap.core.solvers import get_solver
55except ImportError: # pragma: no cover - fallback when WaterTAP is absent.
56 from idaes.core.solvers import get_solver
59_log = idaeslog.getLogger(__name__)
62@declare_process_block_class("Crystallizer")
63class CrystallizerData(UnitModelBlockData):
64 """Indirect-heated evaporator / crystallizer with explicit utility side."""
66 LIQUID_PHASE = "Liq"
67 VAPOR_PHASE = "Vap"
68 SOLID_PHASE = "Sol"
70 CONFIG = UnitModelBlockData.CONFIG()
72 CONFIG.declare(
73 "process_property_package",
74 ConfigValue(
75 default=useDefault,
76 domain=is_physical_parameter_block,
77 description="Process-side property package",
78 ),
79 )
80 CONFIG.declare(
81 "process_property_package_args",
82 ConfigBlock(
83 implicit=True,
84 description="Arguments used when constructing process-side state blocks",
85 ),
86 )
88 CONFIG.declare(
89 "utility_property_package",
90 ConfigValue(
91 default=useDefault,
92 domain=is_physical_parameter_block,
93 description="Utility-side property package",
94 ),
95 )
96 CONFIG.declare(
97 "utility_property_package_args",
98 ConfigBlock(
99 implicit=True,
100 description="Arguments used when constructing utility-side state blocks",
101 ),
102 )
104 CONFIG.declare(
105 "absent_phase_flow",
106 ConfigValue(
107 default=1e-6,
108 domain=float,
109 description="Small numeric flow assigned to excluded outlet phases",
110 ),
111 )
112 CONFIG.declare(
113 "has_pressure_change",
114 ConfigValue(
115 default=False,
116 domain=Bool,
117 description="Whether to include explicit process- and utility-side dP vars",
118 ),
119 )
121 def build(self):
122 super().build()
124 process_pp = self.config.process_property_package
125 utility_pp = self.config.utility_property_package
127 if process_pp is useDefault: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 raise ConfigurationError(
129 f"{self.name}: process_property_package must be provided explicitly."
130 )
132 if utility_pp is useDefault: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 raise ConfigurationError(
134 f"{self.name}: utility_property_package must be provided explicitly."
135 )
137 missing_phases = [
138 p
139 for p in (
140 self.LIQUID_PHASE,
141 self.VAPOR_PHASE,
142 self.SOLID_PHASE,
143 )
144 if p not in process_pp.phase_list
145 ]
146 if missing_phases: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 raise ConfigurationError(
148 f"{self.name}: process property package is missing expected phases: "
149 f"{missing_phases}. Available phases: {list(process_pp.phase_list)}"
150 )
152 time = self.flowsheet().time
154 self.absent_phase_flow = Param(
155 initialize=self.config.absent_phase_flow,
156 mutable=True,
157 units=pyunits.dimensionless,
158 doc="Small numeric flow used for outlet phases that are excluded by the separator",
159 )
162 # ------------------------------------------------------------------
163 # Process-side state blocks
164 proc_in_args = dict(**self.config.process_property_package_args)
165 proc_in_args.setdefault("defined_state", True)
166 proc_in_args.setdefault("has_phase_equilibrium", False)
167 self.process_in = process_pp.build_state_block(time, **proc_in_args)
169 proc_sep_args = dict(**self.config.process_property_package_args)
170 proc_sep_args.setdefault("defined_state", False)
171 proc_sep_args.setdefault("has_phase_equilibrium", True)
172 self.concentrate_state = process_pp.build_state_block(time, **proc_sep_args)
173 self.vapor_state = process_pp.build_state_block(time, **proc_sep_args)
175 # ------------------------------------------------------------------
176 # Utility-side state blocks
177 util_in_args = dict(**self.config.utility_property_package_args)
178 util_in_args.setdefault("defined_state", True)
179 self.utility_in = utility_pp.build_state_block(time, **util_in_args)
181 util_out_args = dict(**self.config.utility_property_package_args)
182 util_out_args.setdefault("defined_state", False)
183 self.utility_out = utility_pp.build_state_block(time, **util_out_args)
185 # ------------------------------------------------------------------
186 # Convenience references to variable units / phase-component indexing.
187 t0 = time.first()
188 self._phase_component_set = Set(
189 initialize=list(self.concentrate_state[t0].flow_mass_phase_comp.keys()),
190 dimen=2,
191 ordered=True,
192 doc="Phase-component pairs used in the process-side property package",
193 )
195 first_pc = next(iter(self._phase_component_set))
196 flow_ref = self.concentrate_state[t0].flow_mass_phase_comp[first_pc]
197 flow_units = pyunits.get_units(flow_ref)
198 self._process_flow_units = pyunits.dimensionless if flow_units is None else flow_units
200 # ------------------------------------------------------------------
201 # Ports
202 self.add_port(name="feed_inlet", block=self.process_in)
203 self.add_port(name="utility_inlet", block=self.utility_in)
204 self.add_port(name="concentrate_outlet", block=self.concentrate_state)
205 self.add_port(name="vapor_outlet", block=self.vapor_state)
206 self.add_port(name="utility_outlet", block=self.utility_out)
208 # ------------------------------------------------------------------
209 # Unit variables and convenience expressions
210 self.heat_duty = Var(
211 time,
212 initialize=1e5,
213 units=pyunits.J / pyunits.s,
214 doc="Heat transferred from the utility side to the process side",
215 )
217 self.deltaP_process = Var(
218 time,
219 initialize=0.0,
220 units=pyunits.Pa,
221 doc="Process-side pressure change, outlet - inlet",
222 )
223 self.deltaP_utility = Var(
224 time,
225 initialize=0.0,
226 units=pyunits.Pa,
227 doc="Utility-side pressure change, outlet - inlet",
228 )
230 if not self.config.has_pressure_change: 230 ↛ 235line 230 didn't jump to line 235 because the condition on line 230 was always true
231 for t in time:
232 self.deltaP_process[t].fix(0.0)
233 self.deltaP_utility[t].fix(0.0)
235 self.utility_enthalpy_drop = Expression(
236 time,
237 rule=lambda b, t: b.utility_in[t].flow_mol * b.utility_in[t].enth_mol
238 - b.utility_out[t].flow_mol * b.utility_out[t].enth_mol,
239 doc="Utility-side enthalpy flow decrease",
240 )
242 self.concentrate_liquid_phase_flow = Expression(
243 time,
244 rule=lambda b, t: sum(
245 b.concentrate_state[t].flow_mass_phase_comp[p, j]
246 for p, j in b._phase_component_set
247 if p == b.LIQUID_PHASE
248 ),
249 doc="Total liquid-phase flow in the concentrate outlet",
250 )
251 self.concentrate_solid_phase_flow = Expression(
252 time,
253 rule=lambda b, t: sum(
254 b.concentrate_state[t].flow_mass_phase_comp[p, j]
255 for p, j in b._phase_component_set
256 if p == b.SOLID_PHASE
257 ),
258 doc="Total solid-phase flow in the concentrate outlet",
259 )
260 self.vapor_phase_flow = Expression(
261 time,
262 rule=lambda b, t: sum(
263 b.vapor_state[t].flow_mass_phase_comp[p, j]
264 for p, j in b._phase_component_set
265 if p == b.VAPOR_PHASE
266 ),
267 doc="Total vapor-phase flow in the vapor outlet",
268 )
270 # ------------------------------------------------------------------
271 # Process-side balances
272 @self.Constraint(time, process_pp.component_list, doc="Total component balances")
273 def process_component_balances(b, t, j):
274 return sum(
275 b.process_in[t].flow_mass_phase_comp[p, j]
276 for p in process_pp.phase_list
277 if (p, j) in b.process_in[t].phase_component_set
278 ) == sum(
279 b.concentrate_state[t].flow_mass_phase_comp[p, j]
280 for p in (b.LIQUID_PHASE, b.SOLID_PHASE)
281 if (p, j) in b.concentrate_state[t].phase_component_set
282 ) + sum(
283 b.vapor_state[t].flow_mass_phase_comp[p, j]
284 for p in (b.VAPOR_PHASE,)
285 if (p, j) in b.vapor_state[t].phase_component_set
286 )
288 @self.Constraint(time, doc="Process-side total enthalpy balance")
289 def process_energy_balance(b, t):
290 return sum(
291 b.concentrate_state[t].get_enthalpy_flow_terms(p)
292 for p in (b.LIQUID_PHASE, b.SOLID_PHASE)
293 if p in process_pp.phase_list
294 ) + sum(
295 b.vapor_state[t].get_enthalpy_flow_terms(p)
296 for p in (b.VAPOR_PHASE,)
297 if p in process_pp.phase_list
298 ) == sum(
299 b.process_in[t].get_enthalpy_flow_terms(p)
300 for p in process_pp.phase_list
301 ) + b.heat_duty[t]
303 @self.Constraint(time, doc="Concentrate pressure balance")
304 def process_pressure_balance(b, t):
305 return (
306 b.concentrate_state[t].pressure
307 == b.process_in[t].pressure + b.deltaP_process[t]
308 )
310 @self.Constraint(time, doc="Outlet pressure equality")
311 def outlet_pressure_equality(b, t):
312 return b.vapor_state[t].pressure == b.concentrate_state[t].pressure
314 @self.Constraint(time, doc="Outlet temperature equality")
315 def outlet_temperature_equality(b, t):
316 return b.vapor_state[t].temperature == b.concentrate_state[t].temperature
318 # ------------------------------------------------------------------
319 # Utility-side balances
320 @self.Constraint(time, doc="Utility total-flow continuity")
321 def utility_flow_balance(b, t):
322 return b.utility_out[t].flow_mol == b.utility_in[t].flow_mol
324 @self.Constraint(time, doc="Utility pressure balance")
325 def utility_pressure_balance(b, t):
326 return b.utility_out[t].pressure == b.utility_in[t].pressure + b.deltaP_utility[t]
328 @self.Constraint(time, doc="Utility-to-process heat balance")
329 def utility_energy_balance(b, t):
330 return b.heat_duty[t] == b.utility_enthalpy_drop[t]
332 # ------------------------------------------------------------------
333 # Direct outlet phase exclusions. The active phases in each outlet are
334 # determined by the property package, but the stream identity is
335 # enforced here by excluding phases that do not belong in that outlet.
336 @self.Constraint(
337 time,
338 doc="Exclude vapor and other non-concentrate phases from the concentrate outlet",
339 )
340 def eq_concentrate_phase_exclusion(b, t,):
341 return (
342 (sum(b.concentrate_state[t].flow_mass_phase_comp[p, j]
343 for p, j in b._phase_component_set
344 if p != b.LIQUID_PHASE and p != b.SOLID_PHASE
345 )
346 - b.absent_phase_flow * b._process_flow_units
347 )/1e5 # Scale this down to avoid numerical issues.
348 == 0
349 )
351 @self.Constraint(
352 time,
353 doc="Exclude liquid and solid phases from the vapor outlet",
354 )
355 def eq_vapor_phase_exclusion(b, t,):
356 return (
357 (sum(b.vapor_state[t].flow_mass_phase_comp[p, j]
358 for p, j in b._phase_component_set
359 if p != b.VAPOR_PHASE
360 )
361 - b.absent_phase_flow * b._process_flow_units
362 )/1e5 # Scale this down to avoid numerical issues.
363 == 0
364 )
368 # ------------------------------------------------------------------
369 # Initialization and reporting
370 def initialize_build(
371 self,
372 process_state_args=None,
373 utility_state_args=None,
374 outlvl=idaeslog.NOTSET,
375 solver=None,
376 optarg=None,
377 ):
378 """Initialize the unit model.
380 The routine initializes the process and utility inlet states, copies
381 those states to outlet-side state blocks as seeds, and then solves the
382 full unit model.
383 """
384 init_log = idaeslog.getInitLogger(self.name, outlvl, tag="unit")
385 solve_log = idaeslog.getSolveLogger(self.name, outlvl, tag="unit")
386 opt = get_solver(solver, optarg)
387 t0 = self.flowsheet().time.first()
388 outlvl=idaeslog.DEBUG
389 process_flags = self.process_in.initialize(
390 state_args=process_state_args,
391 hold_state=True,
392 outlvl=outlvl,
393 solver=solver,
394 optarg=optarg,
395 )
396 utility_flags = self.utility_in.initialize(
397 state_args=utility_state_args,
398 hold_state=True,
399 outlvl=outlvl,
400 solver=solver,
401 optarg=optarg,
402 )
403 init_log.info_high("Initialization Step 1: inlet states initialized.")
405 if process_state_args is None: 405 ↛ 407line 405 didn't jump to line 407 because the condition on line 405 was always true
406 process_state_args = extract_state_args(self.process_in[t0])
407 if utility_state_args is None: 407 ↛ 410line 407 didn't jump to line 410 because the condition on line 407 was always true
408 utility_state_args = extract_state_args(self.utility_in[t0])
410 self.concentrate_state.initialize(
411 state_args=deepcopy(process_state_args),
412 hold_state=False,
413 outlvl=outlvl,
414 solver=solver,
415 optarg=optarg,
416 )
417 self.vapor_state.initialize(
418 state_args=deepcopy(process_state_args),
419 hold_state=False,
420 outlvl=outlvl,
421 solver=solver,
422 optarg=optarg,
423 )
425 self.utility_out.initialize(
426 state_args=deepcopy(utility_state_args),
427 hold_state=False,
428 outlvl=outlvl,
429 solver=solver,
430 optarg=optarg,
431 )
432 init_log.info_high("Initialization Step 2: outlet states seeded.")
434 if degrees_of_freedom(self) != 0: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true
435 raise InitializationError(
436 f"{self.name}: degrees of freedom are {degrees_of_freedom(self)} during initialization; expected 0. "
437 "Fix heat_duty, fix a utility-outlet state, or add an external heat-transfer relation."
438 )
440 with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc:
441 res = opt.solve(self, tee=slc.tee)
442 init_log.info_high(f"Initialization Step 3: {idaeslog.condition(res)}.")
444 self.process_in.release_state(process_flags, outlvl=outlvl)
445 self.utility_in.release_state(utility_flags, outlvl=outlvl)
447 if not check_optimal_termination(res): 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true
448 raise InitializationError(
449 f"{self.name} failed to initialize successfully; solver did not terminate optimally."
450 )
452 init_log.info("Initialization complete.")
454 def _get_stream_table_contents(self, time_point=0):
455 return create_stream_table_dataframe(
456 {
457 "Feed Inlet": self.feed_inlet,
458 "Utility Inlet": self.utility_inlet,
459 "Concentrate Outlet": self.concentrate_outlet,
460 "Vapor Outlet": self.vapor_outlet,
461 "Utility Outlet": self.utility_outlet,
462 },
463 time_point=time_point,
464 )
466 def _get_performance_contents(self, time_point=0):
467 data = {
468 "Heat Duty": self.heat_duty[time_point],
469 # "Utility Enthalpy Drop": self.utility_enthalpy_drop[time_point],
470 # "Concentrate Liquid-Phase Flow": self.concentrate_liquid_phase_flow[time_point],
471 # "Concentrate Solid-Phase Flow": self.concentrate_solid_phase_flow[time_point],
472 # "Vapor Outlet Flow": self.vapor_phase_flow[time_point],
473 "Process dP": self.deltaP_process[time_point],
474 "Utility dP": self.deltaP_utility[time_point],
475 }
477 return {"vars": data}
479 def calculate_scaling_factors(self):
480 super().calculate_scaling_factors()
482 process_pp = self.config.process_property_package
484 for t in self.flowsheet().time:
485 if iscale.get_scaling_factor(self.heat_duty[t]) is None:
486 sf_flow = iscale.get_scaling_factor(
487 self.utility_in[t].flow_mol,
488 default=1.0,
489 )
490 sf_enth = iscale.get_scaling_factor(
491 self.utility_in[t].enth_mol,
492 default=1e-4,
493 )
494 iscale.set_scaling_factor(self.heat_duty[t], sf_flow * sf_enth)
496 if iscale.get_scaling_factor(self.deltaP_process[t]) is None:
497 iscale.set_scaling_factor(self.deltaP_process[t], 1e-5)
498 if iscale.get_scaling_factor(self.deltaP_utility[t]) is None:
499 iscale.set_scaling_factor(self.deltaP_utility[t], 1e-5)
501 iscale.constraint_scaling_transform(
502 self.process_energy_balance[t],
503 iscale.get_scaling_factor(self.heat_duty[t], default=1e-5),
504 overwrite=False,
505 )
506 iscale.constraint_scaling_transform(
507 self.utility_energy_balance[t],
508 iscale.get_scaling_factor(self.heat_duty[t], default=1e-5),
509 overwrite=False,
510 )
511 iscale.constraint_scaling_transform(
512 self.process_pressure_balance[t],
513 iscale.get_scaling_factor(self.deltaP_process[t], default=1e-5),
514 overwrite=False,
515 )
516 iscale.constraint_scaling_transform(
517 self.outlet_pressure_equality[t],
518 iscale.get_scaling_factor(self.deltaP_process[t], default=1e-5),
519 overwrite=False,
520 )
521 iscale.constraint_scaling_transform(
522 self.outlet_temperature_equality[t],
523 iscale.get_scaling_factor(self.concentrate_state[t].temperature, default=1e-2),
524 overwrite=False,
525 )
526 iscale.constraint_scaling_transform(
527 self.utility_pressure_balance[t],
528 iscale.get_scaling_factor(self.deltaP_utility[t], default=1e-5),
529 overwrite=False,
530 )
532 for j in process_pp.component_list:
533 sf_j = 1.0
534 for p in process_pp.phase_list:
535 if (p, j) in self.process_in[t].phase_component_set:
536 sf_j = iscale.get_scaling_factor(
537 self.process_in[t].flow_mass_phase_comp[p, j], default=1.0
538 )
539 break
540 iscale.constraint_scaling_transform(
541 self.process_component_balances[t, j], sf_j, overwrite=False
542 )
544 for t, c in self.eq_concentrate_phase_exclusion.items():
545 sf = 1.0
546 for p, j in self._phase_component_set:
547 if p not in (self.LIQUID_PHASE, self.SOLID_PHASE):
548 sf = iscale.get_scaling_factor(
549 self.concentrate_state[t].flow_mass_phase_comp[p, j],
550 default=1.0,
551 )
552 break
553 iscale.constraint_scaling_transform(c, sf, overwrite=False)
555 for t, c in self.eq_vapor_phase_exclusion.items():
556 sf = 1.0
557 for p, j in self._phase_component_set:
558 if p != self.VAPOR_PHASE:
559 sf = iscale.get_scaling_factor(
560 self.vapor_state[t].flow_mass_phase_comp[p, j],
561 default=1.0,
562 )
563 break
564 iscale.constraint_scaling_transform(c, sf, overwrite=False)
567 def diagnose(self) -> list[tuple[Component, str]]:
568 """
569 Test a few common issues with the heat exchanger model and provide hints to the user.
570 returns a list with the variable the it is most relevant to and a message describing the issue
571 """
572 problems = []
573 utility_vap_frac = pyo.value(self.utility_in[0].vapor_frac) or -1
574 if utility_vap_frac < 0.1: 574 ↛ 583line 574 didn't jump to line 583 because the condition on line 574 was always true
575 problems.append(
576 (
577 self.utility_in[0].vapor_frac,
578 f"""Utility inlet vapor fraction is {utility_vap_frac:.2f}.
579 This model is intended for steam/condensate service; if there is no steam it is unlikely you will have
580 sufficient driving force for heat transfer."""
581 )
582 )
583 process_vap_frac = pyo.value(self.process_in[0].vapor_frac) or -1
584 if process_vap_frac > 0.9: 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true
585 problems.append(
586 (
587 self.process_in[0].vapor_frac,
588 f"""Process inlet vapor fraction is {process_vap_frac:.2f}.
589 There is not much to evaporate; this model requires liquid to be present in the feedstock."""
590 )
591 )
592 vap_flow = pyo.value(self.vapor_state[0].flow_mass) or -1
593 if vap_flow < 0.1: 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true
594 problems.append(
595 (
596 self.vapor_state[0].flow_mass,
597 f"""Vapor flow is {vap_flow:.2f}.
598 This model requires a sufficient vapor flow for proper operation.
599 Perhaps there is not enough heat to create vapor?"""
600 )
601 )
602 flow_in = pyo.value(self.process_in[0].flow_mass) or -1
603 utility_flow_in = pyo.value(self.utility_in[0].flow_mass) or -1
604 flow_ratio = flow_in / utility_flow_in
605 if flow_ratio > 1000 or flow_ratio < 0.001: 605 ↛ 606line 605 didn't jump to line 606 because the condition on line 605 was never true
606 problems.append(
607 (
608 self.utility_in[0].flow_mol,
609 f"""Process inlet mass flow is {flow_in:.2f} kg/s, while utility inlet molar flow is {utility_flow_in:.2f} mol/s,
610 giving a flow ratio of {flow_ratio:.2f}.
611 This model assumes the utility is steam; if the utility flow is very low compared to the process flow,
612 you may not have enough heat transfer driving force for the model to work well."""
613 )
614 )
616 utility_temp_out = pyo.value(self.utility_out[0].temperature) or 0
617 if utility_temp_out < 280: 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true
618 problems.append(
619 (
620 self.utility_out[0].temperature,
621 f"""Utility outlet temperature is {utility_temp_out:.2f} K.
622 This probably means there is insufficient utility flow or temperature to provide the necessary heat duty."""
623 )
624 )
626 utility_temp_in = pyo.value(self.utility_in[0].temperature) or 0
627 if utility_temp_in < utility_temp_out: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true
628 problems.append(
629 (
630 self.utility_in[0].temperature,
631 f"""Utility inlet temperature is {utility_temp_in:.2f} K, which is less than the outlet temperature of {utility_temp_out:.2f} K.
632 This is not physically consistent; check your utility inlet conditions. Are you trying to cool the process instead of heat it? This model is intended for heating applications with steam as the utility."""
633 )
634 )
636 return problems
638 @staticmethod
639 def ahuora_metadata():
640 from ahuora_unit_ops.json_config import (
641 JsonAdapterArgConfig,
642 JsonFrontendConfig,
643 JsonGraphicObjectConfig,
644 JsonIdaesAdapterConfig,
645 JsonPortConfig,
646 JsonPropertyConfig,
647 JsonPropertySetGroupConfig,
648 JsonUnitOpConfig,
649 )
651 return JsonUnitOpConfig(
652 key='crystallizer',
653 objectType='crystallizer',
654 enumMember='Crystallizer',
655 displayType='Crystallizer',
656 displayName='Crystallizer',
657 categoryPath=['chemical', 'separation'],
658 ports={
659 'feed_inlet': JsonPortConfig(
660 displayName='Feed Inlet',
661 type='inlet',
662 streamType='stream',
663 many=False,
664 default=1,
665 minimum=1,
666 makeStream=True,
667 streamOffset=0.75,
668 streamName='Feed S',
669 ),
670 'utility_inlet': JsonPortConfig(
671 displayName='Utility Inlet',
672 type='inlet',
673 streamType='stream',
674 many=False,
675 default=1,
676 minimum=1,
677 makeStream=True,
678 streamOffset=0.75,
679 streamName='Utility S',
680 ),
681 'concentrate_outlet': JsonPortConfig(
682 displayName='Concentrate Outlet',
683 type='outlet',
684 streamType='stream',
685 many=False,
686 default=1,
687 minimum=1,
688 makeStream=True,
689 streamOffset=0.75,
690 streamName='Concentrate S',
691 ),
692 'vapor_outlet': JsonPortConfig(
693 displayName='Vapor Outlet',
694 type='outlet',
695 streamType='stream',
696 many=False,
697 default=1,
698 minimum=1,
699 makeStream=True,
700 streamOffset=0.75,
701 streamName='Vapor S',
702 ),
703 'utility_outlet': JsonPortConfig(
704 displayName='Utility Outlet',
705 type='outlet',
706 streamType='stream',
707 many=False,
708 default=1,
709 minimum=1,
710 makeStream=True,
711 streamOffset=0.75,
712 streamName='Utility S',
713 ),
714 },
715 propertyPackagePorts={
716 'Process': ['feed_inlet', 'concentrate_outlet', 'vapor_outlet'],
717 'Utility': ['utility_inlet', 'utility_outlet'],
718 },
719 graphicObject=JsonGraphicObjectConfig(
720 kind='unitop_graphic',
721 ),
722 indexSets=[],
723 properties={
724 'heat_duty': JsonPropertyConfig(
725 propertySetGroup='default',
726 displayName='Heat Duty',
727 indexSets=None,
728 sumToOne=False,
729 value=None,
730 unit=None,
731 unitType='heatflow',
732 description=None,
733 type='numeric',
734 many=False,
735 default=1,
736 options={},
737 hasTimeIndex=True,
738 ),
739 'deltaP_process': JsonPropertyConfig(
740 propertySetGroup='pressure_change',
741 displayName='Process Pressure Change',
742 indexSets=None,
743 sumToOne=False,
744 value=None,
745 unit=None,
746 unitType='pressure',
747 description=None,
748 type='numeric',
749 many=False,
750 default=1,
751 options={},
752 hasTimeIndex=True,
753 ),
754 'deltaP_utility': JsonPropertyConfig(
755 propertySetGroup='pressure_change',
756 displayName='Utility Pressure Change',
757 indexSets=None,
758 sumToOne=False,
759 value=None,
760 unit=None,
761 unitType='pressure',
762 description=None,
763 type='numeric',
764 many=False,
765 default=1,
766 options={},
767 hasTimeIndex=True,
768 ),
769 'utility_enthalpy_drop': JsonPropertyConfig(
770 propertySetGroup='default',
771 displayName='Utility Enthalpy Drop',
772 indexSets=None,
773 sumToOne=False,
774 value=None,
775 unit=None,
776 unitType='heatflow',
777 description=None,
778 type='numeric',
779 many=False,
780 default=1,
781 options={},
782 hasTimeIndex=True,
783 ),
784 'concentrate_liquid_phase_flow': JsonPropertyConfig(
785 propertySetGroup='default',
786 displayName='Concentrate Liquid Flow',
787 indexSets=None,
788 sumToOne=False,
789 value=None,
790 unit=None,
791 unitType='massflow',
792 description=None,
793 type='numeric',
794 many=False,
795 default=1,
796 options={},
797 hasTimeIndex=True,
798 ),
799 'concentrate_solid_phase_flow': JsonPropertyConfig(
800 propertySetGroup='default',
801 displayName='Concentrate Solid Flow',
802 indexSets=None,
803 sumToOne=False,
804 value=None,
805 unit=None,
806 unitType='massflow',
807 description=None,
808 type='numeric',
809 many=False,
810 default=1,
811 options={},
812 hasTimeIndex=True,
813 ),
814 'vapor_phase_flow': JsonPropertyConfig(
815 propertySetGroup='default',
816 displayName='Vapor Flow',
817 indexSets=None,
818 sumToOne=False,
819 value=None,
820 unit=None,
821 unitType='massflow',
822 description=None,
823 type='numeric',
824 many=False,
825 default=1,
826 options={},
827 hasTimeIndex=True,
828 ),
829 'has_pressure_change': JsonPropertyConfig(
830 propertySetGroup='pressure_change',
831 displayName='Enable Pressure Change',
832 indexSets=None,
833 sumToOne=False,
834 value=False,
835 unit=None,
836 unitType='dimensionless',
837 description=None,
838 type='checkbox',
839 many=False,
840 default=1,
841 options={},
842 hasTimeIndex=True,
843 ),
844 },
845 propertySetGroups={
846 'default': JsonPropertySetGroupConfig(
847 type='stateVars',
848 displayName='Properties',
849 stateVars=['heat_duty'],
850 toggle=None,
851 ),
852 'pressure_change': JsonPropertySetGroupConfig(
853 type='stateVars',
854 displayName='Pressure Change',
855 stateVars=['has_pressure_change', 'deltaP_process', 'deltaP_utility'],
856 toggle='has_pressure_change',
857 ),
858 },
859 keyProperties=[
860 'utility_enthalpy_drop',
861 'concentrate_liquid_phase_flow',
862 'concentrate_solid_phase_flow',
863 'vapor_phase_flow',
864 ],
865 splitterFractionName=None,
866 idaesAdapter=JsonIdaesAdapterConfig(
867 constructor='ahuora_builder.custom.salt.crystallizer.Crystallizer',
868 args={
869 'process_property_package': JsonAdapterArgConfig(
870 kind='property_package',
871 label='Process',
872 ),
873 'utility_property_package': JsonAdapterArgConfig(
874 kind='property_package',
875 label='Utility',
876 ),
877 'has_pressure_change': JsonAdapterArgConfig(
878 kind='toggle',
879 property='has_pressure_change',
880 ),
881 },
882 ports=None,
883 properties=None,
884 ),
885 frontend=JsonFrontendConfig(
886 showInPanel=True,
887 variant=None,
888 ),
889 )