Coverage for backend/ahuora-builder/src/ahuora_builder/custom/thermal_utility_systems/temp.py: 14%

145 statements  

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

1# TODO:  

2# backend/idaes_service/solver/custom/steam_header.py [DONE] 

3# backend/idaes_service/solver/custom/tests/test_steam_header.py [DONE] 

4# backend/idaes_service/solver/methods/adapter_library.py 

5# backend/idaes_factory/adapters/unit_models/header_adapter.py 

6# backend/idaes_factory/adapters/adapter_library.py 

7# backend/flowsheetInternals/unitops/config/config_base.py 

8# backend/flowsheetInternals/unitops/config/objects/header_config.py 

9# backend/django/flowsheetInternals/unitops/config/objects/header_config.json 

10 

11from pyomo.environ import Suffix, Var 

12from pyomo.common.config import ConfigBlock, ConfigValue 

13from pyomo.network import Arc 

14from pyomo.core.base.reference import Reference 

15import pyomo.environ as pyo 

16 

17# Import IDAES cores 

18from idaes.core import ( 

19 declare_process_block_class, 

20 UnitModelBlockData, 

21 useDefault, 

22) 

23from idaes.core.util.config import is_physical_parameter_block 

24import idaes.logger as idaeslog 

25from idaes.core.util.tables import create_stream_table_dataframe 

26from idaes.models.unit_models.mixer import Mixer 

27from idaes.models.unit_models.heater import Heater 

28 

29# Set up logger 

30_log = idaeslog.getLogger(__name__) 

31 

32 

33@declare_process_block_class("Desuperheater") 

34class DesuperheaterData(UnitModelBlockData): 

35 """ 

36 Desuperheater unit operation: 

37 Superheated steam + water -> Mixer -> Losses -> Saturated Vapour 

38 Desuperheater aims to remove steam superhear by direct contact with boiler feed water. 

39 Uses Sequential Decomposition for optimal initialization order. 

40 """ 

41 

42 CONFIG = UnitModelBlockData.CONFIG() 

43 CONFIG.declare( 

44 "property_package", 

45 ConfigValue( 

46 default=useDefault, 

47 domain=is_physical_parameter_block, 

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

49 ), 

50 ) 

51 CONFIG.declare( 

52 "property_package_args", 

53 ConfigBlock( 

54 implicit=True, 

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

56 ), 

57 ) 

58 

59 

60 def build(self): 

61 super().build() 

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

63 

64 # Create internal units 

65 self.mixer = Mixer( 

66 property_package=self.config.property_package, 

67 property_package_args=self.config.property_package_args, 

68 num_inlets=2 

69 ) 

70 self.losses = Heater( 

71 property_package=self.config.property_package, 

72 property_package_args=self.config.property_package_args, 

73 has_pressure_change=True, 

74 ) 

75 

76 # Build saturated vapour state block 

77 tmp_dict = dict(**self.config.property_package_args) 

78 tmp_dict["has_phase_equilibrium"] = True 

79 tmp_dict["defined_state"] = False 

80 

81 self._properties_sat = self.config.property_package.build_state_block( 

82 self.flowsheet().time, 

83 doc="saturated vapour properties at outlet", 

84 **tmp_dict 

85 ) 

86 

87 self.unit_ops = [self.mixer, self.losses, self._properties_sat] 

88 

89 # Create internal arcs 

90 self.condenser_to_subcooler = Arc( 

91 source=self.mixer.outlet, 

92 destination=self.losses.inlet, 

93 ) 

94 

95 # Expand arcs 

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

97 

98 # Identify the exposed ports 

99 inlet_exposed_ls = [ 

100 (self.mixer, "steam_inlet", "inlet_1"), 

101 (self.mixer, "water_inlet", "inlet_2"), 

102 ] 

103 outlet_exposed_ls = [ 

104 (self.losses, "steam_outlet", "outlet"), 

105 ] 

106 

107 self.inlet_list, self.inlet_blocks = self._construct_exposed_ports(inlet_exposed_ls) 

108 self.outlet_list, self.outlet_blocks = self._construct_exposed_ports(outlet_exposed_ls) 

109 

110 # Declare additional variables and provide alias of existing ones to expose to the user  

111 self.deltaT_supheat = Var( 

112 self.flowsheet().time, 

113 domain=pyo.NonNegativeReals, 

114 # initialize=0.0,  

115 units=pyo.units.K, 

116 doc="The degree of steam superheat remaining." 

117 ) 

