Coverage for backend/ahuora-builder/src/ahuora_builder/custom/watertap/pressure_exchanger.py: 86%

211 statements  

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

1from idaes.core import declare_process_block_class 

2import idaes.logger as idaeslog 

3from pyomo.common.collections import ComponentSet 

4from pyomo.core.base.component import Component 

5from pyomo.core.expr.visitor import identify_variables 

6from pyomo.environ import Constraint, check_optimal_termination, value 

7from pyomo.repn import generate_standard_repn 

8from watertap.core.solvers import get_solver 

9from watertap.core.util.initialization import interval_initializer 

10from watertap.unit_models.pressure_exchanger import ( 

11 PressureExchangeType, 

12 PressureExchangerData as WaterTAPPressureExchangerData, 

13) 

14from idaes.core.util.exceptions import ConfigurationError, InitializationError 

15 

16 

17@declare_process_block_class("PressureExchanger") 

18class PressureExchangerData(WaterTAPPressureExchangerData): 

19 """ 

20 Ahuora wrapper around WaterTAP's Pressure Exchanger unit model. 

21 """ 

22 

23 def build(self): 

24 super().build() 

25 

26 def initialize_build( 

27 self, 

28 state_args=None, 

29 routine=None, 

30 outlvl=idaeslog.NOTSET, 

31 solver=None, 

32 optarg=None, 

33 ): 

34 """ 

35 Initialize the pressure exchanger with robust inlet flow guesses. 

36 

37 WaterTAP's pressure exchanger initializer requires equal inlet 

38 volumetric flows before solving the unit. Platform "guess" properties 

39 are temporary initializer aids, so a flow guess should not prevent this 

40 unit from finding its own equal-flow starting point. 

41 """ 

42 init_log = idaeslog.getInitLogger(self.name, outlvl, tag="properties") 

43 solve_log = idaeslog.getSolveLogger(self.name, outlvl, tag="properties") 

44 opt = get_solver(solver, optarg) 

45 

46 released_guess_states = _release_platform_flow_guesses(self) 

47 true_flow_spec_states = _inlet_states_with_platform_flow_specs(self) 

48 

49 flags_low_in = self.feed_side.properties_in.initialize( 

50 outlvl=outlvl, 

51 optarg=optarg, 

52 solver=solver, 

53 state_args=state_args, 

54 hold_state=True, 

55 ) 

56 flags_high_in = self.brine_side.properties_in.initialize( 

57 outlvl=outlvl, 

58 optarg=optarg, 

59 solver=solver, 

60 state_args=state_args, 

61 hold_state=True, 

62 ) 

63 

64 init_log.info_high("Initialize inlets complete") 

65 

66 _seed_equal_inlet_flow_values( 

67 self, 

68 released_guess_states=released_guess_states, 

69 true_flow_spec_states=true_flow_spec_states, 

70 ) 

71 

72 if value(self.feed_side.properties_in[0].pressure) > value( 

73 self.brine_side.properties_in[0].pressure 

74 ): 

75 raise ConfigurationError( 

76 "Initializing pressure exchanger failed because " 

77 "the feed side inlet has a higher pressure " 

78 "than the brine side inlet" 

79 ) 

80 

81 if ( 81 ↛ 91line 81 didn't jump to line 91 because the condition on line 81 was never true

82 abs( 

83 value(self.feed_side.properties_in[0].flow_vol) 

84 - value(self.brine_side.properties_in[0].flow_vol) 

85 ) 

86 / value(self.brine_side.properties_in[0].flow_vol) 

87 > 1e-4 

88 and not self.config.has_mixing 

89 and not self.config.has_leakage 

90 ): 

91 raise ConfigurationError( 

92 "Initializing pressure exchanger failed because " 

93 "the volumetric flow rates are not equal for both inlets " 

94 + str(value(self.brine_side.properties_out[0].flow_vol)) 

95 + "," 

96 + str(value(self.feed_side.properties_in[0].flow_vol)) 

97 ) 

98 else: 

99 self.eq_equal_flow_vol.deactivate() 

100 

101 def propagate_state(sb1, sb2): 

