Coverage for backend/ahuora-builder/src/ahuora_builder/custom/thermal_utility_systems/header.py: 83%

328 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1"""Header unit model. 

2Implementation by Tim and Keegan, taken from Ahuora-UnitOperations repo""" 

3 

4from typing import List, Optional, Iterable 

5 

6from pyomo.common.config import Bool, ConfigBlock, ConfigValue, In 

7import pyomo.environ as pyo 

8from pyomo.environ import ( 

9 Constraint, 

10 Expression, 

11 Param, 

12 PositiveReals, 

13 RangeSet, 

14 Suffix, 

15 Var, 

16 check_optimal_termination, 

17 value, 

18 units as pyunits, 

19) 

20from pyomo.core.base.reference import Reference 

21 

22from idaes.core import StateBlock, UnitModelBlockData, declare_process_block_class, useDefault 

23from idaes.core.initialization import ModularInitializerBase 

24from idaes.core.solvers import get_solver 

25from idaes.core.util import scaling as iscale 

26from idaes.core.scaling import CustomScalerBase, ConstraintScalingScheme 

27from idaes.core.util.config import is_physical_parameter_block 

28from idaes.core.util.exceptions import InitializationError 

29from idaes.core.util.math import smooth_max, smooth_min 

30from idaes.core.util.model_diagnostics import DiagnosticsToolbox 

31from idaes.core.util.model_statistics import degrees_of_freedom, report_statistics 

32from idaes.core.util.tables import create_stream_table_dataframe 

33 

34import idaes.logger as idaeslog 

35 

36_log = idaeslog.getLogger(__name__) 

37 

38__author__ = "Ahuora Centre for Smart Energy Systems, University of Waikato, New Zealand" 

39 

40 

41def _build_config(config: ConfigBlock) -> None: 

42 """Declare configuration options for the Header unit. 

43 

44 Declares property package references and integer counts for inlets and outlets. 

45 

46 Args: 

47 config (ConfigBlock): The mutable configuration block to populate. 

48 

49 Raises: 

50 ValueError: If invalid option values are provided by the caller (via IDAES). 

51 """ 

52 

53 config.declare( 

54 "property_package", 

55 ConfigValue( 

56 default=useDefault, 

57 domain=is_physical_parameter_block, 

58 description="Property package to use for control volume", 

59 ), 

60 ) 

61 config.declare( 

62 "property_package_args", 

63 ConfigBlock( 

64 implicit=True, 

65 description="Arguments to use for constructing property packages", 

66 ), 

67 ) 

68 config.declare( 

69 "num_inlets", 

70 ConfigValue( 

71 default=1, 

72 domain=In(list(range(1, 100))), 

73 description="Number of utility providers at inlets.", 

74 ), 

75 ) 

76 config.declare( 

77 "num_outlets", 

78 ConfigValue( 

79 default=1, 

80 domain=In(list(range(0, 100))), 

81 description=( 

82 "Number of utility users at outlets. Excludes outlets " 

83 "associated with condensate and vent flows." 

84 ), 

85 ), 

86 ) 

87 config.declare( 

88 "is_liquid_header", 

89 ConfigValue( 

90 default=False, 

91 domain=Bool, 

92 description="Flag for selecting liquid or vapour (including steam and other gases).", 

93 ), 

94 ) 

95 

96 

97@declare_process_block_class("simple_header") 

98class SimpleHeaderData(UnitModelBlockData): 

99 """Thermal utility header unit operation.""" 

100 

101 CONFIG = UnitModelBlockData.CONFIG() 

102 _build_config(CONFIG) 

103 

104 def build(self) -> None: 

105 """Build the unit model structure and equations.""" 

106 super().build() 

107 units_meta = self.config.property_package.get_metadata().get_derived_units 

108 

109 if self.config.num_inlets < 1: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 raise ValueError("Header requires at least one provider (num_inlets >= 1).") 

111 if self.config.num_outlets < 1: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 raise ValueError("Header requires at least one user (num_outlets >= 1).") 

113 

114 self.inlet_list = [f"inlet_{i+1}" for i in range(self.config.num_inlets)] 

115 self.outlet_list = [f"outlet_{i+1}" for i in range(self.config.num_outlets)] + [ 

116 "outlet_condensate", 

117 "outlet_vent", 

118 ] 

119 

120 """ 

121 1. Build inlet, outlet and internal state blocks and associate  

122 with ports (where applicable) 

123 """ 

124 self.inlet_blocks = self._build_state_blocks( 

125 stream_name_list=self.inlet_list, 

126 has_phase_equilibrium=False, 

127 is_defined_state=True, 

128 is_build_port=True, 

129 ) 

130 

131 

132 self._outlet_supply_blocks = self._build_state_blocks( 

133 stream_name_list=[f"outlet_{i+1}" for i in range(self.config.num_outlets)], 

134 has_phase_equilibrium=False, 

135 is_defined_state=False, 

136 is_build_port=True, 

137 ) 

138 self._outlet_vent_blocks = self._build_state_blocks( 

139 stream_name_list=["outlet_condensate", "outlet_vent"], 

140 has_phase_equilibrium=False, 

141 is_defined_state=False, 

142 is_build_port=True, 

143 ) 

144 self.outlet_blocks = self._outlet_supply_blocks + self._outlet_vent_blocks 

145 self.internal_blocks = self._build_state_blocks( 

146 stream_name_list=["mixed"], 

147 has_phase_equilibrium=True, 

148 is_defined_state=False, 

149 is_build_port=False, 

150 ) 

151 for sb in (self.inlet_blocks + self.internal_blocks + self.outlet_blocks): 

152 for t in sb: 

153 sb[t].flow_mol.setlb(0.0) 

154 

155 """ 

156 2. Create parameters, variables, references and expressions 

157 """ 

158 # References 

159 self.total_flow_mol = Reference(self.mixed_state[:].flow_mol) 

160 self.total_flow_mass = Reference(self.mixed_state[:].flow_mass) 

161 self.pressure = Reference(self.mixed_state[:].pressure) 

162 self.temperature = Reference(self.mixed_state[:].temperature) 

163 self.enth_mol = Reference(self.mixed_state[:].enth_mol) 

164 self.enth_mass = Reference(self.mixed_state[:].enth_mass) 

165 self.vapor_frac = Reference(self.mixed_state[:].vapor_frac) 

166 

167 # State variables 