118 self.water_temperature = Reference( 

119 self.water_inlet_state[:].temperature 

120 ) 

121 self.water_flow_mol = Reference( 

122 self.water_inlet_state[:].flow_mol 

123 ) 

124 self.water_flow_mass = Reference( 

125 self.water_inlet_state[:].flow_mass 

126 ) 

127 self.heat_duty = Reference( 

128 self.losses.heat_duty[:] 

129 ) 

130 self.deltaP = Reference( 

131 self.losses.deltaP[:] 

132 ) 

133 

134 # Declare internal variables and references for internal use 

135 self._T_sat = Reference( 

136 self._properties_sat[:].temperature 

137 ) 

138 

139 # Additional bounds and constraints 

140 self._additional_constraints() 

141 

142 def _additional_constraints(self): 

143 """ 

144 Additional constraints. 

145 """ 

146 

147 """ 

148 1. Pressure equality for saturation state block 

149 """ 

150 @self.Constraint( 

151 self.flowsheet().time, doc="Pressure equality for saturation state block" 

152 ) 

153 def outlet_pressure_eql(b, t): 

154 return ( 

155 b._properties_sat[t].pressure == b.losses.control_volume.properties_out[t].pressure 

156 ) 

157 

158 """ 

159 2. Vapour quality for saturation state block 

160 """ 

161 @self.Constraint( 

162 self.flowsheet().time, doc="Pressure equality for saturation state block" 

163 ) 

164 def vapour_quality_eq(b, t): 

165 return ( 

166 b._properties_sat[t].vapor_frac == 0.5 

167 ) 

168 

169 """ 

170 3. Mixer pressure inlet equality for steam and water  

171 """ 

172 @self.Constraint( 

173 self.flowsheet().time, doc="Mixer pressure inlet equality for steam and water" 

174 ) 

175 def mixer_inlet_pressure_eql(b, t): 

176 return ( 

177 b.steam_inlet_state[t].pressure == b.water_inlet_state[t].pressure 

178 ) 

179 

180 """ 

181 4. Superheat temperature balance 

182 """ 

183 @self.Constraint(self.flowsheet().time, doc="Temperature superheat balance") 

184 def superheat_temperature_eqn(b, t): 

185 return ( 

186 b.deltaT_supheat[t] + 0.001 == b.steam_outlet_state[t].temperature - b._T_sat[t] 

187 ) 

188 

189 def _construct_exposed_ports(self, exposed_ls): 

190 """ 

191 exposed_ls: list of [(unit, public_name, internal_basename)] 

192 """ 

193 names, states = [], [] 

194 

195 for unit, public_name, internal_basename in exposed_ls: 

196 # 1) Locate the state block on the child unit 

197 # Try <basename>_state (Mixer & many IDAES units) 

198 state = getattr(unit, f"{internal_basename}_state", None) 

199 

200 # Fall back to control_volume properties for CV-based units (e.g., Heater) 

201 if state is None and hasattr(unit, "control_volume"): 

202 cv = unit.control_volume 

203 if internal_basename.startswith("inlet") and hasattr(cv, "properties_in"): 

204 state = cv.properties_in 

205 elif internal_basename.startswith("outlet") and hasattr(cv, "properties_out"): 

206 state = cv.properties_out 

207 

208 if state is None: 

209 raise AttributeError( 

210 f"{unit.name} has no state for '{internal_basename}'. " 

211 f"Tried '{internal_basename}_state' and control_volume properties." 

212 ) 

213 

214 # 2) Expose the state on this wrapper 

215 # (a) keep a convenient Reference to the time-indexed state block 

216 setattr(self, f"{public_name}_state", Reference(state[:])) 

217 

218 # (b) create a public Port mapped to that state 

219 # NOTE: UnitModelBlock.add_port builds a Port with the vars 

220 # defined by the property package’s port members. 

221 self.add_port(name=public_name, block=state) 

222 

223 names.append(public_name) 

224 states.append(state) 

225 

226 return names, states 

227 

228 def calculate_scaling_factors(self): 

229 super().calculate_scaling_factors() 

230 for o in self.unit_ops: 

231 if hasattr(o, "calculate_scaling_factors"): 

232 o.calculate_scaling_factors() 

233 

234 def _get_stream_table_contents(self, time_point=0): 

235 """ 

236 Create stream table showing all inlets and outlets 

237 """ 

238 io_dict = {} 