102 state_dict_1 = sb1.define_state_vars() 

103 state_dict_2 = sb2.define_state_vars() 

104 for key in state_dict_1.keys(): 

105 if state_dict_1[key].is_indexed(): 

106 for index in state_dict_1[key].keys(): 

107 state_dict_2[key][index].value = state_dict_1[key][index].value 

108 else: 

109 state_dict_2[key].value = state_dict_1[key].value 

110 

111 propagate_state( 

112 self.feed_side.properties_in[0], 

113 self.feed_side.properties_out[0], 

114 ) 

115 if self.config.pressure_exchange_calculation is PressureExchangeType.efficiency: 115 ↛ 122line 115 didn't jump to line 122 because the condition on line 115 was always true

116 self.feed_side.properties_out[0].pressure = self.feed_side.properties_in[ 

117 0 

118 ].pressure.value + self.efficiency_pressure_exchanger[0].value * ( 

119 self.brine_side.properties_in[0].pressure.value 

120 - self.feed_side.properties_in[0].pressure.value 

121 ) 

122 elif ( 

123 self.config.pressure_exchange_calculation 

124 is PressureExchangeType.high_pressure_difference 

125 ): 

126 self.feed_side.properties_out[0].pressure = ( 

127 self.brine_side.properties_in[0].pressure.value 

128 - self.high_pressure_difference[0].value 

129 ) 

130 

131 propagate_state( 

132 self.brine_side.properties_in[0], 

133 self.brine_side.properties_out[0], 

134 ) 

135 self.brine_side.properties_out[0].pressure.value = self.feed_side.properties_in[ 

136 0 

137 ].pressure.value 

138 init_log.info_high("Initialize outlets complete") 

139 

140 interval_initializer(self) 

141 

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

143 results = opt.solve(self, tee=slc.tee) 

144 init_log.info("Initialization complete: {}".format(idaeslog.condition(results))) 

145 

146 self.feed_side.properties_in.release_state(flags_low_in) 

147 self.brine_side.properties_in.release_state(flags_high_in) 

148 

149 if not check_optimal_termination(results): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 raise InitializationError(f"Unit model {self.name} failed to initialize") 

151 

152 self.eq_equal_flow_vol.activate() 

153 

154 def diagnose(self) -> list[tuple[Component, str]]: 

155 """ 

156 Return user-facing hints for common pressure exchanger specification issues. 

157 

158 The pressure exchanger expects low-pressure feed on ``feed_side`` and 

159 high-pressure retentate/brine on ``brine_side``. ``deltaP`` and ``work`` 

160 are calculated performance values in the default efficiency mode, so 

161 fixing them alongside efficiency commonly over-specifies the unit. 

162 """ 

163 problems: list[tuple[Component, str]] = [] 

164 

165 feed_in = self.feed_side.properties_in[0] 

166 feed_out = self.feed_side.properties_out[0] 

167 brine_in = self.brine_side.properties_in[0] 

168 brine_out = self.brine_side.properties_out[0] 

169 

170 feed_in_pressure = _safe_value(feed_in.pressure) 

171 feed_out_pressure = _safe_value(feed_out.pressure) 

172 brine_in_pressure = _safe_value(brine_in.pressure) 

173 brine_out_pressure = _safe_value(brine_out.pressure) 

174 

175 if ( 

176 feed_in_pressure is not None 

177 and brine_in_pressure is not None 

178 and feed_in_pressure > brine_in_pressure 

179 ): 

180 problems.append( 

181 ( 

182 feed_in.pressure, 

183 "The pressure exchanger feed side inlet pressure is higher " 

184 "than the brine side inlet pressure. The feed side should " 

185 "receive low-pressure feed, and the brine side should receive " 

186 "high-pressure RO retentate. Check that the low-pressure feed " 

187 "is connected to feed_inlet and RO retentate is connected to " 

188 "brine_inlet.", 

189 ) 

190 ) 

191 elif ( 

192 feed_in_pressure is not None 

193 and brine_in_pressure is not None 

194 and brine_in_pressure - feed_in_pressure <= 1e-6 

195 ): 