168 self.heat_loss = Var( 

169 self.flowsheet().time, 

170 initialize=0.0, 

171 bounds=(0, None), 

172 units=units_meta("power"), 

173 doc="Heat loss", 

174 ) 

175 self.pressure_loss = Var( 

176 self.flowsheet().time, 

177 initialize=0, 

178 bounds=(0, None), 

179 units=units_meta("pressure"), 

180 doc="Pressure loss.", 

181 ) 

182 

183 # Non-normal state variables, other parameters and expression 

184 if self.config.is_liquid_header: 

185 self._liq_out_enth_mol = Var( 

186 self.flowsheet().time, 

187 initialize=42.0 * 18 * pyunits.J / pyunits.mol, 

188 units=units_meta("energy") / units_meta("amount"), 

189 doc="Molar enthalpy of the liquid outlets", 

190 ) 

191 else: 

192 self._vap_out_enth_mol = Var( 

193 self.flowsheet().time, 

194 initialize=2700.0 * 18 * pyunits.J / pyunits.mol, 

195 units=units_meta("energy") / units_meta("amount"), 

196 doc="Molar enthalpy of the vapour outlets", 

197 ) 

198 inlet_idx = RangeSet(len(self.inlet_blocks)) 

199 self._minimum_pressure = Var( 

200 self.flowsheet().time, 

201 inlet_idx, 

202 doc="Variable for calculating minimum inlet pressure", 

203 units=units_meta("pressure"), 

204 ) 

205 self._eps_pressure = Param( 

206 mutable=True, 

207 initialize=1e-3, 

208 domain=PositiveReals, 

209 doc="Smoothing term for minimum inlet pressure", 

210 units=units_meta("pressure"), 

211 ) 

212 

213 # Expressions 

214 self.degree_of_superheat = Expression( 

215 self.flowsheet().time, 

216 rule=lambda b, t: ( 

217 b.temperature[t] - b.outlet_condensate_state[t].temperature 

218 ), 

219 ) 

220 self._partial_total_flow_mol = Expression( 

221 self.flowsheet().time, 

222 rule=lambda b, t: ( 

223 sum(o[t].flow_mol for o in (b.inlet_blocks + b._outlet_supply_blocks)) 

224 ), 

225 ) 

226 self.balance_flow_mol = Expression( 

227 self.flowsheet().time, 

228 rule=lambda b, t: ( 

229 sum(i[t].flow_mol for i in b.inlet_blocks) 

230 - 

231 sum( 

232 o[t].flow_mol 

233 for o in ( 

234 b._outlet_supply_blocks 

235 + 

236 [ 

237 b.outlet_vent_state 

238 if self.config.is_liquid_header 

239 else b.outlet_condensate_state, 

240 ] 

241 ) 

242 ) 

243 ), 

244 doc="Flow imbalance between inlets and outlets; positive if in excess of supply to outlets.", 

245 ) 

246 self.makeup_flow_mol = Expression( 

247 self.flowsheet().time, 

248 rule=lambda b, t: ( 

249 ( 

250 b.outlet_condensate_state[t].flow_mol 

251 if self.config.is_liquid_header 

252 else b.outlet_vent_state[t].flow_mol 

253 ) 

254 - 

255 b.balance_flow_mol[t] 

256 ), 

257 ) 

258 

259 """ 

260 3. Declare constraints to define mass, energy, and momentum balances,  

261 unit operation performance and other constraint  

262 """ 

263 # a) Material balance equations 

264 @self.Constraint(self.flowsheet().time, doc="Mixed state material balance") 

265 def mixed_state_material_balance(b, t): 

266 return b.mixed_state[t].flow_mol == sum(i[t].flow_mol for i in b.inlet_blocks) 

267 

268 eps_smooth = 1e-5 # smoothing parameter; smaller = closer to exact max, larger = smoother 

269 eps_div = 1e-6 # small number to prevent division by zero 

270 if self.config.is_liquid_header: 

271 # Assigns excess liquid flow to outlet_condensate 

272 @self.Constraint(self.flowsheet().time, doc="Condensate flow balance.") 

273 def condensate_flow_balance(b, t): 

274 return ( 

275 b.outlet_condensate_state[t].flow_mol 

276 == 

277 smooth_max( 

278 b.balance_flow_mol[t] / (b._partial_total_flow_mol[t] + eps_div*pyunits.mol / pyunits.s), 

279 0.0, 

280 eps_smooth, 

281 ) * (b._partial_total_flow_mol[t] + eps_div*pyunits.mol / pyunits.s) 

282 ) 

283 

284 # Removes any gas/vapour from a liquid header 

285 @self.Constraint(self.flowsheet().time, doc="Vent flow balance.") 

286 def vent_flow_balance(b, t): 

287 return b.outlet_vent_state[t].flow_mol == b.mixed_state[t].flow_mol * b.mixed_state[t].vapor_frac 

288 

289 else: 

290 # Assigns excess steam/vapour flow to outlet_vent 

291 @self.Constraint(self.flowsheet().time, doc="Vent flow balance.") 

292 def vent_flow_balance(b, t): 

293 return ( 

294 b.outlet_vent_state[t].flow_mol 

295 == 

296 smooth_max( 

297 b.balance_flow_mol[t] / (b._partial_total_flow_mol[t] + eps_div*pyunits.mol / pyunits.s), 

298 0.0, 

299 eps_smooth, 

300 ) * (b._partial_total_flow_mol[t] + eps_div*pyunits.mol / pyunits.s) 

301 ) 

302 

303 # Removes any condensate/liquid from a steam/gas header 

304 @self.Constraint(self.flowsheet().time, doc="Condensate flow balance.") 

305 def condensate_flow_balance(b, t): 

306 return b.outlet_condensate_state[t].flow_mol == b.mixed_state[t].flow_mol * (1 - b.mixed_state[t].vapor_frac) 

307 

308 # b) Energy balance equations 

309 @self.Constraint(self.flowsheet().time, doc="Energy balance for inlets to mixed state including heat loss") 

310 def inlets_to_mixed_state_energy_balance(b, t): 

311 return ( 

312 b.mixed_state[t].flow_mol * b.mixed_state[t].enth_mol 

313 + b.heat_loss[t] 

314 == sum(i[t].flow_mol * i[t].enth_mol for i in b.inlet_blocks) 

315 ) 

316 

