Coverage for backend/idaes_service/solver/custom/thermal_utility_systems/steam_header.py: 90%

129 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-11-06 23:27 +0000

1from pyomo.environ import Suffix, Var, Expression, Constraint 

2from pyomo.common.config import ConfigBlock, ConfigValue, In 

3from pyomo.network import Arc, SequentialDecomposition 

4from pyomo.core.base.reference import Reference 

5import pyomo.environ as pyo 

6 

7# Import IDAES cores 

8from idaes.core import ( 

9 declare_process_block_class, 

10 UnitModelBlockData, 

11 useDefault, 

12) 

13from idaes.core.util.config import is_physical_parameter_block 

14import idaes.logger as idaeslog 

15from idaes.core.util.tables import create_stream_table_dataframe 

16from idaes.core.util.math import smooth_max 

17from idaes.models.unit_models.separator import SplittingType, EnergySplittingType 

18from idaes.models.unit_models.mixer import Mixer 

19from idaes.models.unit_models.heater import Heater 

20from idaes_service.solver.custom.custom_separator import CustomSeparator 

21from idaes_service.solver.custom.simple_separator import SimpleSeparator 

22from ..inverted import add_inverted, initialise_inverted 

23__author__ = "Team Ahuora" 

24 

25# Set up logger 

26_log = idaeslog.getLogger(__name__) 

27 

28 

29@declare_process_block_class("SteamHeader") 

30class SteamHeaderData(UnitModelBlockData): 

31 """ 

32 Steam Header unit operation: 

33 Mixer -> Cooler -> Phase Separator -> Simple Separator 

34 Separates 100% liquid to condensate_outlet and 100% vapor to splitter outlets. 

35 Uses Sequential Decomposition for optimal initialization order. 

36 """ 

37 

38 CONFIG = UnitModelBlockData.CONFIG() 

39 CONFIG.declare( 

40 "property_package", 

41 ConfigValue( 

42 default=useDefault, 

43 domain=is_physical_parameter_block, 

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

45 ), 

46 ) 

47 CONFIG.declare( 

48 "property_package_args", 

49 ConfigBlock( 

50 implicit=True, 

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

52 ), 

53 ) 

54 CONFIG.declare( 

55 "num_inlets", 

56 ConfigValue( 

57 default=2, 

58 domain=int, 

59 description="Number of inlets to add" "Index [-1]: Steam makeup", 

60 ), 

61 ) 

62 CONFIG.declare( 

63 "num_outlets", 

64 ConfigValue( 

65 default=2, 

66 domain=int, 

67 description="Number of outlets to add" 

68 "Index [-1]: Vent" 

69 "Index [-2]: Condensate", 

70 ), 

71 ) 

72 

73 def build(self): 

74 super().build() 

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

76 

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

78 self.outlet_list = [ 

79 f"outlet_{i+1}" for i in range(self.config.num_outlets) 

80 ] + ["vent"] 

81 

82 # Create internal units 

83 self.mixer = Mixer( 

84 property_package=self.config.property_package, 

85 property_package_args=self.config.property_package_args, 

86 num_inlets=self.config.num_inlets, 

87 inlet_list=self.inlet_list, 

88 ) 

89 self.cooler = Heater( 

90 property_package=self.config.property_package, 

91 property_package_args=self.config.property_package_args, 

92 has_pressure_change=True, 

93 dynamic=self.config.dynamic, 

94 has_holdup=self.config.has_holdup, 

95 ) 

96 self.phase_separator = CustomSeparator( 

97 property_package=self.config.property_package, 

98 property_package_args=self.config.property_package_args, 

99 outlet_list=["vapor_outlet", "condensate_outlet"], 

100 split_basis=SplittingType.phaseFlow, 

101 energy_split_basis=EnergySplittingType.enthalpy_split, 

102 ) 

103 self.splitter = SimpleSeparator( 

104 property_package=self.config.property_package, 

105 property_package_args=self.config.property_package_args, 

106 outlet_list=self.outlet_list, 

107 ) 

108 self.unit_ops = [self.mixer, self.cooler, self.phase_separator, self.splitter] 

109 

110 # Updated internal arcs 

111 self.mixer_to_cooler_arc = Arc( 

112 source=self.mixer.outlet, destination=self.cooler.inlet 

113 ) 

114 self.cooler_to_separator_arc = Arc( 

115 source=self.cooler.outlet, destination=self.phase_separator.inlet 

116 ) 

117 self.separator_to_splitter_arc = Arc( 

118 source=self.phase_separator.vapor_outlet, destination=self.splitter.inlet 

119 ) 

120 