196 problems.append( 

197 ( 

198 brine_in.pressure, 

199 "The brine side inlet pressure is not higher than the feed " 

200 "side inlet pressure, so there is no pressure difference to " 

201 "recover. The brine side should be the high-pressure stream.", 

202 ) 

203 ) 

204 

205 if ( 

206 feed_in_pressure is not None 

207 and feed_out_pressure is not None 

208 and feed_out_pressure <= feed_in_pressure 

209 ): 

210 problems.append( 

211 ( 

212 feed_out.pressure, 

213 "The feed outlet pressure is not higher than the feed inlet " 

214 "pressure. A pressure exchanger should raise pressure on the " 

215 "feed side.", 

216 ) 

217 ) 

218 

219 if ( 

220 brine_in_pressure is not None 

221 and brine_out_pressure is not None 

222 and brine_out_pressure >= brine_in_pressure 

223 ): 

224 problems.append( 

225 ( 

226 brine_out.pressure, 

227 "The brine outlet pressure is not lower than the brine inlet " 

228 "pressure. A pressure exchanger should recover pressure from " 

229 "the high-pressure brine side.", 

230 ) 

231 ) 

232 

233 feed_flow_vol = _safe_value(feed_in.flow_vol) 

234 brine_flow_vol = _safe_value(brine_in.flow_vol) 

235 if ( 235 ↛ 260line 235 didn't jump to line 260 because the condition on line 235 was always true

236 feed_flow_vol is not None 

237 and brine_flow_vol is not None 

238 and min(abs(feed_flow_vol), abs(brine_flow_vol)) > 1e-12 

239 ): 

240 flow_ratio = abs(feed_flow_vol / brine_flow_vol) 

241 if flow_ratio > 100: 

242 problems.append( 

243 ( 

244 feed_in.flow_vol, 

245 "The feed side volumetric flow is more than 100 times " 

246 "the brine side flow. With no leakage or mixing, the " 

247 "pressure exchanger expects similar volumetric flows.", 

248 ) 

249 ) 

250 elif flow_ratio < 0.01: 

251 problems.append( 

252 ( 

253 brine_in.flow_vol, 

254 "The brine side volumetric flow is more than 100 times " 

255 "the feed side flow. With no leakage or mixing, the " 

256 "pressure exchanger expects similar volumetric flows.", 

257 ) 

258 ) 

259 

260 efficiency = _safe_value(self.efficiency_pressure_exchanger[0]) 

261 if efficiency is not None: 261 ↛ 280line 261 didn't jump to line 280 because the condition on line 261 was always true

262 if efficiency <= 0 or efficiency > 1: 

263 problems.append( 

264 ( 

265 self.efficiency_pressure_exchanger[0], 

266 "Pressure exchanger efficiency must be greater than 0 " 

267 "and no more than 1.", 

268 ) 

269 ) 

270 elif efficiency < 0.5: 

271 problems.append( 

272 ( 

273 self.efficiency_pressure_exchanger[0], 

274 "Pressure exchanger efficiency is very low. Confirm this " 

275 "is intentional; typical pressure exchangers are much " 

276 "closer to 1.", 

277 ) 

278 ) 

279 

280 if not _is_specified(self, self.efficiency_pressure_exchanger[0]) and not hasattr( 

281 self, "high_pressure_difference" 

282 ): 

283 problems.append( 

284 ( 

285 self.efficiency_pressure_exchanger[0], 

286 "Pressure exchanger efficiency is not specified. For a simulation " 

287 "case, specify efficiency or use the high-pressure-difference " 

288 "calculation mode with its pressure differences specified.", 

289 ) 

290 ) 

291 

292 for component, label in ( 

293 (self.feed_side.deltaP[0], "feed side pressure change"), 

294 (self.brine_side.deltaP[0], "brine side pressure change"), 

295 ): 

296 if component.fixed: 

297 problems.append( 

298 ( 

299 component, 

300 f"The {label} is fixed, but it is calculated from the " 

301 "pressure exchanger equations. Fixing it alongside " 

302 "efficiency or outlet pressure can over-specify the unit.", 

303 ) 

304 ) 