317 @self.Constraint(self.flowsheet().time, doc="Energy balance for mixed state to outlets") 

318 def mixed_state_to_outlets_energy_balance(b, t): 

319 return ( 

320 b.mixed_state[t].enth_mol * sum(o[t].flow_mol for o in b.outlet_blocks) 

321 == 

322 sum(o[t].flow_mol * o[t].enth_mol for o in b.outlet_blocks) 

323 ) 

324 

325 if self.config.is_liquid_header: 

326 self._outlet_blocks_exc_vent = self._outlet_supply_blocks + [self.outlet_condensate_state] 

327 @self.Constraint(self.flowsheet().time, self._outlet_blocks_exc_vent, 

328 doc="All liquid outlets (incl. condensate) share a common liquid enthalpy", 

329 ) 

330 def molar_enthalpy_equality_eqn(b, t, o): 

331 return o[t].enth_mol == b._liq_out_enth_mol[t] 

332 else: 

333 self._outlet_blocks_exc_condensate = self._outlet_supply_blocks + [self.outlet_vent_state] 

334 @self.Constraint(self.flowsheet().time, self._outlet_blocks_exc_condensate, 

335 doc="All vapour outlets (incl. vent) share a common vapour enthalpy", 

336 ) 

337 def molar_enthalpy_equality_eqn(b, t, o): 

338 return o[t].enth_mol == b._vap_out_enth_mol[t] 

339 

340 # c) Momentum balance equations 

341 @self.Constraint(self.flowsheet().time, inlet_idx, 

342 doc="Calculation for minimum inlet pressure", 

343 ) 

344 def minimum_pressure_constraint(b, t, i): 

345 if i == inlet_idx.first(): 

346 return b._minimum_pressure[t, i] == b.inlet_blocks[i - 1][t].pressure 

347 else: 

348 return ( 

349 b._minimum_pressure[t, i] 

350 == 

351 smooth_min( 

352 b._minimum_pressure[t, i - 1], 

353 b.inlet_blocks[i - 1][t].pressure, 

354 b._eps_pressure, 

355 ) 

356 ) 

357 # Set mixed pressure to minimum inlet pressure minus any pressure loss 

358 @self.Constraint(self.flowsheet().time, doc="Pressure equality constraint from minimum inlet to mixed state") 

359 def mixture_pressure(b, t): 

360 return b.mixed_state[t].pressure == ( 

361 b._minimum_pressure[t, inlet_idx.last()] - b.pressure_loss[t] 

362 ) 

363 # Set outlet pressures to mixed pressure 

364 @self.Constraint(self.flowsheet().time, self.outlet_blocks, 

365 doc="Pressure equality constraint from mixed state to outlets", 

366 ) 

367 def pressure_equality_eqn(b, t, o): 

368 return b.mixed_state[t].pressure == o[t].pressure 

369 

370 # d) Additional constraints for vapour/liquid separation and outlet splits 

371 if self.config.is_liquid_header: 

372 @self.Constraint(self.flowsheet().time, doc="Vent vapour fraction.") 

373 def vent_vapour_fraction(b, t): 

374 return b.outlet_vent_state[t].vapor_frac == 1 # 1 - 1e-6 

375 else: 

376 @self.Constraint(self.flowsheet().time, doc="Condensate vapour fraction.") 

377 def condensate_vapour_fraction(b, t): 

378 return b.outlet_condensate_state[t].vapor_frac == 0 # 1e-6 

379 

380 """ 

381 4. Other model components and references  

382 """ 

383 self.scaling_factor = Suffix(direction=Suffix.EXPORT) 

384 self.outlet_idx = pyo.Set(initialize=self.outlet_list) 

385 # Map each (t, o) to the outlet state's flow var 

386 ref_map = {} 

387 for o in self.outlet_list: 

388 if o != "vent": 388 ↛ 387line 388 didn't jump to line 387 because the condition on line 388 was always true

389 outlet_state_block = getattr(self, f"{o}_state") 

390 for t in self.flowsheet().time: 

391 ref_map[(t, o)] = outlet_state_block[t].flow_mol 

392 

393 self.split_flow = Reference(ref_map) 

394 

395 

396 def initialize_build(self, state_args=None, outlvl=idaeslog.NOTSET, solver=None, optarg=None): 

397 """ 

398 General wrapper for template initialization routines 

399 

400 Keyword Arguments: 

401 state_args : a dict of arguments to be passed to the property 

402 package(s) to provide an initial state for 

403 initialization (see documentation of the specific 

404 property package) (default = {}). 

405 outlvl : sets output level of initialization routine 

406 optarg : solver options dictionary object (default=None) 

407 solver : str indicating which solver to use during 

408 initialization (default = None) 

409 

410 Returns: None 

411 """ 

412 init_log = idaeslog.getInitLogger(self.name, outlvl, tag="unit") 

413 solve_log = idaeslog.getSolveLogger(self.name, outlvl, tag="unit") 

414 t0 = self.flowsheet().config.time.first() 

415 

416 opt = get_solver(solver, optarg) 

417 

418 pp = self.mixed_state[t0].params 

419 state_args = {} if state_args is None else dict(state_args) 

420 

421 shared_state_args = { 

422 key: state_args[key] 

423 for key in ("flow_mol", "pressure", "enth_mol", "temperature") 

424 if key in state_args 

425 } 

426 

427 def _state_seed_args(stream_name): 

428 local_args = dict(shared_state_args) 

429 local_args.update(state_args.get(stream_name, {})) 

430 return local_args 

431 

432 inlet_state_args = {name: _state_seed_args(name) for name in self.inlet_list} 

433 outlet_state_args = {name: _state_seed_args(name) for name in self.outlet_list} 

434 mixed_state_args = _state_seed_args("mixed") 

435 

436 # Helper functions 

437 def _value_or_none(obj): # returns the value of a Var or Param if it is fixed, otherwise returns None 

438 return value(obj, exception=False) 

439 

440 def _pick_seed(*candidates): # loops through different options of seeding and picks the first (in order of preference) 

441 for candidate in candidates: 441 ↛ 444line 441 didn't jump to line 444 because the loop on line 441 didn't complete

442 if candidate is not None: 

443 return candidate 

444 return None 

445 

446 def _enthalpy_from_tp(temperature, pressure): 

447 if temperature is None or pressure is None: 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 return None 

449 return value(pp.htpx(temperature * pyunits.K, pressure * pyunits.Pa)) 