121 self.inlet_blocks = [getattr(self.mixer, f"{i}_state") for i in self.inlet_list] 

122 self.outlet_blocks = [getattr(self.splitter, f"{o}_state") for o in self.outlet_list] 

123 

124 # Declare slack variables for internal use 

125 self.balance_flow_mol = Var( 

126 self.flowsheet().time, initialize=0.0, doc="Balance molar flow (negative means makeup is required, positive means venting)", 

127 units=pyo.units.mol / pyo.units.s 

128 ) 

129 

130 # This is only used for scaling the smooth_min/smooth_max value. 

131 self._partial_total_flow_mol = Var( 

132 self.flowsheet().time, initialize=0.0, doc="Partial total fixed molar flow", 

133 units=pyo.units.mol / pyo.units.s 

134 ) 

135 

136 self.makeup_flow_mol = Var( 

137 self.flowsheet().time, initialize=0.0, doc="Makeup molar flow", 

138 units=pyo.units.mol / pyo.units.s 

139 ) 

140 

141 

142 # Add inverted transformers to heat_duty and deltaP 

143 # (so that positive values correspond to heat loss and pressure drop) 

144 

145 add_inverted(self.cooler, "heat_duty") 

146 add_inverted(self.cooler, "deltaP") 

147 # Declare additional variables and aliases to expose to the user 

148 self.heat_duty_inverted = Reference(self.cooler.heat_duty_inverted) 

149 self.deltaP_inverted = Reference(self.cooler.deltaP_inverted) 

150 self.heat_duty = Reference(self.cooler.heat_duty) 

151 self.deltaP = Reference(self.cooler.deltaP) 

152 

153 self.total_flow_mol = Reference( 

154 self.cooler.control_volume.properties_out[:].flow_mol 

155 ) 

156 self.total_flow_mass = Reference( 

157 self.cooler.control_volume.properties_out[:].flow_mass 

158 ) 

159 self.pressure = Reference( 

160 self.cooler.control_volume.properties_out[:].pressure 

161 ) 

162 self.temperature = Reference( 

163 self.cooler.control_volume.properties_out[:].temperature 

164 ) 

165 self.enth_mol = Reference( 

166 self.cooler.control_volume.properties_out[:].enth_mol 

167 ) 

168 self.enth_mass = Reference( 

169 self.cooler.control_volume.properties_out[:].enth_mass 

170 ) 

171 self.vapor_frac = Reference( 

172 self.cooler.control_volume.properties_out[:].vapor_frac 

173 ) 

174 self.split_flow = self._create_split_flow_references() 

175 

176 # Condensate liquid always is removed first. 

177 self.phase_separator.split_fraction[:, "vapor_outlet", "Vap"].fix(1.0) 

178 self.phase_separator.split_fraction[:, "vapor_outlet", "Liq"].fix(0.0) 

179 

180 # Additional bounds and constraints 

181 self._additional_constraints() 

182 

183 # Expand arcs 

184 pyo.TransformationFactory("network.expand_arcs").apply_to(self) 

185 

186 self.cooler_to_separator_arc_expanded.flow_mol_equality.deactivate() 

187 

188 # add an expression for the degree of superheat 

189 

190 @self.Expression(self.flowsheet().time) 

191 def degree_of_superheat(self,t): 

192 return self.splitter.mixed_state[t].temperature - self.splitter.mixed_state[t].temperature_sat 

193 

194 def _create_split_flow_references(self): 

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

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

197 ref_map = {} 

198 for o in self.outlet_list: 

199 if o != "vent": 

200 outlet_state_block = getattr(self.splitter, f"{o}_state") 

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

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

203 

204 return Reference(ref_map) 

205 

206 def _additional_constraints(self): 

207 """ 

208 Additional constraints. 

209 """ 

210 

211 """ 

212 1) Set lower bounds on flow variables for all external ports. 

213 """ 

214 [ 

215 state_block[t].flow_mol.setlb(0.0) 

216 for state_block in (self.inlet_blocks + self.outlet_blocks) 

217 for t in state_block 

218 ] 

219 

220 """ 

221 2) Add overall material balance equation. 

222 """ 

223 

224 # Write phase-component balances 

225 @self.Constraint(self.flowsheet().time, doc="Material balance equation") 

226 def material_balance_equation(b, t): 

227 pc_set = b.outlet_blocks[0].phase_component_set 

228 comp_ls = b.outlet_blocks[0].component_list 

229 phase_ls = b.outlet_blocks[0].phase_list 