305 

306 if feed_out.pressure.fixed and brine_out.pressure.fixed: 

307 problems.append( 

308 ( 

309 feed_out.pressure, 

310 "Both pressure exchanger outlet pressures are fixed. Outlet " 

311 "pressures are coupled to the pressure transfer equations, so " 

312 "fixing both can over-specify the unit.", 

313 ) 

314 ) 

315 

316 for constraint in _constraints_fixing_expression(self, self.feed_side.work[0]): 

317 problems.append( 

318 ( 

319 constraint, 

320 "Feed side mechanical work is a calculated expression. A " 

321 "constraint fixing it can over-specify the pressure exchanger.", 

322 ) 

323 ) 

324 

325 for constraint in _constraints_fixing_expression(self, self.brine_side.work[0]): 

326 problems.append( 

327 ( 

328 constraint, 

329 "Brine side mechanical work is a calculated expression. A " 

330 "constraint fixing it can over-specify the pressure exchanger.", 

331 ) 

332 ) 

333 

334 return problems 

335 

336 

337def _safe_value(component) -> float | None: 

338 try: 

339 return value(component, exception=False) 

340 except Exception: 

341 return None 

342 

343 

344def _constraints_fixing_expression(block, expression): 

345 expr_id = id(expression) 

346 for constraint in block.parent_block().component_data_objects( 

347 Constraint, active=True, descend_into=True 

348 ): 

349 if id(constraint.body) == expr_id: 

350 yield constraint 

351 

352 

353def _is_specified(block, component) -> bool: 

354 return component.fixed or _has_single_variable_equality_constraint(block, component) 

355 

356 

357def _has_single_variable_equality_constraint(block, component) -> bool: 

358 component_id = id(component) 

359 for constraint in block.parent_block().component_data_objects( 

360 Constraint, active=True, descend_into=True 

361 ): 

362 if _is_descendant_of(constraint.parent_block(), block): 

363 continue 

364 if not constraint.equality: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true

365 continue 

366 

367 repn = generate_standard_repn(constraint.body) 

368 if ( 368 ↛ 375line 368 didn't jump to line 375 because the condition on line 368 was always true

369 repn.is_linear() 

370 and len(repn.linear_vars) == 1 

371 and id(repn.linear_vars[0]) == component_id 

372 ): 

373 return True 

374 

375 variables = list(identify_variables(constraint.body, include_fixed=False)) 

376 if len(variables) == 1 and id(variables[0]) == component_id: 

377 return True 

378 

379 return False 

380 

381 

382def _is_descendant_of(child_block, parent_block) -> bool: 

383 block = child_block 

384 while block is not None: 

385 if block is parent_block: 

386 return True 

387 block = block.parent_block() 

388 return False 

389 

390 

391def _release_platform_flow_guesses(unit) -> ComponentSet: 

392 """ 

393 Unfix platform flow guesses on inlet state blocks before WaterTAP initializes. 

394 

395 Flexible state blocks normally reactivate values registered through 

396 ``constrain_component`` during their own initialization. That is right for 

397 real specifications, but platform guesses should remain free after their 

398 value has seeded the model. 

399 """ 

400 guess_components = _platform_guess_components(unit) 

401 released_states = ComponentSet() 

402 if not guess_components: 

403 return released_states 

404 

405 for state_block in _pressure_exchanger_inlet_states(unit): 

406 vars_to_deactivate = state_block.__dict__.get("vars_to_deactivate") 

407 if vars_to_deactivate is None: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 continue 

409 

410 retained_vars = [] 

411 for component in vars_to_deactivate: 

412 if component in guess_components and _is_flow_component(component): 

413 component.unfix() 

414 released_states.add(state_block) 

415 else: 

416 retained_vars.append(component) 

417 state_block.__dict__["vars_to_deactivate"] = retained_vars 

418 

419 for component in guess_components: 

420 if _is_flow_component(component) and hasattr(component, "unfix"): 420 ↛ 419line 420 didn't jump to line 419 because the condition on line 420 was always true

421 component.unfix() 

422 

423 return released_states 

424 

425 