450 

451 

452 def _safe_nonnegative(val, fallback=0.0): 

453 if val is None: 

454 return fallback 

455 return max(val, 0.0) 

456 

457 #----------------------------------------------------- 

458 # 1. Build state seeds. Prefer explicit state_args, then connected/fixed 

459 # values already present on the unit, then infer from local specs, then 

460 # fall back to generic guesses. 

461 

462 # Inlets flows ranking 

463 # 1. Explicit state_args 

464 # 2. A fixed inlet flow on this unit 

465 # 3. A propagated upstream inlet flow 

466 # 4. Back-calculate from fixed outlet flow(s) + 10% divided evenly among inlets. The 10% is for venting 

467 # 5. Nominal fallback 

468 

469 # First get an estimate of the total inlet based on outlet flows if they were fixed 

470 fixed_outlet_flows = [] 

471 for outlet_sb in self.outlet_blocks: 

472 outlet_flow_fixed = ( 

473 _value_or_none(outlet_sb[t0].flow_mol) 

474 if outlet_sb[t0].flow_mol.fixed 

475 else None 

476 ) 

477 if outlet_flow_fixed is not None: 

478 fixed_outlet_flows.append(outlet_flow_fixed) 

479 

480 total_fixed_outlet_flow = ( 

481 sum(fixed_outlet_flows) if fixed_outlet_flows else None 

482 ) 

483 nominal_total_flow = _pick_seed(total_fixed_outlet_flow, 500.0) 

484 nominal_inlet_flow = nominal_total_flow / max(len(self.inlet_blocks), 1) 

485 

486 # Now build the inlet seeds using the ranking above 

487 inlet_seeds = {} 

488 seeded_inlet_flows = [] 

489 for inlet_name, inlet_sb in zip(self.inlet_list, self.inlet_blocks): 

490 inlet_has_source = len(list(getattr(self, inlet_name).sources())) > 0 

491 local_args = inlet_state_args[inlet_name] 

492 

493 flow_from_fixed = ( 

494 _value_or_none(inlet_sb[t0].flow_mol) 

495 if inlet_sb[t0].flow_mol.fixed 

496 else None 

497 ) 

498 flow_from_upstream = ( 

499 _value_or_none(inlet_sb[t0].flow_mol) 

500 if inlet_has_source 

501 else None 

502 ) 

503 flow_from_fixed_outlets = ( 

504 total_fixed_outlet_flow / max(len(self.inlet_blocks), 1) * 1.1 

505 if total_fixed_outlet_flow is not None 

506 else None 

507 ) 

508 

509 f_in = _pick_seed( 

510 local_args.get("flow_mol"), 

511 flow_from_fixed, 

512 flow_from_upstream, 

513 flow_from_fixed_outlets, 

514 nominal_inlet_flow, 

515 ) 

516 

517 inlet_seeds[inlet_name] = {"flow_mol": f_in} 

518 seeded_inlet_flows.append(f_in) 

519 

520 # Pass these seeded inlet flows to the mixed state and outlet states 

521 f_mixed = sum(seeded_inlet_flows) 

522 

523 # Outlet flow ranking: 

524 # 1. Fixed outlet flow on this unit 

525 # 2. Even split of seeded inlet flow (minus 10% for venting) divided among numbered outlets, vent gets 10%  

526 

527 # First split the total flow into outlet and vent portions. The outlet portion is then divided evenly among the numbered outlets, and the vent portion is assigned to the vent outlet. This is just a starting guess to help initialization, and will be overridden if there are fixed outlet flows that provide a better seed. 

528 numbered_outlet_flow = 0.9 * f_mixed / max(self.config.num_outlets, 1) 

529 vent_flow = 0.1 * f_mixed 

530 

531 # Now build the outlet seeds using the ranking above 

532 outlet_seeds = {} 

533 seeded_outlet_flows = [] 

534 for outlet_name, outlet_sb in zip(self.outlet_list, self.outlet_blocks): 

535 flow_from_fixed = ( 

536 _value_or_none(outlet_sb[t0].flow_mol) 

537 if outlet_sb[t0].flow_mol.fixed 

538 else None 

539 ) 

540 

541 if outlet_name.startswith("outlet_") and outlet_name not in ( 

542 "outlet_condensate", 

543 "outlet_vent", 

544 ): 

545 default_flow = numbered_outlet_flow 

546 elif outlet_name == "outlet_vent": 

547 default_flow = vent_flow 

548 else: 

549 default_flow = 0.0 

550 

551 f_out = _pick_seed( 

552 flow_from_fixed, 

553 default_flow, 

554 ) 

555 

556 outlet_seeds[outlet_name] = {"flow_mol": f_out} 

557 seeded_outlet_flows.append(f_out) 

558 

559 # Inlet pressure ranking 

560 # 1. Explicit state_args 

561 # 2. A propagated upstream inlet pressure 

562 # 3. A fixed inlet pressure on this unit 

563 # 4. Assume all inlet pressures equal to the outlet pressure + pressure loss 

564 # 5. Nominal fallback of 10 bar  

565 

566 # First determine the outlet pressure if it were fixed  

567 pressure_loss = _pick_seed(_value_or_none(self.pressure_loss[t0]), 0.0) 

568 fixed_outlet_pressure = None 

569 for outlet_sb in self.outlet_blocks: 

570 outlet_pressure_fixed = ( 

571 _value_or_none(outlet_sb[t0].pressure) 

572 if outlet_sb[t0].pressure.fixed 

573 else None 

574 ) 

575 if outlet_pressure_fixed is not None: 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true

576 fixed_outlet_pressure = outlet_pressure_fixed 

577 break 

578 

579 inlet_pressure_from_outlet = ( 

580 fixed_outlet_pressure + pressure_loss 

581 if fixed_outlet_pressure is not None 

582 else None 

583 ) 

584 

585 # Now build the inlet pressure seeds using the ranking above 

586 seeded_inlet_pressures = [] 

587 for inlet_name, inlet_sb in zip(self.inlet_list, self.inlet_blocks): 

588 inlet_has_source = len(list(getattr(self, inlet_name).sources())) > 0 

589 local_args = inlet_state_args[inlet_name] 

590 

591 pressure_from_upstream = ( 

592 _value_or_none(inlet_sb[t0].pressure) 

593 if inlet_has_source 

594 else None 

595 ) 

