Coverage for backend/idaes_service/solver/custom/thermal_utility_systems/steam_user.py: 89%
255 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-11-06 23:27 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-11-06 23:27 +0000
1# Pyomo core
2from pyomo.environ import (
3 Constraint,
4 Expression,
5 NonNegativeReals,
6 Suffix,
7 Var,
8 value,
9 units as UNIT,
10)
11from pyomo.core.base.reference import Reference
12from pyomo.common.config import ConfigBlock, ConfigValue, Bool
14# IDAES core
15from idaes.core import (
16 declare_process_block_class,
17 UnitModelBlockData,
18 useDefault,
19 StateBlock,
20)
21from idaes.core.util import scaling
22from idaes.core.util.config import is_physical_parameter_block
23from idaes.core.util.tables import create_stream_table_dataframe
24from idaes.core.solvers import get_solver
25from idaes.core.initialization import ModularInitializerBase
27# Other
28from property_packages.build_package import build_package
30# Logger
31import idaes.logger as idaeslog
33# Typing
34from typing import List
37__author__ = "Ahuora Centre for Smart Energy Systems, University of Waikato, New Zealand"
39# Set up logger
40_log = idaeslog.getLogger(__name__)
42class SteamUserInitializer(ModularInitializerBase):
43 """Initializer for ``SteamUser``.
45 Parameters
46 ----------
47 blk : SteamUser
48 The unit model block to initialize.
49 solver : optional
50 A Pyomo/IDAES solver instance. Defaults to :func:`idaes.core.solvers.get_solver`.
51 solver_options : dict, optional
52 Options to set on the solver, e.g. tolerances.
53 outlvl : int, optional
54 IDAES log level (e.g. :data:`idaes.logger.WARNING`).
56 Returns
57 -------
58 pyomo.opt.results.results_.SolverResults
59 Result from the final solve.
60 """
62 def initialize(self, blk, **kwargs):
63 # --- Solver setup
64 solver = kwargs.get("solver", None) or get_solver()
65 solver_options = kwargs.get("solver_options", {})
66 for k, v in solver_options.items(): 66 ↛ 67line 66 didn't jump to line 67 because the loop on line 66 never started
67 solver.options[k] = v
69 outlvl = kwargs.get("outlvl", idaeslog.WARNING)
70 log = idaeslog.getLogger(__name__)
72 # --- Time index
73 t0 = blk.flowsheet().time.first()
75 # --- 1) Initialize inlet state blocks
76 inlet_blocks = list(blk.inlet_blocks)
77 if blk.config.has_desuperheating:
78 inlet_steam, inlet_water = inlet_blocks
79 inlet_water[t0].pressure.set_value(
80 inlet_steam[t0].pressure
81 )
82 inlet_water[t0].enth_mol.set_value(
83 blk.water.htpx(
84 blk.bfw_temperature[t0],
85 inlet_water[t0].pressure,
86 )
87 )
88 else:
89 inlet_steam = inlet_blocks[0]
91 for sb in inlet_blocks:
92 if hasattr(sb, "initialize"): 92 ↛ 91line 92 didn't jump to line 91 because the condition on line 92 was always true
93 sb.initialize(outlvl=outlvl)
95 # --- 2) Seed satuarate inlet-related state
96 if blk.config.has_desuperheating:
97 sb = blk._int_inlet_sat_vap_state
98 sb[t0].pressure.set_value(
99 inlet_steam[t0].pressure
100 )
101 sb[t0].enth_mol.set_value(
102 inlet_steam[t0].enth_mol_sat_phase["Vap"]
103 )
104 if hasattr(sb, "initialize"): 104 ↛ 108line 104 didn't jump to line 108 because the condition on line 104 was always true
105 sb.initialize(outlvl=outlvl)
107 # --- 3) Aggregate inlet info for seeding mixed state block
108 ms = blk._int_mixed_inlet_state
109 ms[t0].pressure.set_value(
110 inlet_steam[t0].pressure
111 )
112 if blk.config.has_desuperheating:
113 if value(blk.deltaT_superheat[t0]) > 0:
114 ms[t0].enth_mol.set_value(
115 blk.water.htpx(
116 T=blk._int_inlet_sat_vap_state[t0].temperature + blk.deltaT_superheat[t0],
117 p=inlet_steam[t0].pressure,
118 )
119 )
120 else:
121 ms[t0].enth_mol.set_value(
122 blk._int_inlet_sat_vap_state[t0].enth_mol
123 )
125 if abs(value(ms[t0].enth_mol - inlet_water[t0].enth_mol)) > 0: 125 ↛ 130line 125 didn't jump to line 130 because the condition on line 125 was always true
126 ms[t0].flow_mol.set_value(
127 inlet_steam[t0].flow_mol * (inlet_steam[t0].enth_mol - ms[t0].enth_mol) / (ms[t0].enth_mol - inlet_water[t0].enth_mol)
128 )
129 else:
130 ms[t0].flow_mol.set_value(
131 inlet_steam[t0].flow_mol
132 )
133 else:
134 ms[t0].enth_mol.set_value(
135 inlet_steam[t0].enth_mol
136 )
137 ms[t0].flow_mol.set_value(
138 inlet_steam[t0].flow_mol
139 )
141 if hasattr(ms, "initialize"): 141 ↛ 146line 141 didn't jump to line 146 because the condition on line 141 was always true
142 ms.initialize(outlvl=outlvl)
144 # --- 4) Seed outlet-related states
145 # Condensate after heating (internal)
146 ios = blk._int_outlet_cond_state
147 ios[t0].flow_mol.set_value(
148 ms[t0].flow_mol
149 )
150 ios[t0].pressure.set_value(
151 ms[t0].pressure - blk.pressure_loss[t0]
152 )
153 ios[t0].enth_mol.set_value(
154 blk.water.htpx(
155 T=blk._int_outlet_cond_state[t0].temperature_sat - blk.deltaT_subcool[t0],
156 p=ios[t0].pressure,
157 )
158 )
159 if hasattr(ios, "initialize"): 159 ↛ 163line 159 didn't jump to line 163 because the condition on line 159 was always true
160 ios.initialize(outlvl=outlvl)
162 # --- 5) Seed satuarate outlet-related state
163 sb = blk._int_outlet_sat_liq_state
164 sb[t0].pressure.set_value(
165 ios[t0].pressure
166 )
167 sb[t0].enth_mol.set_value(
168 ios[t0].enth_mol_sat_phase["Vap"]
169 )
170 if hasattr(sb, "initialize"): 170 ↛ 175line 170 didn't jump to line 175 because the condition on line 170 was always true
171 sb.initialize(outlvl=outlvl)
173 # --- 5) Seed external outlets
174 # Return line properties at user-specified temperature
175 ors = blk.outlet_return_state
176 ors[t0].flow_mol.set_value(ms[t0].flow_mol * value(blk.cond_return_rate[t0]))
177 ors[t0].pressure.set_value(
178 ms[t0].pressure - blk.pressure_loss[t0]
179 )
180 ors[t0].enth_mol.set_value(
181 blk.water.htpx(
182 T=blk.cond_return_temperature[t0],
183 p=ors[t0].pressure,
184 )
185 )
186 if hasattr(ors, "initialize"): 186 ↛ 191line 186 didn't jump to line 191 because the condition on line 186 was always true
187 ors.initialize(outlvl=outlvl)
189 # --- 6) Drain/blowdown outlet uses reference enthalpy (fixed later in build)
190 # Flow/pressure will be solved by constraints
191 if hasattr(blk.outlet_drain_state, "initialize"): 191 ↛ 195line 191 didn't jump to line 195 because the condition on line 191 was always true
192 blk.outlet_drain_state.initialize(outlvl=outlvl)
194 # --- 7) Solve
195 res = solver.solve(blk, tee=False)
196 log.info(f"SteamUser init status: {res.solver.termination_condition}")
197 return res
199def _make_config_block(config):
200 """Declare configuration options for the SteamUser unit.
202 Declares property package references and integer counts for inlets and outlets.
204 Args:
205 config (ConfigBlock): The mutable configuration block to populate.
206 """
208 config.declare(
209 "property_package",
210 ConfigValue(
211 default=useDefault,
212 domain=is_physical_parameter_block,
213 description="Property package to use for control volume",
214 ),
215 )
216 config.declare(
217 "property_package_args",
218 ConfigBlock(
219 implicit=True,
220 description="Arguments to use for constructing property packages",
221 ),
222 )
223 config.declare(
224 "has_desuperheating",
225 ConfigValue(
226 default=False,
227 domain=Bool,
228 description="If true, include desuperheating prior to use as process heat. " \
229 "Adds the state variable of the degree of superheat after desuperheating. Default: 0.",
230 ),
231 )
233@declare_process_block_class("SteamUser")
234class SteamUserData(UnitModelBlockData):
235 """Steam user unit operation.
237 The SteamUser aggregates thermal loads from multiple sub-users (heaters) within a site.
238 Desuperheating the flow is optional. Heat loss and pressure loss may be defined.
239 A mixed (intermediate) states are used for balances.
241 Key features:
242 - Material, energy, and momentum balances around the user
243 - Optional desuperheating step prior to process use
244 - User-specified condensate return rate and temperature
245 - Optional heat and pressure losses.
247 Attributes:
248 inlet_list (list[str]): Names for inlet ports.
249 outlet_list (list[str]): Names for outlet ports (incl. condensate/ and vent).
250 inlet_blocks (list): StateBlocks for all inlets.
251 outlet_blocks (list): StateBlocks for all outlets.
252 _int_mixed_inlet_state: Intermediate mixture StateBlock.
254 heat_loss (Var): Heat loss from the header (W).
255 pressure_loss (Var): Pressure drop from inlet minimum to mixed state (Pa).
256 bfw_flow_mol (Var): Required inlet boiler feed water flow for desuperheating (mol/s).
257 """
259 default_initializer=SteamUserInitializer
260 CONFIG = UnitModelBlockData.CONFIG()
261 _make_config_block(CONFIG)
263 def build(self) -> None:
264 """Build the unit model structure (ports, states, constraints)."""
265 # 1. Inherit standard UnitModelBlockData properties and functions
266 super().build()
268 # 2. Validate input parameters are valid
269 self._validate_model_config()
271 # 3. Create lists of ports with state blocks to add
272 self.inlet_list = self._create_inlet_port_name_list()
273 self.outlet_list = self._create_outlet_port_name_list()
275 # 4. Declare ports, state blocks and state property bounds
276 self.inlet_blocks = self._add_ports_with_state_blocks(
277 stream_list=self.inlet_list,
278 is_inlet=True,
279 has_phase_equilibrium=False,
280 is_defined_state=True,
281 )
282 self.outlet_blocks = self._add_ports_with_state_blocks(
283 stream_list=self.outlet_list,
284 is_inlet=False,
285 has_phase_equilibrium=False,
286 is_defined_state=False
287 )
288 self._internal_blocks = self._add_internal_state_blocks()
289 self._ref_enth = self._add_environmental_reference_enth()
290 self._add_bounds_to_state_properties()
292 # 4. Declare references, variables and expressions for external and internal use
293 self._create_references()
294 self._create_variables()
295 self._create_expressions()
297 # 5. Set balance equations
298 self._add_material_balances()
299 self._add_energy_balances()
300 self._add_momentum_balances()
301 self._add_additional_constraints()
303 # 6. Other
304 self.scaling_factor = Suffix(direction=Suffix.EXPORT)
306 # ------------------------------------------------------------------
307 # Helpers & construction utilities
308 # ------------------------------------------------------------------
309 def _validate_model_config(self) -> bool:
310 """Validate configuration for inlet and outlet counts.
312 Raises:
313 ValueError: If ``property_package is None``.
314 """
315 if self.config.property_package is None: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true
316 raise ValueError("SteamUser: Property package not defined.")
317 return True
319 def _create_inlet_port_name_list(self) -> List[str]:
320 """Build ordered inlet port names.
322 Returns:
323 list[str]: Names
324 """
326 return (
327 [
328 "inlet_steam", "inlet_water"
329 ]
330 if self.config.has_desuperheating else
331 [
332 "inlet_steam"
333 ]
334 )
336 def _create_outlet_port_name_list(self) -> List[str]:
337 """Build ordered outlet port names.
339 Returns:
340 list[str]: Names
341 """
342 return [
343 "outlet_return",
344 "outlet_drain",
345 ]
347 def _add_ports_with_state_blocks(self,
348 stream_list: List[str],
349 is_inlet: List[str],
350 has_phase_equilibrium: bool=False,
351 is_defined_state: bool=None,
352 ) -> List[StateBlock]:
353 """Construct StateBlocks and expose them as ports.
355 Creates a StateBlock per named stream and attaches a corresponding inlet or
356 outlet Port. Inlet blocks are defined states; outlet blocks are calculated states.
358 Args:
359 stream_list (list[str]): Port/StateBlock base names to create.
360 is_inlet (bool): If True, create inlet ports with ``defined_state=True``;
361 otherwise create outlet ports with ``defined_state=False``.
362 has_phase_equilibrium (bool)
364 Returns:
365 list: The created StateBlocks, in the same order as ``stream_list``.
366 """
367 # Create empty list to hold StateBlocks for return
368 state_block_ls = []
370 # Setup StateBlock argument dict
371 tmp_dict = dict(**self.config.property_package_args)
372 tmp_dict["has_phase_equilibrium"] = has_phase_equilibrium
373 if is_defined_state == None: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 tmp_dict["defined_state"] = True if is_inlet else False
375 else:
376 tmp_dict["defined_state"] = is_defined_state
378 # Create an instance of StateBlock for all streams
379 for s in stream_list:
380 sb = self.config.property_package.build_state_block(
381 self.flowsheet().time, doc=f"Thermophysical properties at {s}", **tmp_dict
382 )
383 setattr(
384 self, s + "_state",
385 sb
386 )
387 state_block_ls.append(sb)
388 add_fn = self.add_inlet_port if is_inlet else self.add_outlet_port
389 add_fn(
390 name=s,
391 block=sb,
392 )
394 return state_block_ls
396 def _add_internal_state_blocks(self) -> List[StateBlock]:
397 """Create the intermediate StateBlock(s)."""
398 # The _int_outlet_cond_state:
399 # - Is not a defined state (solved from balances).
400 # - Represents the state of the condensate after delivering process heating.
401 tmp_dict = dict(**self.config.property_package_args)
402 tmp_dict["has_phase_equilibrium"] = False
403 tmp_dict["defined_state"] = False
405 self._int_outlet_cond_state = self.config.property_package.build_state_block(
406 self.flowsheet().time,
407 doc="Thermophysical properties of condensate after process heating.",
408 **tmp_dict
409 )
410 # The _int_mixed_inlet_state:
411 # - Has phase equilibrium enabled.
412 # - Is not a defined state (solved from balances).
413 # - Always exists even when not desuperheating
414 tmp_dict["has_phase_equilibrium"] = True
415 tmp_dict["defined_state"] = False
416 self._int_mixed_inlet_state = self.config.property_package.build_state_block(
417 self.flowsheet().time,
418 doc="Thermophysical properties internal mixed inlet state after desuperheating (if applicable).",
419 **tmp_dict
420 )
421 # The _int_outlet_sat_liq_state:
422 # - Has phase equilibrium enabled.
423 # - Is not a defined state (solved from balances).
424 # - Always exists even when not desuperheating
425 tmp_dict["has_phase_equilibrium"] = True
426 tmp_dict["defined_state"] = False
427 self._int_outlet_sat_liq_state = self.config.property_package.build_state_block(
428 self.flowsheet().time,
429 doc="Thermophysical properties internal mixed saturate state.",
430 **tmp_dict
431 )
432 self._int_outlet_sat_liq_state[:].flow_mol.fix(1)
434 if self.config.has_desuperheating:
435 # The _int_inlet_sat_vap_state:
436 # - Has phase equilibrium enabled.
437 # - Is not a defined state (solved from balances).
438 # - Only exists when desuperheating
439 tmp_dict["has_phase_equilibrium"] = True
440 tmp_dict["defined_state"] = False
441 self._int_inlet_sat_vap_state = self.config.property_package.build_state_block(
442 self.flowsheet().time,
443 doc="Thermophysical properties internal mixed saturate state.",
444 **tmp_dict
445 )
446 self._int_inlet_sat_vap_state[:].flow_mol.fix(1)
448 return [
449 self._int_mixed_inlet_state,
450 self._int_outlet_cond_state,
451 self._int_outlet_sat_liq_state,
452 self._int_inlet_sat_vap_state,
453 ] if self.config.has_desuperheating else [
454 self._int_mixed_inlet_state,
455 self._int_outlet_cond_state,
456 self._int_outlet_sat_liq_state,
457 ]
459 def _add_environmental_reference_enth(self) -> None:
460 """Create a helper to compute reference enthalpy at 15°C, 1 atm (water)."""
461 self.water = build_package("helmholtz", ["water"], ["Liq"])
462 return self.water.htpx(
463 (15 + 273.15) * UNIT.K,
464 101325 * UNIT.Pa
465 )
467 def _add_bounds_to_state_properties(self) -> None:
468 """Add lower and/or upper bounds to state properties.
470 - Set nonnegativity lower bounds on all inlet/intermediate/outlet flows.
471 """
472 for sb in (self.inlet_blocks + self.outlet_blocks + self._internal_blocks):
473 for t in sb:
474 sb[t].flow_mol.setlb(0.0)
476 def _create_references(self) -> None:
477 """Create convenient References.
479 Creates references to _int_mixed_inlet_state properties:
480 - ``bfw_temperature``
481 - ``bfw_flow_mass``
482 - ``bfw_flow_mol``
483 """
484 # Read only variables, only applicable if desuperheating is active
485 if self.config.has_desuperheating:
486 self.bfw_flow_mass = Reference(
487 self.inlet_water_state[:].flow_mass
488 )
489 self.bfw_flow_mol = Reference(
490 self.inlet_water_state[:].flow_mol
491 )
492 self.inlet_water_state[:].flow_mol.unfix()
494 def _create_variables(self) -> None:
495 """Declare decision/parameter variables for the unit.
497 Creates:
498 - ``heat_demand``
499 - ``cond_return_rate``
500 - ``cond_return_temperature``
501 - ``deltaT_subcool``
502 - ``heat_loss``
503 - ``pressure_loss``
504 If desuperheating:
505 - ``bfw_temperature``
506 - ``deltaT_superheat``
507 """
508 # Get units consistent with the property package
509 units_meta = self.config.property_package.get_metadata()
511 # Calculated: Process heat demand (kW) — user typically fixes
512 self.heat_demand = Var(
513 self.flowsheet().time,
514 domain=NonNegativeReals,
515 doc="Process heat demand of the users. Default: 0 kW.",
516 units=units_meta.get_derived_units("power"),
517 )
518 self.heat_demand[:].set_value(
519 0 # Default value
520 )
521 # User defined: Fraction of total condensate that returns to boiler (dimensionless)
522 self.cond_return_rate = Var(
523 self.flowsheet().time,
524 domain=NonNegativeReals,
525 bounds=(0,1),
526 doc="Fraction of condensate returned to the boiler. Default: 0.7."
527 )
528 self.cond_return_rate.fix(
529 0.7 # Default value
530 )
531 # User defined: Condensate return temperature (degC or K)
532 self.cond_return_temperature = Var(
533 self.flowsheet().time,
534 domain=NonNegativeReals,
535 doc="Temperature at which the condensate returns to the boiler. Default: 80 degC.",
536 units=units_meta.get_derived_units("temperature"),
537 )
538 self.cond_return_temperature.fix(
539 (80 + 273.15) * UNIT.K # Default fixed value
540 )
541 # User defined: Subcooling target delta T for condensate after process heating (K)
542 self.deltaT_subcool = Var(
543 self.flowsheet().time,
544 domain=NonNegativeReals,
545 doc="The target amount of subcooling of the condensate after process heating. Default: 0 K.",
546 units=units_meta.get_derived_units("temperature"),
547 )
548 self.deltaT_subcool.fix(
549 0 # Default fixed value
550 )
551 # User defined: Heat and pressure losses
552 self.heat_loss = Var(
553 self.flowsheet().time,
554 domain=NonNegativeReals,
555 doc="Heat loss. Default: 0 kW.",
556 units=units_meta.get_derived_units("power")
557 )
558 self.heat_loss.fix(
559 0 # Default fixed value
560 )
561 self.pressure_loss = Var(
562 self.flowsheet().time,
563 domain=NonNegativeReals,
564 doc="Pressure loss. Default: 0 Pa.",
565 units=units_meta.get_derived_units("pressure")
566 )
567 self.pressure_loss.fix(
568 0 # Default fixed value
569 )
571 # User defined when desuperheating is active, otherwise do not show
572 if self.config.has_desuperheating:
573 # User defined: Boiler feed water temperature entering the desuperheater
574 self.bfw_temperature = Var(
575 self.flowsheet().time,
576 domain=NonNegativeReals,
577 doc="The target amount of subcooling of the condensate after process heating. Default: 0 K.",
578 units=units_meta.get_derived_units("temperature"),
579 )
580 self.bfw_temperature.fix(
581 (110 + 273.15) * UNIT.K # Default fixed value
582 )
583 # User defined: Target degree of superheat at the outlet of the desuperheater
584 self.deltaT_superheat = Var(
585 self.flowsheet().time,
586 domain=NonNegativeReals,
587 doc="The target amount of superheat present in the steam after desuperheating before use. Default: 0 K.",
588 units=units_meta.get_derived_units("temperature"),
589 )
590 self.deltaT_superheat.fix(
591 0 # Default fixed value
592 )
594 def _create_expressions(self) -> None:
595 """Create helper Expressions.
597 Creates:
598 - ``energy_lost``
599 """
600 # Calculated, always show
601 self.energy_lost = Expression(
602 self.flowsheet().time,
603 rule=lambda b, t: (
604 b._int_outlet_cond_state[t].flow_mol
605 * (b._int_outlet_cond_state[t].enth_mol - b._ref_enth)
606 -
607 b.outlet_return_state[t].flow_mol
608 * (b.outlet_return_state[t].enth_mol - b._ref_enth)
609 ),
610 doc="Energy lost from condensate cooling and condensate to drain.",
611 )
613 # ------------------------------------------------------------------
614 # Balances
615 # ------------------------------------------------------------------
616 def _add_material_balances(self) -> None:
617 """Material balance equations summary.
619 Balances / Constraints:
620 - ``overall_material_balance``
621 - ``condensate_return_material_eq``
622 - ``intermediate_material_balance_post_heating``
623 - ``intermediate_material_balance_pre_heating``
624 """
625 @self.Constraint(
626 self.flowsheet().time,
627 doc="Overall material balance",
628 )
629 def overall_material_balance(b, t):
630 return (
631 sum(
632 o[t].flow_mol
633 for o in b.outlet_blocks
634 )
635 ==
636 sum(
637 i[t].flow_mol
638 for i in b.inlet_blocks
639 )
640 )
641 @self.Constraint(
642 self.flowsheet().time,
643 doc="Intermediate material balance",
644 )
645 def intermediate_material_balance_pre_heating(b, t):
646 return (
647 b._int_mixed_inlet_state[t].flow_mol
648 ==
649 sum(
650 i[t].flow_mol
651 for i in b.inlet_blocks
652 )
653 )
654 @self.Constraint(
655 self.flowsheet().time,
656 doc="Intermediate material balance",
657 )
658 def intermediate_material_balance_post_heating(b, t):
659 return (
660 b._int_outlet_cond_state[t].flow_mol
661 ==
662 sum(
663 i[t].flow_mol
664 for i in b.inlet_blocks
665 )
666 )
667 @self.Constraint(
668 self.flowsheet().time,
669 doc="Condensate return material equation",
670 )
671 def condensate_return_material_eq(b, t):
672 return (
673 b.outlet_return_state[t].flow_mol
674 ==
675 sum(
676 i[t].flow_mol
677 for i in b.inlet_blocks
678 )
679 *
680 b.cond_return_rate[t]
681 )
683 def _add_energy_balances(self) -> None:
684 """Energy balance equations summary.
686 Balances / Constraints:
687 - ``mixing_energy_balance``
688 - ``heating_energy_balance``
689 """
690 @self.Constraint(
691 self.flowsheet().time,
692 doc="Inlet mixing energy balance",
693 )
694 def mixing_energy_balance(b, t):
695 return (
696 b._int_mixed_inlet_state[t].flow_mol * b._int_mixed_inlet_state[t].enth_mol
697 ==
698 sum(
699 i[t].flow_mol * i[t].enth_mol
700 for i in b.inlet_blocks
701 )
702 )
703 @self.Constraint(
704 self.flowsheet().time,
705 doc="Process heating energy balance",
706 )
707 def heating_energy_balance(b, t):
708 return (
709 b._int_mixed_inlet_state[t].flow_mol * b._int_mixed_inlet_state[t].enth_mol
710 ==
711 b._int_outlet_cond_state[t].flow_mol * b._int_outlet_cond_state[t].enth_mol
712 +
713 b.heat_loss[t]
714 +
715 b.heat_demand[t]
716 )
717 self.outlet_drain_state[:].enth_mol.fix(
718 value(
719 self._ref_enth
720 )
721 )
722 @self.Constraint(
723 self.flowsheet().time,
724 doc="Saturated liquid enthalpy",
725 )
726 def saturated_liq_enthalpy_eq(b, t):
727 return (
728 b._int_outlet_sat_liq_state[t].enth_mol_sat_phase["Liq"]
729 ==
730 b._int_outlet_sat_liq_state[t].enth_mol
731 )
732 if self.config.has_desuperheating:
733 @self.Constraint(
734 self.flowsheet().time,
735 doc="Saturated vapour enthalpy",
736 )
737 def saturated_vap_enthalpy_eq(b, t):
738 return (
739 b._int_inlet_sat_vap_state[t].enth_mol_sat_phase["Vap"]
740 ==
741 b._int_inlet_sat_vap_state[t].enth_mol
742 )
744 def _add_momentum_balances(self) -> None:
745 """Momentum balance equations summary.
747 Balances / Constraints:
748 - ``mixing_momentum_balance``
749 - ``heating_momentum_balance``
750 - ``outlet_momentum_balance``
751 If desuperheating:
752 - ``intlet_water_momentum_balance``
753 """
754 @self.Constraint(
755 self.flowsheet().time,
756 doc="Momentum equalities",
757 )
758 def mixing_momentum_balance(b, t):
759 return (
760 b.inlet_steam_state[t].pressure
761 ==
762 b._int_mixed_inlet_state[t].pressure
763 )
764 @self.Constraint(
765 self.flowsheet().time,
766 doc="Process heating momentum balance",
767 )
768 def heating_momentum_balance(b, t):
769 return (
770 b._int_mixed_inlet_state[t].pressure
771 ==
772 b._int_outlet_cond_state[t].pressure
773 +
774 b.pressure_loss[t]
775 )
776 @self.Constraint(
777 self.flowsheet().time,
778 doc="Saturated liq pressure",
779 )
780 def saturated_liq_pressure_eq(b, t):
781 return (
782 b._int_outlet_sat_liq_state[t].pressure
783 ==
784 b._int_outlet_cond_state[t].pressure
785 )
786 @self.Constraint(
787 self.flowsheet().time,
788 doc="Outlet momentum equality",
789 )
790 def outlet_momentum_balance(b, t):
791 return (
792 b.outlet_return_state[t].pressure
793 ==
794 b.outlet_drain_state[t].pressure
795 )
796 self.outlet_return_state[:].pressure.fix(
797 101325 * UNIT.Pa # Fixed value, hidden from the user
798 )
799 if self.config.has_desuperheating:
800 @self.Constraint(
801 self.flowsheet().time,
802 doc="Inlet water momentum balance",
803 )
804 def intlet_water_momentum_balance(b, t):
805 return (
806 b.inlet_water_state[t].pressure
807 ==
808 b.inlet_steam_state[t].pressure
809 )
810 @self.Constraint(
811 self.flowsheet().time,
812 doc="Saturated vapour pressure",
813 )
814 def saturated_vap_pressure_eq(b, t):
815 return (
816 b.inlet_steam_state[t].pressure
817 ==
818 b._int_inlet_sat_vap_state[t].pressure
819 )
821 def _add_additional_constraints(self) -> None:
822 """Add auxiliary constraints and bounds.
824 Constraints:
825 - ``condensate_temperature_eq``
826 - ``subcooling_temperature_eq``
827 If desuperheating:
828 - ``desuperheating_mixed_temperature_eq``
829 """
830 @self.Constraint(
831 self.flowsheet().time,
832 doc="Condensate return temperature",
833 )
834 def condensate_temperature_eq(b, t):
835 return (
836 b.outlet_return_state[t].temperature
837 ==
838 b.cond_return_temperature[t]
839 )
840 @self.Constraint(
841 self.flowsheet().time,
842 doc="Subcool temperature",
843 )
844 def subcooling_temperature_eq(b, t):
845 return (
846 b._int_outlet_cond_state[t].temperature
847 ==
848 b._int_outlet_sat_liq_state[t].temperature - b.deltaT_subcool[t]
849 )
850 if self.config.has_desuperheating:
851 @self.Constraint(
852 self.flowsheet().time,
853 doc="Inlet water temperature",
854 )
855 def inlet_water_temperature_eq(b, t):
856 return (
857 b.inlet_water_state[t].temperature
858 ==
859 b.bfw_temperature[t]
860 )
861 @self.Constraint(
862 self.flowsheet().time,
863 doc="Mixed temperature after desuperheating",
864 )
865 def desuperheating_mixed_temperature_eq(b, t):
866 return (
867 b._int_mixed_inlet_state[t].temperature
868 ==
869 b._int_inlet_sat_vap_state[t].temperature + b.deltaT_superheat[t]
870 )
872 def calculate_scaling_factors(self):
873 """Assign scaling factors to improve numerical conditioning.
875 Sets scaling factors for performance and auxiliary variables.
876 """
877 super().calculate_scaling_factors()
878 scaling.set_scaling_factor(self.heat_loss, 1e-6) # kW scale
879 scaling.set_scaling_factor(self.pressure_loss, 1e-6) # Pa scale
881 def _get_stream_table_contents(self, time_point=0):
882 """Create a stream table for all inlets and outlets.
884 Args:
885 time_point (int | float): Time index at which to extract stream data.
887 Returns:
888 pandas.DataFrame: A tabular view suitable for reporting via
889 ``create_stream_table_dataframe``.
890 """
891 io_dict = {}
893 for inlet_name in self.inlet_list:
894 io_dict[inlet_name] = getattr(self, inlet_name)
896 for outlet_name in self.outlet_list:
897 io_dict[outlet_name] = getattr(self, outlet_name)
899 return create_stream_table_dataframe(io_dict, time_point=time_point)
901 def _get_performance_contents(self, time_point=0):
902 """Collect performance variables for reporting.
904 Args:
905 time_point (int | float): Time index at which to report values.
907 Returns:
908 dict: Mapping used by IDAES reporters, containing human-friendly labels
909 to Vars/References (e.g., heat/pressure loss, mixed-state properties).
910 """
911 perf = {
912 "vars": {
913 "Heat demand [W]": self.heat_demand[time_point],
914 "Degree of subcooling target [K]": self.deltaT_subcool[time_point],
915 "Heat loss [W]": self.heat_loss[time_point],
916 "Pressure loss [Pa]": self.pressure_loss[time_point],
917 "Condensate return rate [-]:": self.cond_return_rate[time_point],
918 "Condensate return temperature [degC]": UNIT.convert_temp_K_to_C(self.cond_return_temperature[time_point]),
919 },
920 "exprs": {
921 "Energy lost to return network [W]": self.energy_lost[time_point],
922 },
923 }
924 if self.config.has_desuperheating:
925 perf["vars"].update(
926 {
927 "BFW temperature [K]": self.bfw_temperature[time_point],
928 "Degree of superheat target [K]": self.deltaT_superheat[time_point],
929 }
930 )
931 return perf
933 def initialize(self, *args, **kwargs):
934 """Initialize the SteamUser unit using :class:`SteamUserInitializer`.
936 Args:
937 *args: Forwarded to ``SteamUserInitializer.initialize``.
938 **kwargs: Forwarded to ``SteamUserInitializer.initialize`` (e.g., solver, options).
940 Returns:
941 pyomo.opt.results.results_.SolverResults: Results from the initializer's solve.
942 """
943 init = SteamUserInitializer()
944 return init.initialize(self, *args, **kwargs)