Coverage for backend/idaes_service/solver/custom/custom_heat_exchanger_1d.py: 15%

103 statements  

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

1from pyomo.environ import Var, Constraint, Reference, units as pyunits 

2from pyomo.common.numeric_types import value 

3from pyomo.opt.results.solver import check_optimal_termination 

4from idaes.core import declare_process_block_class 

5from idaes.core.util import scaling as iscale 

6from idaes.models.unit_models.heat_exchanger_1D import HeatExchanger1DData, HX1DInitializer 

7from idaes.models.unit_models.heat_exchanger import HeatExchangerFlowPattern 

8from idaes.core.solvers import get_solver 

9from idaes.core.util.exceptions import InitializationError 

10import idaes.logger as idaeslog 

11 

12class CustomHX1DInitializer(HX1DInitializer): 

13 """ 

14 Use our custom control-volume initialize (no source port-member fixing). 

15 """ 

16 def initialize_control_volume(self, cv, state_args=None): 

17 return initialize( 

18 cv, 

19 state_args=state_args, 

20 outlvl=self.get_output_level(), 

21 ) 

22 

23@declare_process_block_class( 

24 "CustomHeatExchanger1D", 

25 doc="1D Heat Exchanger with overall U tied to local heat_transfer_coefficient.", 

26) 

27class CustomHeatExchanger1DData(HeatExchanger1DData): 

28 # Use our initializer so both sides use the custom CV initialize 

29 default_initializer = CustomHX1DInitializer 

30 

31 CONFIG = HeatExchanger1DData.CONFIG() 

32 

33 def build(self): 

34 super().build() 

35 # Ends of the tube along the length axis (start and end positions) 

36 x_first = self.hot_side.length_domain.first() 

37 x_last = self.hot_side.length_domain.last() 

38 

39 # Hot side: inlet at start, outlet at end 

40 x_hot_in, x_hot_out = x_first, x_last 

41 

42 # Cold side: depends on flow pattern 

43 if self.config.flow_type == HeatExchangerFlowPattern.cocurrent: 

44 x_cold_in, x_cold_out = x_first, x_last 

45 else: 

46 x_cold_in, x_cold_out = x_last, x_first 

47 

48 # Time-only inlet/outlet views of the boundary states (no extra vars/cons) 

49 self.hot_side.properties_in = Reference(self.hot_side.properties[:, x_hot_in]) 

50 self.hot_side.properties_out = Reference(self.hot_side.properties[:, x_hot_out]) 

51 self.cold_side.properties_in = Reference(self.cold_side.properties[:, x_cold_in]) 

52 self.cold_side.properties_out = Reference(self.cold_side.properties[:, x_cold_out]) 

53 

54 # Overall U 

55 self.overall_heat_transfer_coefficient = Var( 

56 self.flowsheet().time, 

57 initialize=500.0, 

58 bounds=(1.0, 1e5), 

59 units=pyunits.W / pyunits.m**2 / pyunits.K, 

60 doc="Overall (constant along length) heat transfer coefficient U.", 

61 ) 

62 

63 @self.Constraint(self.flowsheet().time, self.hot_side.length_domain) 

64 def overall_heat_transfer_coefficient_def(b, t, x): 

65 return b.overall_heat_transfer_coefficient[t] == b.heat_transfer_coefficient[t, x] 

66 

67 iscale.set_scaling_factor(self.overall_heat_transfer_coefficient, 1e-3) 

68 

69 def initialize_build( 

70 self, 

71 hot_side_state_args=None, 

72 cold_side_state_args=None, 

73 outlvl=idaeslog.NOTSET, 

74 solver=None, 

75 optarg=None, 

76 duty=None, 

77 ): 

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

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

80 opt = get_solver(solver, optarg) 

81 

82 # Sync length values 

83 if self.length.fixed: 

84 self.cold_side.length.set_value(self.length) 

85 elif self.cold_side.length.fixed: 

86 self.length.set_value(self.cold_side.length) 

87 

88 # Initialize control volumes with length fixed 

89 Lfix = self.hot_side.length.fixed 

90 self.hot_side.length.fix() 

91 flags_hot_side = initialize( 

92 self.hot_side, 

93 outlvl=outlvl, 

94 optarg=optarg, 

95 solver=solver, 

96 state_args=hot_side_state_args, 

97 ) 

98 if not Lfix: 

99 self.hot_side.length.unfix() 

100 

101 Lfix = self.cold_side.length.fixed 

102 self.cold_side.length.fix() 

103 # Use our custom CV initialize here as well 

104 flags_cold_side = initialize( 

105 self.cold_side, 

106 outlvl=outlvl, 

107 optarg=optarg, 

108 solver=solver, 

109 state_args=cold_side_state_args, 

110 ) 

111 if not Lfix: 

112 self.cold_side.length.unfix() 

113 

114 init_log.info_high("Initialization Step 1 Complete.") 

115 

116 # Fixed-duty solve 

117 hot_units = self.hot_side.config.property_package.get_metadata().get_derived_units 

118 cold_units = self.cold_side.config.property_package.get_metadata().get_derived_units 