596 pressure_from_fixed = ( 

597 _value_or_none(inlet_sb[t0].pressure) 

598 if inlet_sb[t0].pressure.fixed 

599 else None 

600 ) 

601 

602 p_in = _pick_seed( 

603 local_args.get("pressure"), 

604 pressure_from_upstream, 

605 pressure_from_fixed, 

606 inlet_pressure_from_outlet, 

607 10e5, 

608 ) 

609 

610 inlet_seeds[inlet_name]["pressure"] = p_in 

611 seeded_inlet_pressures.append(p_in) 

612 

613 # Pass the seeded mixed pressure to the outlet states 

614 p_mixed = min(seeded_inlet_pressures) - pressure_loss 

615 for outlet_name, outlet_sb in zip(self.outlet_list, self.outlet_blocks): 

616 outlet_seeds[outlet_name]["pressure"] = p_mixed 

617 

618 # Inlet enthalpy ranking 

619 # 1. Explicit state_args 

620 # 2. A propagated upstream inlet enthalpy 

621 # 3. Fixed inlet enthalpy on this unit 

622 # 4. Back calculate from fixed outlet enthalpies and seeded inlet flows with user heat loss 

623 # 5. Assume inlet enthalpies all equal to the saturation enthalpy + 10 C superheat # TODO: adapt this for water headers 

624 

625 # For rank 4, first determine the outlet enthalpies if they were fixed, and calculate the inlet enthalpy with heat loss 

626 heat_loss = _pick_seed(_value_or_none(self.heat_loss[t0]), 0.0) 

627 inlet_enthalpy_from_outlets = None 

628 if sum(seeded_inlet_flows) > 0: 

629 outlet_energy_terms = [] 

630 all_outlet_enthalpies_fixed = True 

631 for outlet_name, outlet_sb in zip(self.outlet_list, self.outlet_blocks): 631 ↛ 647line 631 didn't jump to line 647 because the loop on line 631 didn't complete

632 outlet_enthalpy_fixed = ( 

633 _value_or_none(outlet_sb[t0].enth_mol) 

634 if outlet_sb[t0].enth_mol.fixed 

635 else None 

636 ) 

637 seeded_outlet_flow = outlet_seeds[outlet_name].get("flow_mol") 

638 

639 if outlet_enthalpy_fixed is None or seeded_outlet_flow is None: 639 ↛ 643line 639 didn't jump to line 643 because the condition on line 639 was always true

640 all_outlet_enthalpies_fixed = False 

641 break 

642 

643 outlet_energy_terms.append( 

644 seeded_outlet_flow * outlet_enthalpy_fixed 

645 ) 

646 

647 if all_outlet_enthalpies_fixed: 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true

648 inlet_enthalpy_from_outlets = ( 

649 sum(outlet_energy_terms) + heat_loss 

650 ) / (sum(seeded_inlet_flows) + 1e-6) 

651 

652 seeded_inlet_enthalpies = [] 

653 for inlet_name, inlet_sb in zip(self.inlet_list, self.inlet_blocks): 

654 inlet_has_source = len(list(getattr(self, inlet_name).sources())) > 0 

655 local_args = inlet_state_args[inlet_name] 

656 p_in = inlet_seeds[inlet_name]["pressure"] 

657 flow_mol = inlet_seeds[inlet_name]["flow_mol"] 

658 enthalpy_from_upstream = ( 

659 _value_or_none(inlet_sb[t0].enth_mol) 

660 if inlet_has_source 

661 else None 

662 ) 

663 enthalpy_from_fixed = ( 

664 _value_or_none(inlet_sb[t0].enth_mol) 

665 if inlet_sb[t0].enth_mol.fixed 

666 else None 

667 ) 

668 

669 inlet_sb[t0].pressure.set_value(p_in) 

670 temperature_sat = _value_or_none( 

671 getattr(inlet_sb[t0], "temperature_sat", None) 

672 ) 

673 

674 h_in = _pick_seed( 

675 local_args.get("enth_mol"), 

676 enthalpy_from_upstream, 

677 enthalpy_from_fixed, 

678 inlet_enthalpy_from_outlets, 

679 _enthalpy_from_tp( 

680 temperature_sat + 10.0, 

681 p_in, 

682 ) if temperature_sat is not None else None, 

683 ) 

684 

685 inlet_seeds[inlet_name]["enth_mol"] = h_in 

686 seeded_inlet_enthalpies.append(h_in*flow_mol) 

687 

688 # Seed inlet enthalpy to mixed and outlet states  

689 h_mixed = (sum(seeded_inlet_enthalpies) - pyo.value(self.heat_loss[t0])) / (sum(seeded_inlet_flows) + 1e-6) 

690 for outlet_name, outlet_sb in zip(self.outlet_list, self.outlet_blocks): 

691 outlet_seeds[outlet_name]["enth_mol"] = h_mixed 

692 

693 # TODO: do better seeding of condensate enthalpy 

694 #----------------------------------------------------- 

695 # 2. Initialize state blocks using explicitly seeded values for all state variables 

696 for inlet_name, inlet_sb in zip(self.inlet_list, self.inlet_blocks): 

697 inlet_sb.initialize( 

698 solver=solver, 

699 optarg=optarg, 

700 outlvl=outlvl, 

701 state_args=inlet_seeds[inlet_name], 

702 ) 

703 init_log.info_high(f"{inlet_name} state initialization complete") 

704 

705 flags_mixed = self.mixed_state.initialize( 

706 solver=solver, 

707 optarg=optarg, 

708 outlvl=outlvl, 

709 state_args={ 

710 "flow_mol": _pick_seed(mixed_state_args.get("flow_mol"), f_mixed), 

711 "pressure": _pick_seed(mixed_state_args.get("pressure"), p_mixed), 

712 "enth_mol": _pick_seed(mixed_state_args.get("enth_mol"), h_mixed), 

713 }, 

714 hold_state=True, 

715 ) 

716 init_log.info_high("mixed state initialization complete") 

717 

718 held_outlet_states = {} 

719 for outlet_name, outlet_sb in zip(self.outlet_list, self.outlet_blocks): 

720 held_outlet_states[outlet_name] = outlet_sb.initialize( 

721 solver=solver, 

722 optarg=optarg, 

723 outlvl=outlvl, 

724 state_args=outlet_seeds[outlet_name], 

725 hold_state=True, 

726 ) 