230 return ( 

231 0 

232 == sum( 

233 sum( 

234 b.mixer.mixed_state[t].get_material_flow_terms(p, j) 

235 - (b.phase_separator.mixed_state[t].get_material_flow_terms(p, j) 

236 - b.splitter.vent_state[t].get_material_flow_terms(p, j)) 

237 for j in comp_ls 

238 if (p, j) in pc_set 

239 ) 

240 for p in phase_ls 

241 ) 

242 - b.balance_flow_mol[t] 

243 ) 

244 

245 """ 

246 3) Add vent flow balance (scaled by the total known flows). 

247 """ 

248 

249 @self.Constraint( 

250 self.flowsheet().time, 

251 doc="Material balance of known inlet and outlet flows. " 

252 "Only used for scaling the steam vent min flow, doesn't actually matter.", 

253 ) 

254 def partial_material_balance_equation(b, t): 

255 pc_set = b.inlet_blocks[0].phase_component_set 

256 comp_ls = b.inlet_blocks[0].component_list 

257 phase_ls = b.inlet_blocks[0].phase_list 

258 return ( 

259 0 

260 == sum( 

261 sum( 

262 sum( 

263 o[t].get_material_flow_terms(p, j) 

264 for o in b.inlet_blocks[:-1] + b.outlet_blocks[:-2] 

265 ) 

266 for j in comp_ls 

267 if (p, j) in pc_set 

268 ) 

269 for p in phase_ls 

270 ) 

271 - b._partial_total_flow_mol[t] 

272 ) 

273 

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

275 # calculate amount for vent flow: positive balance amount 

276 @self.Constraint(self.flowsheet().time, doc="Steam vent flow balance.") 

277 def vent_flow_balance(b, t): 

278 return 0 == ( 

279 smooth_max( 

280 b.balance_flow_mol[t] / (b._partial_total_flow_mol[t] + 1e-6), 

281 0.0, 

282 eps, 

283 ) 

284 * (b._partial_total_flow_mol[t] + 1e-6) 

285 - b.splitter.vent_state[t].flow_mol 

286 ) 

287 

288 # if balance is negative, vent will be zero. makeup will be required. 

289 @self.Constraint(self.flowsheet().time, doc="Steam makeup balance") 

290 def makeup_flow_balance(b, t): 

291 return b.makeup_flow_mol[t] == ( 

292 b.splitter.vent_state[t].flow_mol - b.balance_flow_mol[t] 

293 ) 

294 

295 

296 def calculate_scaling_factors(self): 

297 super().calculate_scaling_factors() 

298 [getattr(o, "calculate_scaling_factors")() for o in self.unit_ops] 

299 

300 def initialize_build(self, outlvl=idaeslog.NOTSET, **kwargs): 

301 """ 

302 Initialize the Steam Header unit using Sequential Decomposition to determine optimal order 

303 """ 

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

305 

306 init_log.info( 

307 f"Starting {self.parent_block().__class__.__name__} initialization using Sequential Decomposition" 

308 ) 

309 # Initialize the inverted values 

310 initialise_inverted(self.cooler, "heat_duty") 

311 initialise_inverted(self.cooler, "deltaP") 

312 

313 # create Sequential Decomposition object 

314 seq = SequentialDecomposition() 

315 seq.options.select_tear_method = "heuristic" 

316 seq.options.tear_method = "Wegstein" 

317 seq.options.iterLim = 1 

318 

319 # create computation graph 

320 G = seq.create_graph(self) 

321 heuristic_tear_set = seq.tear_set_arcs(G, method="heuristic") 

322 # get calculation order 

323 order = seq.calculation_order(G) 

324 

325 for o in heuristic_tear_set: 325 ↛ 326line 325 didn't jump to line 326 because the loop on line 325 never started

326 print(o.name) 

327 

328 print("Initialization order:") 

329 for o in order: 

330 print(o[0].name) 

331 

332 # define unit initialisation function 

333 def init_unit(unit): 

334 unit.initialize(outlvl=outlvl, **kwargs) 

335 

336 # run sequential decomposition 

337 seq.run(self, init_unit) 

338 

339 def _get_stream_table_contents(self, time_point=0): 

340 """ 

341 Create stream table showing all inlets, outlets, and liquid outlet 

342 """ 

343 io_dict = {} 

344 

345 for inlet_name in self.inlet_list: 

346 io_dict[inlet_name] = getattr(self, inlet_name) 

347 

348 for outlet_name in self.outlet_list: 

349 io_dict[outlet_name] = getattr(self, outlet_name) 

350 

351 io_dict["condensate_outlet"] = self.condensate_outlet 

352 

353 return create_stream_table_dataframe(io_dict, time_point=time_point)