239 

240 for inlet_name in self.inlet_list: 

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

242 

243 for outlet_name in self.outlet_list: 

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

245 

246 return create_stream_table_dataframe(io_dict, time_point=time_point) 

247 

248 def _copy_port_state(self, src_port, snk_port, t): 

249 for name in ("flow_mol", "pressure", "enth_mol", "temperature", "enth_mass", "vapor_frac"): 

250 if hasattr(src_port, name) and hasattr(snk_port, name): 

251 src = getattr(src_port, name) 

252 snk = getattr(snk_port, name) 

253 if (t in src) and (t in snk) and pyo.is_variable_type(snk[t]): 

254 try: 

255 snk[t].set_value(pyo.value(src[t])) 

256 except Exception: 

257 pass 

258 

259 def _hold_state_vars(self, sb): 

260 held = {} 

261 if hasattr(sb, "define_state_vars"): 

262 for k, v in sb.define_state_vars().items(): 

263 if pyo.is_variable_type(v) and not v.fixed: 

264 v.fix() 

265 held[k] = False 

266 else: 

267 held[k] = True 

268 return held 

269 

270 def _release_state_vars(self, sb, held): 

271 if hasattr(sb, "define_state_vars"): 

272 for k, was_fixed in held.items(): 

273 v = sb.define_state_vars()[k] 

274 if was_fixed is False and pyo.is_variable_type(v): 

275 v.unfix() 

276 

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

278 """ 

279 Initialize the Desuperheater without using state_args for sub-units. 

280 

281 Steps: 

282 1) Initialize Mixer (no state_args; it reads from connected inlet ports). 

283 2) Initialize Heater with temporary ΔP=0 and Q=0, then restore. 

284 3) Seed and (if supported) initialize saturated-vapor state block. 

285 4) Seed deltaT_supheat. 

286 """ 

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

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

289 

290 t0 = list(self.flowsheet().time)[0] 

291 

292 # 1) Mixer 

293 init_log.info("Initializing Mixer (no state_args)...") 

294 try: 

295 # Inlet states should already be provided via self.steam_inlet / self.water_inlet 

296 # and are wired to mixer.inlet_1 / mixer.inlet_2 via the exposed ports. 

297 self.water_flow_mol[t0].fix(0.0) 

298 self.water_flow_mol[t0].unfix() 

299 self.mixer.inlet_2.pressure[t0].fix( 

300 pyo.value( 

301 self.mixer.inlet_1.pressure[t0] 

302 ) 

303 ) 

304 self.mixer.inlet_2.pressure[t0].unfix() 

305 self.mixer.initialize(outlvl=outlvl) 

306 except Exception as err: 

307 init_log.error(f"Mixer initialization failed: {err}") 

308 raise 

309 

310 # 2) Heater (losses) 

311 init_log.info("Initializing Heater (losses) with temporary ΔP=0 and Q=0...") 

312 self._copy_port_state( 

313 src_port=self.mixer.outlet, 

314 snk_port=self.losses.inlet, 

315 t=t0 

316 ) 

317 held = self._hold_state_vars(self.losses.control_volume.properties_in[t0]) 

318 try: 

319 self.losses.initialize(outlvl=outlvl) 

320 finally: 

321 self._release_state_vars(self.losses.control_volume.properties_in[t0], held) 

322 

323 # 3) Saturated-vapor state block seeding 

324 init_log.info("Seeding saturated-vapor state block...") 

325 try: 

326 # Set seed values directly 

327 self._properties_sat[t0].pressure.set_value( 

328 pyo.value(self.losses.outlet.pressure[t0]) 

329 ) 

330 self._properties_sat[t0].enth_mol.set_value( 

331 pyo.value(self.losses.outlet.enth_mol[t0]) 

332 ) 

333 

334 # If the block exposes its own initialize(outlvl=...), call it 

335 if hasattr(self._properties_sat[t0], "initialize"): 

336 # Many property packages implement initialize on the *indexed* block, 

337 # i.e. call on the parent indexed component: 

338 try: 

339 self._properties_sat.initialize(outlvl=outlvl) 

340 except TypeError: 

341 # Some implementations only support per-timepoint initialize 

342 self._properties_sat[t0].initialize(outlvl=outlvl) 

343 

344 except Exception as err: 

345 init_log.error(f"Saturation state initialization failed: {err}") 

346 raise 

347 

348 init_log.info("Desuperheater initialization complete.")