727 init_log.info_high(f"{outlet_name} state initialization complete") 

728 # #----------------------------------------------------- 

729 # # 3. Deactivate constraints not specified in Step 2 then solve first pass 

730 relaxed_eqns = [ 

731 self.mixed_state_material_balance, 

732 self.condensate_flow_balance, 

733 self.vent_flow_balance, 

734 self.inlets_to_mixed_state_energy_balance, 

735 self.mixed_state_to_outlets_energy_balance, 

736 self.molar_enthalpy_equality_eqn, 

737 self.minimum_pressure_constraint, 

738 self.mixture_pressure, 

739 self.pressure_equality_eqn, 

740 ] 

741 if self.config.is_liquid_header: 

742 relaxed_eqns.append(self.vent_vapour_fraction) 

743 else: 

744 relaxed_eqns.append(self.condensate_vapour_fraction) 

745 

746 for con in relaxed_eqns: 

747 con.deactivate() 

748 

749 report_statistics(self) 

750 

751 dt = DiagnosticsToolbox(self) 

752 dt.report_structural_issues() 

753 dt.display_underconstrained_set() 

754 

755 active_constraints = sum( 

756 1 for _ in self.component_data_objects(Constraint, active=True, descend_into=True) 

757 ) 

758 

759 if active_constraints > 0: 759 ↛ 760line 759 didn't jump to line 760 because the condition on line 759 was never true

760 with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: 

761 res = opt.solve(self, tee=slc.tee) 

762 if not check_optimal_termination(res): 

763 dt.report_numerical_issues() 

764 raise InitializationError(f"{self.name} failed relaxed initialization") 

765 else: 

766 init_log.info_high( 

767 "Relaxed initialization pass skipped: no active constraints remained after seeding." 

768 ) 

769 

770 # Restore full model 

771 self.mixed_state.release_state(flags_mixed) 

772 for outlet_name, flags in held_outlet_states.items(): 

773 getattr(self, f"{outlet_name}_state").release_state(flags) 

774 

775 for con in relaxed_eqns: 

776 con.activate() 

777 

778 dof = degrees_of_freedom(self) 

779 # NOTE: Seems header mostly has >0 DoF in tests so check is skipped, but should be revisited to debug it 

780 # if dof != 0: 

781 # raise InitializationError( 

782 # f"{self.name} degrees of freedom were not 0 before final solve. DoF = {dof}" 

783 # ) 

784 

785 with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: 

786 res = opt.solve(self, tee=slc.tee) 

787 if not check_optimal_termination(res): 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true

788 raise InitializationError(f"{self.name} failed final initialization") 

789 

790 init_log.info(f"Initialization complete: {idaeslog.condition(res)}") 

791 

792 def _get_performance_contents(self, time_point=0, is_full_report=True): 

793 """Collect performance results for reporting. 

794 

795 Args: 

796 time_point (int | float): Time index at which to report values. 

797 is_full_report (bool): Flag for full or partial performance report. 

798 

799 Returns: 

800 dict: A report of internal unit model results. 

801 """ 

802 if is_full_report: 

803 var_dict = { 

804 "Heat Loss": self.heat_loss[time_point], 

805 "Pressure Drop": self.pressure_loss[time_point], 

806 "Mass Flow": self.mixed_state[time_point].flow_mass, 

807 "Molar Flow": self.mixed_state[time_point].flow_mol, 

808 "Balance Flow": self.balance_flow_mol[time_point], 

809 "Pressure": self.mixed_state[time_point].pressure, 

810 "Temperature": self.mixed_state[time_point].temperature, 

811 "Degree of Superheat": self.degree_of_superheat[time_point], 

812 "Vapour Fraction": self.mixed_state[time_point].vapor_frac, 

813 "Mass Specific Enthalpy": self.mixed_state[time_point].enth_mass, 

814 "Molar Specific Enthalpy": self.mixed_state[time_point].enth_mol, 

815 } 

816 else: 

817 var_dict = { 

818 "Balance Flow": self.balance_flow_mol[time_point], 

819 "Pressure": self.mixed_state[time_point].pressure, 

820 "Temperature": self.mixed_state[time_point].temperature, 

821 "Degree of Superheat": self.degree_of_superheat[time_point], 

822 } 

823 

824 return {"vars": var_dict} 

825 

826 def diagnose(self) -> list[tuple[pyo.Component, str]]: 

827 """Report common header formulation issues for flowsheet diagnostics.""" 

828 problems = [] 

829 

830 for time in self.flowsheet().time: 

831 balance_flow = value(self.balance_flow_mol[time], exception=False) 

832 condensate_flow = value( 

833 self.outlet_condensate_state[time].flow_mol, 

834 exception=False, 

835 ) 

836 total_flow = value(self.total_flow_mol[time], exception=False) 

837 

838 if balance_flow is not None and abs(balance_flow) > 1e-6: 

839 problems.append( 

840 ( 

841 self.balance_flow_mol[time], 

842 f"Balance flow is {balance_flow:.6g} mol/s. " 

843 "This means the header is adding flow from nowhere " 

844 "to make the material balance close. Replace balance flow " 

845 "with an inlet flow variable to specify the makeup.", 

846 ) 

847 ) 

848 

849 if ( 

850 not self.config.is_liquid_header 

851 and condensate_flow is not None 

852 and total_flow is not None 

853 ): 

854 condensate_tolerance = max(abs(total_flow) * 1e-3, 1e-6) 

855 if ( 

856 total_flow > 1e-6 

857 and abs(condensate_flow - total_flow) <= condensate_tolerance 

858 ): 

859 problems.append( 

860 ( 

861 self.total_flow_mol[time], 

862 f"Condensate flow ({condensate_flow:.6g} mol/s) " 

863 f"is approximately equal to total header flow " 

864 f"({total_flow:.6g} mol/s). This means everything " 

865 "entering the steam header is liquid after mixing, " 

866 "so the steam header will fail to solve. Check the " 

867 "inlet conditions and ensure there is sufficient " 

868 "vapour entering the header.", 

869 ) 

870 ) 

871 

872 return problems 

873 

874 

875 # ----------------------------------------------------------------- 

876 # Common utilities 

877 # ----------------------------------------------------------------- 

878 

879 def calculate_scaling_factors(self): 

880 super().calculate_scaling_factors() 

881 iscale.set_scaling_factor(self.heat_loss, 1e-6) 