119 t0 = self.flowsheet().time.first() 

120 

121 # Use inlet indices for each side 

122 x_hot_in = self.hot_side.length_domain.first() 

123 x_cold_in = self.cold_side.length_domain.first() if self.config.flow_type == HeatExchangerFlowPattern.cocurrent else self.cold_side.length_domain.last() 

124 

125 if duty is None: 

126 duty = value( 

127 0.25 

128 * self.heat_transfer_coefficient[t0, x_hot_in] 

129 * self.area 

130 * ( 

131 self.hot_side.properties[t0, x_hot_in].temperature 

132 - pyunits.convert( 

133 self.cold_side.properties[t0, x_cold_in].temperature, 

134 to_units=hot_units("temperature"), 

135 ) 

136 ) 

137 ) 

138 else: 

139 duty = pyunits.convert_value(duty[0], from_units=duty[1], to_units=hot_units("power")) 

140 

141 duty_per_length = value(duty / self.length) 

142 

143 # Fix heat duties 

144 for v in self.hot_side.heat.values(): 

145 v.fix(-duty_per_length) 

146 for v in self.cold_side.heat.values(): 

147 v.fix(pyunits.convert_value(duty_per_length, to_units=cold_units("power")/cold_units("length"), from_units=hot_units("power")/hot_units("length"))) 

148 

149 # Deactivate heat duty constraints and solve 

150 self.heat_transfer_eq.deactivate() 

151 self.heat_conservation.deactivate() 

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

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

154 init_log.info_high("Initialization Step 2 {}.".format(idaeslog.condition(res))) 

155 

156 # Unfix heat duties and re-activate constraints 

157 for v in self.hot_side.heat.values(): 

158 v.unfix() 

159 for v in self.cold_side.heat.values(): 

160 v.unfix() 

161 self.heat_transfer_eq.activate() 

162 self.heat_conservation.activate() 

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

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

165 init_log.info_high("Initialization Step 3 {}.".format(idaeslog.condition(res))) 

166 

167 release_state(self.hot_side, flags_hot_side) 

168 release_state(self.cold_side, flags_cold_side) 

169 

170 if res is not None and not check_optimal_termination(res): 

171 raise InitializationError(f"{self.name} failed to initialize successfully. See logs.") 

172 

173 init_log.info("Initialization Complete.") 

174 

175 

176def initialize( 

177 blk, 

178 state_args=None, 

179 outlvl=idaeslog.NOTSET, 

180 optarg=None, 

181 solver=None, 

182 hold_state=True, 

183 ): 

184 """ 

185 Initialization routine for 1D control volume. 

186 

187 Keyword Arguments: 

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

189 package(s) to provide an initial state for initialization 

190 (see documentation of the specific property package) (default = {}). 

191 outlvl: sets output level of initialization routine 

192 optarg: solver options dictionary object (default=None, use 

193 default solver options) 

194 solver: str indicating which solver to use during initialization 

195 (default = None) 

196 hold_state: flag indicating whether the initialization routine 

197 should unfix any state variables fixed during initialization, 

198 (default = True). **Valid values:** 

199 **True** - states variables are not unfixed, and a dict of 

200 returned containing flags for which states were fixed 

201 during initialization, **False** - state variables are 

202 unfixed after initialization by calling the release_state 

203 method. 

204 

205 Returns: 

206 If hold_states is True, returns a dict containing flags for which 

207 states were fixed during initialization else the release state is 

208 triggered. 

209 """ 

210 if optarg is None: 

211 optarg = {} 

212 

213 # Get inlet state if not provided 

214 init_log = idaeslog.getInitLogger(blk.name, outlvl, tag="control_volume") 

215 

216 # Provide guesses if none 

217 if state_args is None: 

218 blk.estimate_states(always_estimate=True) 

219 

220 

221 if state_args is None: 

222 # If no initial guesses provided, estimate values for states 

223 blk.estimate_states(always_estimate=True) 

224 

225 # Initialize state blocks 

226 flags = blk.properties.initialize( 

227 state_args=state_args, 

228 outlvl=outlvl, 

229 optarg=optarg, 

230 solver=solver, 

231 hold_state=True, 

232 ) 

233 

234 try: 

235 # TODO: setting state_vars_fixed may not work for heterogeneous 

236 # systems where a second control volume is involved, as we cannot 

237 # assume those state vars are also fixed. For now, heterogeneous 

238 # reactions should ignore the state_vars_fixed argument and always 

239 # check their state_vars. 

240 blk.reactions.initialize( 

241 outlvl=outlvl, 

242 optarg=optarg, 

243 solver=solver, 

244 state_vars_fixed=True, 

245 ) 

246 except AttributeError: 

247 pass 

248 

249 init_log.info("Initialization Complete") 

250 

251 # Unfix state variables except for source block 

252 blk.properties.release_state(flags) 

253 

254 return {} 

255 

256def release_state(blk, flags, outlvl=idaeslog.NOTSET): 

257 # No-op: nothing was fixed at the CV level in our custom initialize 

258 return