426def _seed_equal_inlet_flow_values( 

427 unit, 

428 *, 

429 released_guess_states: ComponentSet, 

430 true_flow_spec_states: ComponentSet, 

431) -> None: 

432 if unit.config.has_mixing or unit.config.has_leakage: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true

433 return 

434 

435 feed = unit.feed_side.properties_in[0] 

436 brine = unit.brine_side.properties_in[0] 

437 feed_flow_vol = _safe_value(feed.flow_vol) 

438 brine_flow_vol = _safe_value(brine.flow_vol) 

439 if ( 439 ↛ 445line 439 didn't jump to line 445 because the condition on line 439 was never true

440 feed_flow_vol is None 

441 or brine_flow_vol is None 

442 or abs(feed_flow_vol) <= 1e-12 

443 or abs(brine_flow_vol) <= 1e-12 

444 ): 

445 return 

446 

447 if abs(feed_flow_vol - brine_flow_vol) / abs(brine_flow_vol) <= 1e-4: 

448 return 

449 

450 feed_can_move = feed in released_guess_states or feed not in true_flow_spec_states 

451 brine_can_move = brine in released_guess_states or brine not in true_flow_spec_states 

452 if feed_can_move: 452 ↛ 454line 452 didn't jump to line 454 because the condition on line 452 was always true

453 _scale_flow_state_to_volume(feed, brine_flow_vol) 

454 elif brine_can_move: 

455 _scale_flow_state_to_volume(brine, feed_flow_vol) 

456 

457 

458def _pressure_exchanger_inlet_states(unit): 

459 return ( 

460 unit.feed_side.properties_in[0], 

461 unit.brine_side.properties_in[0], 

462 ) 

463 

464 

465def _platform_guess_components(unit) -> ComponentSet: 

466 try: 

467 guess_groups = unit.flowsheet().guess_vars 

468 except AttributeError: 

469 return ComponentSet() 

470 

471 components = ComponentSet() 

472 inlet_states = ComponentSet(_pressure_exchanger_inlet_states(unit)) 

473 for guess_group in guess_groups: 

474 for component in guess_group: 

475 if component.parent_block() in inlet_states: 475 ↛ 474line 475 didn't jump to line 474 because the condition on line 475 was always true

476 components.add(component) 

477 return components 

478 

479 

480def _inlet_states_with_platform_flow_specs(unit) -> ComponentSet: 

481 states = ComponentSet() 

482 for state_block in _pressure_exchanger_inlet_states(unit): 

483 for component in state_block.__dict__.get("vars_to_deactivate", []): 

484 if _is_flow_component(component): 

485 states.add(state_block) 

486 break 

487 return states 

488 

489 

490def _is_flow_component(component) -> bool: 

491 return "flow" in component.local_name 

492 

493 

494def _scale_flow_state_to_volume(state_block, target_flow_vol: float) -> None: 

495 current_flow_vol = _safe_value(state_block.flow_vol) 

496 if current_flow_vol is None or abs(current_flow_vol) <= 1e-12: 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true

497 return 

498 

499 flow_mass_vars = list(state_block.flow_mass_phase_comp.values()) 

500 current_total_flow_mass = sum( 

501 _safe_value(component) or 0 for component in flow_mass_vars 

502 ) 

503 target_total_flow_mass = current_total_flow_mass * target_flow_vol / current_flow_vol 

504 if current_total_flow_mass <= 1e-12: 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 per_component_flow_mass = target_total_flow_mass / len(flow_mass_vars) 

506 for component in flow_mass_vars: 

507 component.set_value(per_component_flow_mass) 

508 return 

509 

510 scale_factor = target_total_flow_mass / current_total_flow_mass 

511 for component in flow_mass_vars: 

512 component.set_value((_safe_value(component) or 0) * scale_factor) 

513 

514 if hasattr(state_block, "flow_vol_phase"): 514 ↛ exitline 514 didn't return from function '_scale_flow_state_to_volume' because the condition on line 514 was always true

515 for component in state_block.flow_vol_phase.values(): 

516 component.set_value(target_flow_vol / len(state_block.flow_vol_phase))