882 iscale.set_scaling_factor(self.pressure_loss, 1e-6) 

883 iscale.set_scaling_factor(self.balance_flow_mol, 1e-3) 

884 iscale.set_scaling_factor(self._partial_total_flow_mol, 1e-3) 

885 if hasattr(self, "_vap_out_enth_mol"): 

886 iscale.set_scaling_factor(self._vap_out_enth_mol, 1e-6) 

887 

888 

889 def _build_state_blocks( 

890 self, 

891 stream_name_list: Iterable[str], 

892 has_phase_equilibrium: bool, 

893 is_defined_state: Optional[bool] = False, 

894 is_build_port: Optional[bool] = False, 

895 ) -> List[StateBlock]: 

896 blocks: List[StateBlock] = [] 

897 

898 base_args = dict(self.config.property_package_args) 

899 base_args["has_phase_equilibrium"] = has_phase_equilibrium 

900 base_args["defined_state"] = is_defined_state 

901 

902 for stream_name in stream_name_list: 

903 args = dict(base_args) 

904 args["doc"] = f"Thermophysical properties at {stream_name}" 

905 sb = self.config.property_package.build_state_block(self.flowsheet().time, **args) 

906 setattr(self, f"{stream_name}_state", sb) 

907 blocks.append(sb) 

908 

909 if is_build_port: # No port is needed for intermediate/internal state blocks 

910 self.add_port(name=stream_name, block=sb) 

911 

912 return blocks 

913 

914 

915 def _get_stream_table_contents(self, time_point=0): 

916 io_dict = {name: getattr(self, name) for name in [*self.inlet_blocks, *self.outlet_blocks, *self.internal_blocks]} 

917 return create_stream_table_dataframe(io_dict, time_point=time_point) 

918 

919 

920 @staticmethod 

921 def ahuora_metadata(): 

922 from ahuora_unit_ops.json_config import ( 

923 JsonAdapterArgConfig, 

924 JsonFrontendConfig, 

925 JsonGraphicObjectConfig, 

926 JsonIdaesAdapterConfig, 

927 JsonPortAdapterConfig, 

928 JsonPortConfig, 

929 JsonPropertyConfig, 

930 JsonPropertySetGroupConfig, 

931 JsonSchemaPortMappingConfig, 

932 JsonUnitOpConfig, 

933 ) 

934 

