Coverage for backend/ahuora-builder/src/ahuora_builder/custom/salt/crystallizer.py: 65%
203 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-05-13 02:47 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-05-13 02:47 +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
40import pyomo.environ as pyo
42from idaes.core import UnitModelBlockData, declare_process_block_class, useDefault
43from idaes.core.util.config import is_physical_parameter_block
44from idaes.core.util.initialization import fix_state_vars, revert_state_vars
45from idaes.core.util.model_statistics import degrees_of_freedom
46from idaes.core.util.tables import create_stream_table_dataframe
47from idaes.core.util.exceptions import ConfigurationError, InitializationError
48import idaes.core.util.scaling as iscale
49import idaes.logger as idaeslog
50from ahuora_builder.state_args import extract_state_args
52try: # WaterTAP installs a compatible convenience wrapper.
53 from watertap.core.solvers import get_solver
54except ImportError: # pragma: no cover - fallback when WaterTAP is absent.
55 from idaes.core.solvers import get_solver
58_log = idaeslog.getLogger(__name__)
61@declare_process_block_class("Crystallizer")
62class CrystallizerData(UnitModelBlockData):
63 """Indirect-heated evaporator / crystallizer with explicit utility side."""
65 LIQUID_PHASE = "Liq"
66 VAPOR_PHASE = "Vap"
67 SOLID_PHASE = "Sol"
69 CONFIG = UnitModelBlockData.CONFIG()
71 CONFIG.declare(
72 "process_property_package",
73 ConfigValue(
74 default=useDefault,
75 domain=is_physical_parameter_block,
76 description="Process-side property package",
77 ),
78 )
79 CONFIG.declare(
80 "process_property_package_args",
81 ConfigBlock(
82 implicit=True,
83 description="Arguments used when constructing process-side state blocks",
84 ),
85 )
87 CONFIG.declare(
88 "utility_property_package",
89 ConfigValue(
90 default=useDefault,
91 domain=is_physical_parameter_block,
92 description="Utility-side property package",
93 ),
94 )
95 CONFIG.declare(
96 "utility_property_package_args",
97 ConfigBlock(
98 implicit=True,
99 description="Arguments used when constructing utility-side state blocks",
100 ),
101 )
103 CONFIG.declare(
104 "absent_phase_flow",
105 ConfigValue(
106 default=1e-6,
107 domain=float,
108 description="Small numeric flow assigned to excluded outlet phases",
109 ),
110 )
111 CONFIG.declare(
112 "has_pressure_change",
113 ConfigValue(
114 default=False,
115 domain=Bool,
116 description="Whether to include explicit process- and utility-side dP vars",
117 ),
118 )
120 def build(self):
121 super().build()
123 process_pp = self.config.process_property_package
124 utility_pp = self.config.utility_property_package
126 if process_pp is useDefault: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 raise ConfigurationError(
128 f"{self.name}: process_property_package must be provided explicitly."
129 )
130 if utility_pp is useDefault: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 raise ConfigurationError(
132 f"{self.name}: utility_property_package must be provided explicitly."
133 )
135 missing_phases = [
136 p
137 for p in (
138 self.LIQUID_PHASE,
139 self.VAPOR_PHASE,
140 self.SOLID_PHASE,
141 )
142 if p not in process_pp.phase_list
143 ]
144 if missing_phases: 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 raise ConfigurationError(
146 f"{self.name}: process property package is missing expected phases: "
147 f"{missing_phases}. Available phases: {list(process_pp.phase_list)}"
148 )
150 time = self.flowsheet().time
152 self.absent_phase_flow = Param(
153 initialize=self.config.absent_phase_flow,
154 mutable=True,
155 units=pyunits.dimensionless,
156 doc="Small numeric flow used for outlet phases that are excluded by the separator",
157 )
159 # ------------------------------------------------------------------
160 # Process-side state blocks
161 proc_in_args = dict(**self.config.process_property_package_args)
162 proc_in_args.setdefault("defined_state", True)
163 proc_in_args.setdefault("has_phase_equilibrium", False)
164 self.process_in = process_pp.build_state_block(time, **proc_in_args)
166 proc_sep_args = dict(**self.config.process_property_package_args)
167 proc_sep_args.setdefault("defined_state", False)
168 proc_sep_args.setdefault("has_phase_equilibrium", True)
169 self.concentrate_state = process_pp.build_state_block(time, **proc_sep_args)
170 self.vapor_state = process_pp.build_state_block(time, **proc_sep_args)
172 # ------------------------------------------------------------------
173 # Utility-side state blocks
174 util_in_args = dict(**self.config.utility_property_package_args)
175 util_in_args.setdefault("defined_state", True)
176 self.utility_in = utility_pp.build_state_block(time, **util_in_args)
178 util_out_args = dict(**self.config.utility_property_package_args)
179 util_out_args.setdefault("defined_state", False)
180 self.utility_out = utility_pp.build_state_block(time, **util_out_args)
182 # ------------------------------------------------------------------
183 # Convenience references to variable units / phase-component indexing.
184 t0 = time.first()
185 self._phase_component_set = Set(
186 initialize=list(self.concentrate_state[t0].flow_mass_phase_comp.keys()),
187 dimen=2,
188 ordered=True,
189 doc="Phase-component pairs used in the process-side property package",
190 )
192 first_pc = next(iter(self._phase_component_set))
193 flow_ref = self.concentrate_state[t0].flow_mass_phase_comp[first_pc]
194 flow_units = pyunits.get_units(flow_ref)
195 self._process_flow_units = pyunits.dimensionless if flow_units is None else flow_units
197 # ------------------------------------------------------------------
198 # Ports
199 self.add_port(name="feed_inlet", block=self.process_in)
200 self.add_port(name="utility_inlet", block=self.utility_in)
201 self.add_port(name="concentrate_outlet", block=self.concentrate_state)
202 self.add_port(name="vapor_outlet", block=self.vapor_state)
203 self.add_port(name="utility_outlet", block=self.utility_out)
205 # ------------------------------------------------------------------
206 # Unit variables and convenience expressions
207 self.heat_duty = Var(
208 time,
209 initialize=1e5,
210 units=pyunits.J / pyunits.s,
211 doc="Heat transferred from the utility side to the process side",
212 )
214 self.deltaP_process = Var(
215 time,
216 initialize=0.0,
217 units=pyunits.Pa,
218 doc="Process-side pressure change, outlet - inlet",
219 )
220 self.deltaP_utility = Var(
221 time,
222 initialize=0.0,
223 units=pyunits.Pa,
224 doc="Utility-side pressure change, outlet - inlet",
225 )
227 if not self.config.has_pressure_change: 227 ↛ 232line 227 didn't jump to line 232 because the condition on line 227 was always true
228 for t in time:
229 self.deltaP_process[t].fix(0.0)
230 self.deltaP_utility[t].fix(0.0)
232 self.utility_enthalpy_drop = Expression(
233 time,
234 rule=lambda b, t: b.utility_in[t].flow_mol * b.utility_in[t].enth_mol
235 - b.utility_out[t].flow_mol * b.utility_out[t].enth_mol,
236 doc="Utility-side enthalpy flow decrease",
237 )
239 self.concentrate_liquid_phase_flow = Expression(
240 time,
241 rule=lambda b, t: sum(
242 b.concentrate_state[t].flow_mass_phase_comp[p, j]
243 for p, j in b._phase_component_set
244 if p == b.LIQUID_PHASE
245 ),
246 doc="Total liquid-phase flow in the concentrate outlet",
247 )
248 self.concentrate_solid_phase_flow = Expression(
249 time,
250 rule=lambda b, t: sum(
251 b.concentrate_state[t].flow_mass_phase_comp[p, j]
252 for p, j in b._phase_component_set
253 if p == b.SOLID_PHASE
254 ),
255 doc="Total solid-phase flow in the concentrate outlet",
256 )
257 self.vapor_phase_flow = Expression(
258 time,
259 rule=lambda b, t: sum(
260 b.vapor_state[t].flow_mass_phase_comp[p, j]
261 for p, j in b._phase_component_set
262 if p == b.VAPOR_PHASE
263 ),
264 doc="Total vapor-phase flow in the vapor outlet",
265 )
267 # ------------------------------------------------------------------
268 # Process-side balances
269 @self.Constraint(time, process_pp.component_list, doc="Total component balances")
270 def process_component_balances(b, t, j):
271 return sum(
272 b.process_in[t].flow_mass_phase_comp[p, j]
273 for p in process_pp.phase_list
274 if (p, j) in b.process_in[t].phase_component_set
275 ) == sum(
276 b.concentrate_state[t].flow_mass_phase_comp[p, j]
277 for p in (b.LIQUID_PHASE, b.SOLID_PHASE)
278 if (p, j) in b.concentrate_state[t].phase_component_set
279 ) + sum(
280 b.vapor_state[t].flow_mass_phase_comp[p, j]
281 for p in (b.VAPOR_PHASE,)
282 if (p, j) in b.vapor_state[t].phase_component_set
283 )
285 @self.Constraint(time, doc="Process-side total enthalpy balance")
286 def process_energy_balance(b, t):
287 return sum(
288 b.concentrate_state[t].get_enthalpy_flow_terms(p)
289 for p in (b.LIQUID_PHASE, b.SOLID_PHASE)
290 if p in process_pp.phase_list
291 ) + sum(
292 b.vapor_state[t].get_enthalpy_flow_terms(p)
293 for p in (b.VAPOR_PHASE,)
294 if p in process_pp.phase_list
295 ) == sum(
296 b.process_in[t].get_enthalpy_flow_terms(p)
297 for p in process_pp.phase_list
298 ) + b.heat_duty[t]
300 @self.Constraint(time, doc="Concentrate pressure balance")
301 def process_pressure_balance(b, t):
302 return (
303 b.concentrate_state[t].pressure
304 == b.process_in[t].pressure + b.deltaP_process[t]
305 )
307 @self.Constraint(time, doc="Outlet pressure equality")
308 def outlet_pressure_equality(b, t):
309 return b.vapor_state[t].pressure == b.concentrate_state[t].pressure
311 @self.Constraint(time, doc="Outlet temperature equality")
312 def outlet_temperature_equality(b, t):
313 return b.vapor_state[t].temperature == b.concentrate_state[t].temperature
315 # ------------------------------------------------------------------
316 # Utility-side balances
317 @self.Constraint(time, doc="Utility total-flow continuity")
318 def utility_flow_balance(b, t):
319 return b.utility_out[t].flow_mol == b.utility_in[t].flow_mol
321 @self.Constraint(time, doc="Utility pressure balance")
322 def utility_pressure_balance(b, t):
323 return b.utility_out[t].pressure == b.utility_in[t].pressure + b.deltaP_utility[t]
325 @self.Constraint(time, doc="Utility-to-process heat balance")
326 def utility_energy_balance(b, t):
327 return b.heat_duty[t] == b.utility_enthalpy_drop[t]
329 # ------------------------------------------------------------------
330 # Direct outlet phase exclusions. The active phases in each outlet are
331 # determined by the property package, but the stream identity is
332 # enforced here by excluding phases that do not belong in that outlet.
333 @self.Constraint(
334 time,
335 doc="Exclude vapor and other non-concentrate phases from the concentrate outlet",
336 )
337 def eq_concentrate_phase_exclusion(b, t,):
338 return (
339 (sum(b.concentrate_state[t].flow_mass_phase_comp[p, j]
340 for p, j in b._phase_component_set
341 if p != b.LIQUID_PHASE and p != b.SOLID_PHASE
342 )
343 - b.absent_phase_flow * b._process_flow_units
344 )/1e5 # Scale this down to avoid numerical issues.
345 == 0
346 )
348 @self.Constraint(
349 time,
350 doc="Exclude liquid and solid phases from the vapor outlet",
351 )
352 def eq_vapor_phase_exclusion(b, t,):
353 return (
354 (sum(b.vapor_state[t].flow_mass_phase_comp[p, j]
355 for p, j in b._phase_component_set
356 if p != b.VAPOR_PHASE
357 )
358 - b.absent_phase_flow * b._process_flow_units
359 )/1e5 # Scale this down to avoid numerical issues.
360 == 0
361 )
365 # ------------------------------------------------------------------
366 # Initialization and reporting
367 def initialize_build(
368 self,
369 process_state_args=None,
370 utility_state_args=None,
371 outlvl=idaeslog.NOTSET,
372 solver=None,
373 optarg=None,
374 ):
375 """Initialize the unit model.
377 The routine initializes the process and utility inlet states, copies
378 those states to outlet-side state blocks as seeds, and then solves the
379 full unit model.
380 """
381 init_log = idaeslog.getInitLogger(self.name, outlvl, tag="unit")
382 solve_log = idaeslog.getSolveLogger(self.name, outlvl, tag="unit")
383 opt = get_solver(solver, optarg)
384 t0 = self.flowsheet().time.first()
385 outlvl=idaeslog.DEBUG
386 process_flags = self.process_in.initialize(
387 state_args=process_state_args,
388 hold_state=True,
389 outlvl=outlvl,
390 solver=solver,
391 optarg=optarg,
392 )
393 utility_flags = self.utility_in.initialize(
394 state_args=utility_state_args,
395 hold_state=True,
396 outlvl=outlvl,
397 solver=solver,
398 optarg=optarg,
399 )
400 init_log.info_high("Initialization Step 1: inlet states initialized.")
402 if process_state_args is None: 402 ↛ 404line 402 didn't jump to line 404 because the condition on line 402 was always true
403 process_state_args = extract_state_args(self.process_in[t0])
404 if utility_state_args is None: 404 ↛ 407line 404 didn't jump to line 407 because the condition on line 404 was always true
405 utility_state_args = extract_state_args(self.utility_in[t0])
407 self.concentrate_state.initialize(
408 state_args=deepcopy(process_state_args),
409 hold_state=False,
410 outlvl=outlvl,
411 solver=solver,
412 optarg=optarg,
413 )
414 self.vapor_state.initialize(
415 state_args=deepcopy(process_state_args),
416 hold_state=False,
417 outlvl=outlvl,
418 solver=solver,
419 optarg=optarg,
420 )
422 self.utility_out.initialize(
423 state_args=deepcopy(utility_state_args),
424 hold_state=False,
425 outlvl=outlvl,
426 solver=solver,
427 optarg=optarg,
428 )
429 init_log.info_high("Initialization Step 2: outlet states seeded.")
431 if degrees_of_freedom(self) != 0: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 raise InitializationError(
433 f"{self.name}: degrees of freedom are {degrees_of_freedom(self)} during initialization; expected 0. "
434 "Fix heat_duty, fix a utility-outlet state, or add an external heat-transfer relation."
435 )
437 with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc:
438 res = opt.solve(self, tee=slc.tee)
439 init_log.info_high(f"Initialization Step 3: {idaeslog.condition(res)}.")
441 self.process_in.release_state(process_flags, outlvl=outlvl)
442 self.utility_in.release_state(utility_flags, outlvl=outlvl)
444 if not check_optimal_termination(res): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true
445 raise InitializationError(
446 f"{self.name} failed to initialize successfully; solver did not terminate optimally."
447 )
449 init_log.info("Initialization complete.")
451 def _get_stream_table_contents(self, time_point=0):
452 return create_stream_table_dataframe(
453 {
454 "Feed Inlet": self.feed_inlet,
455 "Utility Inlet": self.utility_inlet,
456 "Concentrate Outlet": self.concentrate_outlet,
457 "Vapor Outlet": self.vapor_outlet,
458 "Utility Outlet": self.utility_outlet,
459 },
460 time_point=time_point,
461 )
463 def _get_performance_contents(self, time_point=0):
464 data = {
465 "Heat Duty": self.heat_duty[time_point],
466 # "Utility Enthalpy Drop": self.utility_enthalpy_drop[time_point],
467 # "Concentrate Liquid-Phase Flow": self.concentrate_liquid_phase_flow[time_point],
468 # "Concentrate Solid-Phase Flow": self.concentrate_solid_phase_flow[time_point],
469 # "Vapor Outlet Flow": self.vapor_phase_flow[time_point],
470 "Process dP": self.deltaP_process[time_point],
471 "Utility dP": self.deltaP_utility[time_point],
472 }
474 return {"vars": data}
476 def calculate_scaling_factors(self):
477 super().calculate_scaling_factors()
479 process_pp = self.config.process_property_package
481 for t in self.flowsheet().time:
482 if iscale.get_scaling_factor(self.heat_duty[t]) is None:
483 sf_flow = iscale.get_scaling_factor(
484 self.utility_in[t].flow_mol,
485 default=1.0,
486 )
487 sf_enth = iscale.get_scaling_factor(
488 self.utility_in[t].enth_mol,
489 default=1e-4,
490 )
491 iscale.set_scaling_factor(self.heat_duty[t], sf_flow * sf_enth)
493 if iscale.get_scaling_factor(self.deltaP_process[t]) is None:
494 iscale.set_scaling_factor(self.deltaP_process[t], 1e-5)
495 if iscale.get_scaling_factor(self.deltaP_utility[t]) is None:
496 iscale.set_scaling_factor(self.deltaP_utility[t], 1e-5)
498 iscale.constraint_scaling_transform(
499 self.process_energy_balance[t],
500 iscale.get_scaling_factor(self.heat_duty[t], default=1e-5),
501 overwrite=False,
502 )
503 iscale.constraint_scaling_transform(
504 self.utility_energy_balance[t],
505 iscale.get_scaling_factor(self.heat_duty[t], default=1e-5),
506 overwrite=False,
507 )
508 iscale.constraint_scaling_transform(
509 self.process_pressure_balance[t],
510 iscale.get_scaling_factor(self.deltaP_process[t], default=1e-5),
511 overwrite=False,
512 )
513 iscale.constraint_scaling_transform(
514 self.outlet_pressure_equality[t],
515 iscale.get_scaling_factor(self.deltaP_process[t], default=1e-5),
516 overwrite=False,
517 )
518 iscale.constraint_scaling_transform(
519 self.outlet_temperature_equality[t],
520 iscale.get_scaling_factor(self.concentrate_state[t].temperature, default=1e-2),
521 overwrite=False,
522 )
523 iscale.constraint_scaling_transform(
524 self.utility_pressure_balance[t],
525 iscale.get_scaling_factor(self.deltaP_utility[t], default=1e-5),
526 overwrite=False,
527 )
529 for j in process_pp.component_list:
530 sf_j = 1.0
531 for p in process_pp.phase_list:
532 if (p, j) in self.process_in[t].phase_component_set:
533 sf_j = iscale.get_scaling_factor(
534 self.process_in[t].flow_mass_phase_comp[p, j], default=1.0
535 )
536 break
537 iscale.constraint_scaling_transform(
538 self.process_component_balances[t, j], sf_j, overwrite=False
539 )
541 for t, c in self.eq_concentrate_phase_exclusion.items():
542 sf = 1.0
543 for p, j in self._phase_component_set:
544 if p not in (self.LIQUID_PHASE, self.SOLID_PHASE):
545 sf = iscale.get_scaling_factor(
546 self.concentrate_state[t].flow_mass_phase_comp[p, j],
547 default=1.0,
548 )
549 break
550 iscale.constraint_scaling_transform(c, sf, overwrite=False)
552 for t, c in self.eq_vapor_phase_exclusion.items():
553 sf = 1.0
554 for p, j in self._phase_component_set:
555 if p != self.VAPOR_PHASE:
556 sf = iscale.get_scaling_factor(
557 self.vapor_state[t].flow_mass_phase_comp[p, j],
558 default=1.0,
559 )
560 break
561 iscale.constraint_scaling_transform(c, sf, overwrite=False)
564 def diagnose(self) -> list[tuple[Component, str]]:
565 """
566 Test a few common issues with the heat exchanger model and provide hints to the user.
567 returns a list with the variable the it is most relevant to and a message describing the issue
568 """
569 problems = []
570 utility_vap_frac = pyo.value(self.utility_in[0].vapor_frac) or -1
571 if utility_vap_frac < 0.1: 571 ↛ 580line 571 didn't jump to line 580 because the condition on line 571 was always true
572 problems.append(
573 (
574 self.utility_in[0].vapor_frac,
575 f"""Utility inlet vapor fraction is {utility_vap_frac:.2f}.
576 This model is intended for steam/condensate service; if there is no steam it is unlikely you will have
577 sufficient driving force for heat transfer."""
578 )
579 )
580 process_vap_frac = pyo.value(self.process_in[0].vapor_frac) or -1
581 if process_vap_frac > 0.9: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true
582 problems.append(
583 (
584 self.process_in[0].vapor_frac,
585 f"""Process inlet vapor fraction is {process_vap_frac:.2f}.
586 There is not much to evaporate; this model requires liquid to be present in the feedstock."""
587 )
588 )
589 vap_flow = pyo.value(self.vapor_state[0].flow_mass) or -1
590 if vap_flow < 0.1: 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true
591 problems.append(
592 (
593 self.vapor_state[0].flow_mass,
594 f"""Vapor flow is {vap_flow:.2f}.
595 This model requires a sufficient vapor flow for proper operation.
596 Perhaps there is not enough heat to create vapor?"""
597 )
598 )
599 flow_in = pyo.value(self.process_in[0].flow_mass) or -1
600 utility_flow_in = pyo.value(self.utility_in[0].flow_mass) or -1
601 flow_ratio = flow_in / utility_flow_in
602 if flow_ratio > 1000 or flow_ratio < 0.001: 602 ↛ 603line 602 didn't jump to line 603 because the condition on line 602 was never true
603 problems.append(
604 (
605 self.utility_in[0].flow_mol,
606 f"""Process inlet mass flow is {flow_in:.2f} kg/s, while utility inlet molar flow is {utility_flow_in:.2f} mol/s,
607 giving a flow ratio of {flow_ratio:.2f}.
608 This model assumes the utility is steam; if the utility flow is very low compared to the process flow,
609 you may not have enough heat transfer driving force for the model to work well."""
610 )
611 )
613 utility_temp_out = pyo.value(self.utility_out[0].temperature) or 0
614 if utility_temp_out < 280: 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true
615 problems.append(
616 (
617 self.utility_out[0].temperature,
618 f"""Utility outlet temperature is {utility_temp_out:.2f} K.
619 This probably means there is insufficient utility flow or temperature to provide the necessary heat duty."""
620 )
621 )
623 utility_temp_in = pyo.value(self.utility_in[0].temperature) or 0
624 if utility_temp_in < utility_temp_out: 624 ↛ 625line 624 didn't jump to line 625 because the condition on line 624 was never true
625 problems.append(
626 (
627 self.utility_in[0].temperature,
628 f"""Utility inlet temperature is {utility_temp_in:.2f} K, which is less than the outlet temperature of {utility_temp_out:.2f} K.
629 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."""
630 )
631 )
633 return problems