935 return JsonUnitOpConfig( 

936 key='simple_header', 

937 objectType='simple_header', 

938 enumMember='SimpleHeader', 

939 displayType='Simple Header', 

940 displayName='Simple Header', 

941 categoryPath=['chemical', 'mixer'], 

942 ports={ 

943 'outlet_condensate': JsonPortConfig( 

944 displayName='Condensate', 

945 type='outlet', 

946 streamType='stream', 

947 many=False, 

948 default=1, 

949 minimum=1, 

950 makeStream=True, 

951 streamOffset=3, 

952 streamName='Condensate S', 

953 ), 

954 'outlet_vent': JsonPortConfig( 

955 displayName='Vent Outlet', 

956 type='outlet', 

957 streamType='stream', 

958 many=False, 

959 default=1, 

960 minimum=1, 

961 makeStream=True, 

962 streamOffset=3, 

963 streamName='Vent S', 

964 ), 

965 'inlet': JsonPortConfig( 

966 displayName='Inlet', 

967 type='inlet', 

968 streamType='stream', 

969 many=True, 

970 default=1, 

971 minimum=1, 

972 makeStream=True, 

973 streamOffset=3, 

974 streamName='S', 

975 ), 

976 'outlet': JsonPortConfig( 

977 displayName='Outlet', 

978 type='outlet', 

979 streamType='stream', 

980 many=True, 

981 default=2, 

982 minimum=1, 

983 makeStream=True, 

984 streamOffset=3, 

985 streamName='S', 

986 ), 

987 }, 

988 propertyPackagePorts={ 

989 '': ['inlet', 'outlet', 'outlet_vent', 'outlet_condensate'], 

990 }, 

991 graphicObject=JsonGraphicObjectConfig( 

992 kind='explicit', 

993 width=25, 

994 height=800, 

995 autoHeight=True, 

996 ), 

997 indexSets=['splitter_fraction'], 

998 properties={ 

999 'split_flow': JsonPropertyConfig( 

1000 propertySetGroup='default', 

1001 displayName='Flow', 

1002 indexSets=['splitter_fraction'], 

1003 sumToOne=False, 

1004 value=None, 

1005 unit=None, 

1006 unitType='molarflow', 

1007 description=None, 

1008 type='numeric', 

1009 many=False, 

1010 default=1, 

1011 options={}, 

1012 hasTimeIndex=True, 

1013 ), 

1014 'outlet_vent.flow_mol': JsonPropertyConfig( 

1015 propertySetGroup='default', 

1016 displayName='Vent Flow', 

1017 indexSets=None, 

1018 sumToOne=False, 

1019 value=None, 

1020 unit=None, 

1021 unitType='molarflow', 

1022 description=None, 

1023 type='numeric', 

1024 many=False, 

1025 default=1, 

1026 options={}, 

1027 hasTimeIndex=True, 

1028 ), 

1029 'heat_loss': JsonPropertyConfig( 

1030 propertySetGroup='default', 

1031 displayName='Heat Loss', 

1032 indexSets=None, 

1033 sumToOne=False, 

1034 value=0, 

1035 unit=None, 

1036 unitType='heatflow', 

1037 description=None, 

1038 type='numeric', 

1039 many=False, 

1040 default=1, 

1041 options={}, 

1042 hasTimeIndex=True, 

1043 ), 

1044 'pressure_loss': JsonPropertyConfig( 

1045 propertySetGroup='default', 

1046 displayName='Pressure Drop', 

1047 indexSets=None, 

1048 sumToOne=False, 

1049 value=0, 

1050 unit=None, 

1051 unitType='pressure', 

1052 description=None, 

1053 type='numeric', 

1054 many=False, 

1055 default=1, 

1056 options={}, 

1057 hasTimeIndex=True, 

1058 ), 

1059 'total_flow_mass': JsonPropertyConfig( 

1060 propertySetGroup='default', 

1061 displayName='Mass Flow', 

1062 indexSets=None, 

1063 sumToOne=False, 

1064 value=None, 

1065 unit=None, 

1066 unitType='massflow', 

1067 description=None, 

1068 type='numeric', 

1069 many=False, 

1070 default=1, 

1071 options={}, 

1072 hasTimeIndex=True, 

1073 ), 

1074 'total_flow_mol': JsonPropertyConfig( 

1075 propertySetGroup='default', 

1076 displayName='Molar Flow', 

1077 indexSets=None, 

1078 sumToOne=False, 

1079 value=None, 

1080 unit=None, 

1081 unitType='molarflow', 

1082 description=None, 

1083 type='numeric', 

1084 many=False, 

1085 default=1, 

1086 options={}, 

1087 hasTimeIndex=True, 

1088 ), 

1089 'balance_flow_mol': JsonPropertyConfig( 

1090 propertySetGroup='default', 

1091 displayName='Balance Flow', 

1092 indexSets=None, 

1093 sumToOne=False, 

1094 value=None, 

1095 unit=None, 

1096 unitType='molarflow', 

1097 description=None, 

1098 type='numeric', 

1099 many=False, 

1100 default=1, 

1101 options={}, 

1102 hasTimeIndex=True, 

1103 ), 

1104 'temperature': JsonPropertyConfig( 

1105 propertySetGroup='default', 

1106 displayName='Temperature', 

1107 indexSets=None, 

1108 sumToOne=False, 

1109 value=None, 

1110 unit=None, 

1111 unitType='temperature', 

1112 description=None, 

1113 type='numeric', 

1114 many=False, 

1115 default=1, 

1116 options={}, 

1117 hasTimeIndex=True, 

1118 ), 

1119 'degree_of_superheat': JsonPropertyConfig( 

1120 propertySetGroup='default', 

1121 displayName='Degree of Superheat', 

1122 indexSets=None, 

1123 sumToOne=False, 

1124 value=None, 

1125 unit=None, 

1126 unitType='deltaTemperature', 

1127 description=None, 

1128 type='numeric', 

1129 many=False, 

1130 default=1, 

1131 options={}, 

1132 hasTimeIndex=True, 

1133 ), 

1134 'pressure': JsonPropertyConfig( 

1135 propertySetGroup='default', 

1136 displayName='Pressure', 

1137 indexSets=None, 

1138 sumToOne=False, 

1139 value=None, 

1140 unit=None, 

1141 unitType='pressure', 

1142 description=None, 

1143 type='numeric', 

1144 many=False, 

1145 default=1, 

1146 options={}, 

1147 hasTimeIndex=True, 

1148 ), 

1149 'vapor_frac': JsonPropertyConfig( 

1150 propertySetGroup='default', 

1151 displayName='Vapor Fraction', 

1152 indexSets=None, 

1153 sumToOne=False, 

1154 value=None, 

1155 unit=None, 

1156 unitType='ratio', 

1157 description=None, 

1158 type='numeric', 

1159 many=False, 

1160 default=1, 

1161 options={}, 

1162 hasTimeIndex=True, 

1163 ), 

1164 'enth_mass': JsonPropertyConfig( 

1165 propertySetGroup='default', 

1166 displayName='Mass Specific Enthalpy', 

1167 indexSets=None, 

1168 sumToOne=False, 

1169 value=None, 

1170 unit=None, 

1171 unitType='massEnthalpy', 

1172 description=None, 

1173 type='numeric', 

1174 many=False, 

1175 default=1, 

1176 options={}, 

1177 hasTimeIndex=True, 

1178 ), 

1179 'enth_mol': JsonPropertyConfig( 

1180 propertySetGroup='default', 

1181 displayName='Molar Specific Enthalpy', 

1182 indexSets=None, 

1183 sumToOne=False, 

1184 value=None, 

1185 unit=None, 

1186 unitType='molarEnthalpy', 

1187 description=None, 

1188 type='numeric', 

1189 many=False, 

1190 default=1, 

1191 options={}, 

1192 hasTimeIndex=True, 

1193 ), 

1194 }, 

1195 propertySetGroups={ 

1196 'default': JsonPropertySetGroupConfig( 

1197 type='stateVars', 

1198 displayName='Properties', 

1199 stateVars=['split_flow', 'heat_loss', 'pressure_loss'], 

1200 toggle=None, 

1201 ), 

1202 }, 

1203 keyProperties=['pressure', 'temperature', 'degree_of_superheat', 'enth_mass', 'vapor_frac'], 

1204 splitterFractionName='Outlet', 

1205 idaesAdapter=JsonIdaesAdapterConfig( 

1206 constructor='ahuora_builder.custom.thermal_utility_systems.header.simple_header', 

1207 args={ 

1208 'property_package': JsonAdapterArgConfig( 

1209 kind='property_package', 

1210 label=None, 

1211 ), 

1212 'num_inlets': JsonAdapterArgConfig( 

1213 kind='port_count', 

1214 port='inlet', 

1215 ), 

1216 'num_outlets': JsonAdapterArgConfig( 

1217 kind='port_count', 

1218 port='outlet', 

1219 ), 

1220 }, 

1221 ports=JsonPortAdapterConfig( 

1222 kind='schema_ports', 

1223 mappings=[ 

1224 JsonSchemaPortMappingConfig( 

1225 type='group', 

1226 port='inlet', 

1227 outputKeyTemplate='inlet_{index}', 

1228 inlet=True, 

1229 connectedOnly=True, 

1230 sortByIndex=True, 

1231 ), 

1232 JsonSchemaPortMappingConfig( 

1233 type='group', 

1234 port='outlet', 

1235 outputKeyTemplate='outlet_{index}', 

1236 inlet=False, 

1237 connectedOnly=True, 

1238 sortByIndex=True, 

1239 ), 

1240 JsonSchemaPortMappingConfig( 

1241 type='fixed', 

1242 port='outlet_vent', 

1243 outputKey='outlet_vent', 

1244 inlet=False, 

1245 optional=True, 

1246 connectedOnly=True, 

1247 ), 

1248 JsonSchemaPortMappingConfig( 

1249 type='fixed', 

1250 port='outlet_condensate', 

1251 outputKey='outlet_condensate', 

1252 inlet=False, 

1253 optional=True, 

1254 connectedOnly=True, 

1255 ), 

1256 ], 

1257 ), 

1258 properties=None, 

1259 ), 

1260 frontend=JsonFrontendConfig( 

1261 showInPanel=True, 

1262 variant=None, 

1263 ), 

1264 )