Coverage for backend/ahuora-builder/src/ahuora_builder/custom/watertap/osmotically_assisted_reverse_osmosis_0d.py: 83%

126 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 

2from idaes.core.util import scaling as iscale 

3from idaes.core.util.model_statistics import degrees_of_freedom 

4from pyomo.common.collections import ComponentSet 

5from pyomo.core.base.component import Component 

6from pyomo.core.expr.visitor import identify_variables 

7from pyomo.environ import Constraint, Expression, value 

8from pyomo.repn import generate_standard_repn 

9from watertap.unit_models.osmotically_assisted_reverse_osmosis_0D import ( 

10 OsmoticallyAssistedReverseOsmosisData, 

11) 

12 

13from ahuora_builder.methods.scaling_suffix import sanitize_scaling_suffix 

14 

15 

16@declare_process_block_class("OsmoticallyAssistedReverseOsmosis0D") 

17class OsmoticallyAssistedReverseOsmosis0DData(OsmoticallyAssistedReverseOsmosisData): 

18 """ 

19 Ahuora wrapper around WaterTAP's Osmotically Assisted Reverse Osmosis 0D model. 

20 """ 

21 

22 def build(self): 

23 super().build() 

24 time = self.flowsheet().time 

25 

26 self.feed_inlet_reynolds_number = Expression( 

27 time, 

28 rule=lambda b, t: b.feed_side.N_Re[t, b.feed_side.length_domain.first()], 

29 doc="Feed-side Reynolds number at the channel inlet", 

30 ) 

31 self.permeate_inlet_reynolds_number = Expression( 

32 time, 

33 rule=lambda b, t: b.permeate_side.N_Re[ 

34 t, b.permeate_side.length_domain.last() 

35 ], 

36 doc="Permeate-side Reynolds number at the channel inlet", 

37 ) 

38 self.volumetric_recovery = Expression( 

39 time, 

40 rule=lambda b, t: b.recovery_vol_phase[t, "Liq"], 

41 doc="Liquid-phase volumetric recovery", 

42 ) 

43 self.water_mass_recovery = Expression( 

44 time, 

45 rule=lambda b, t: b.recovery_mass_phase_comp[t, "Liq", "H2O"], 

46 doc="Water mass recovery", 

47 ) 

48 self.tds_mass_recovery = Expression( 

49 time, 

50 rule=lambda b, t: b.recovery_mass_phase_comp[t, "Liq", "TDS"], 

51 doc="TDS mass recovery", 

52 ) 

53 self.tds_observed_rejection = Expression( 

54 time, 

55 rule=lambda b, t: b.rejection_phase_comp[t, "Liq", "TDS"], 

56 doc="Observed TDS rejection", 

57 ) 

58 self.average_water_mass_flux = Expression( 

59 time, 

60 rule=lambda b, t: b.flux_mass_phase_comp_avg[t, "Liq", "H2O"], 

61 doc="Average water mass flux through the membrane", 

62 ) 

63 self.average_tds_mass_flux = Expression( 

64 time, 

65 rule=lambda b, t: b.flux_mass_phase_comp_avg[t, "Liq", "TDS"], 

66 doc="Average TDS mass flux through the membrane", 

67 ) 

68 

69 def initialize_build(self, *args, **kwargs): 

70 iscale.calculate_scaling_factors(self) 

71 sanitize_scaling_suffix(self) 

72 return super().initialize_build(*args, **kwargs) 

73 

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

75 """ 

76 Return user-facing hints for common OARO specification issues. 

77 

78 OARO needs feed and permeate inlet states, membrane transport 

79 coefficients, structural parameter, and channel geometry. Recovery, 

80 rejection, Reynolds numbers, and fluxes are calculated performance 

81 values in this wrapper, so fixing them can over-specify the unit unless 

82 the user intentionally frees another design variable. 

83 """ 

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

85 

86 feed_in = self.feed_side.properties_in[0] 

87 permeate_in = self.permeate_side.properties_in[0] 

88 feed_pressure = _safe_value(feed_in.pressure) 

89 permeate_pressure = _safe_value(permeate_in.pressure) 

90 if feed_pressure is not None and permeate_pressure is not None: 90 ↛ 109line 90 didn't jump to line 109 because the condition on line 90 was always true

91 if permeate_pressure >= feed_pressure: 

92 problems.append( 

93 ( 

94 permeate_in.pressure, 

95 "The OARO permeate inlet pressure is not lower than feed " 

96 "inlet pressure. OARO normally needs feed pressure above " 

97 "permeate pressure for reverse-osmosis water transport.", 

98 ) 

99 ) 

100 elif feed_pressure - permeate_pressure < 1e5: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 problems.append( 

102 ( 

103 feed_in.pressure, 

104 "The OARO pressure difference is very small. This may lead " 

105 "to little or no water transfer through the membrane.", 

106 ) 

107 ) 

108 

109 feed_temperature = _safe_value(feed_in.temperature) 

110 permeate_temperature = _safe_value(permeate_in.temperature) 

111 if ( 

112 feed_temperature is not None 

113 and permeate_temperature is not None 

114 and abs(feed_temperature - permeate_temperature) > 1e-6 

115 ): 

116 problems.append( 

117 ( 

118 permeate_in.temperature, 

119 "The OARO feed and permeate inlet temperatures differ, but the " 

120 "WaterTAP OARO model assumes isothermal operation.", 

121 ) 

122 ) 

123 

124 for component in self.A_comp.values(): 

125 water_permeability = _safe_value(component) 

126 if water_permeability is not None and water_permeability <= 0: 

127 problems.append( 

128 ( 

129 component, 

130 "OARO water permeability must be greater than 0.", 

131 ) 

132 ) 

133 

134 for component in self.B_comp.values(): 

135 salt_permeability = _safe_value(component) 

136 if salt_permeability is not None and salt_permeability < 0: 

137 problems.append( 

138 ( 

139 component, 

140 "OARO salt permeability must be greater than or equal to 0.", 

141 ) 

142 ) 

143 

144 structural_parameter = _safe_value(self.structural_parameter) 

145 if structural_parameter is not None and structural_parameter <= 0: 

146 problems.append( 

147 ( 

148 self.structural_parameter, 

149 "OARO membrane structural parameter must be greater than 0.", 

150 ) 

151 ) 

152 

153 geometry_specs = [ 

154 _is_specified(self, self.area), 

155 _is_specified(self, self.length), 

156 _is_specified(self, self.width), 

157 ] 

158 if sum(geometry_specs) < 2: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 problems.append( 

160 ( 

161 self.area, 

162 "OARO membrane geometry is under-specified. Specify two of " 

163 "membrane area, length, and width.", 

164 ) 

165 ) 

166 elif all(geometry_specs): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 problems.append( 

168 ( 

169 self.width, 

170 "OARO membrane area, length, and width are all fixed. Because " 

171 "area = length * width, fixing all three can over-specify the unit.", 

172 ) 

173 ) 

174 

175 for component, label in ( 

176 (self.area, "membrane area"), 

177 (self.length, "membrane length"), 

178 (self.width, "membrane width"), 

179 (self.feed_side.channel_height, "feed channel height"), 

180 (self.permeate_side.channel_height, "permeate channel height"), 

181 ): 

182 component_value = _safe_value(component) 

183 if component_value is not None and component_value <= 0: 

184 problems.append((component, f"OARO {label} must be greater than 0.")) 

185 

186 for component, label in ( 

187 (self.feed_side.spacer_porosity, "feed spacer porosity"), 

188 (self.permeate_side.spacer_porosity, "permeate spacer porosity"), 

189 ): 

190 component_value = _safe_value(component) 

191 if component_value is not None and not 0 < component_value < 1: 

192 problems.append((component, f"OARO {label} must be between 0 and 1.")) 

193 

194 for component, label in ( 

195 (self.recovery_vol_phase[0, "Liq"], "volumetric recovery"), 

196 ( 

197 self.recovery_mass_phase_comp[0, "Liq", "H2O"], 

198 "water mass recovery", 

199 ), 

200 ( 

201 self.recovery_mass_phase_comp[0, "Liq", "TDS"], 

202 "TDS mass recovery", 

203 ), 

204 ( 

205 self.rejection_phase_comp[0, "Liq", "TDS"], 

206 "observed solute rejection", 

207 ), 

208 ): 

209 component_value = _safe_value(component) 

210 if component_value is not None and not 0 <= component_value <= 1: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true

211 problems.append( 

212 ( 

213 component, 

214 f"OARO {label} should be between 0 and 1.", 

215 ) 

216 ) 

217 

218 calculated_outputs = ( 

219 ( 

220 self.recovery_vol_phase[0, "Liq"], 

221 "Volumetric recovery is calculated from the OARO flow balances.", 

222 ), 

223 ( 

224 self.recovery_mass_phase_comp[0, "Liq", "H2O"], 

225 "Water mass recovery is calculated from the OARO component balances.", 

226 ), 

227 ( 

228 self.recovery_mass_phase_comp[0, "Liq", "TDS"], 

229 "TDS mass recovery is calculated from the OARO component balances.", 

230 ), 

231 ( 

232 self.rejection_phase_comp[0, "Liq", "TDS"], 

233 "Observed solute rejection is calculated from feed and permeate " 

234 "composition.", 

235 ), 

236 ( 

237 self.flux_mass_phase_comp_avg[0, "Liq", "H2O"], 

238 "Average water mass flux is calculated from membrane transport " 

239 "equations.", 

240 ), 

241 ( 

242 self.feed_side.N_Re[0, self.feed_side.length_domain.first()], 

243 "Feed inlet Reynolds number is calculated from channel geometry " 

244 "and flow.", 

245 ), 

246 ( 

247 self.permeate_side.N_Re[0, self.permeate_side.length_domain.last()], 

248 "Permeate inlet Reynolds number is calculated from channel geometry " 

249 "and flow.", 

250 ), 

251 ) 

252 model_dof = _safe_degrees_of_freedom(self) 

253 for component, message in calculated_outputs: 

254 if model_dof is not None and model_dof < 0 and _is_specified( 

255 self, component 

256 ): 

257 problems.append( 

258 ( 

259 component, 

260 f"{message} Fixing it can over-specify the OARO unit.", 

261 ) 

262 ) 

263 

264 return problems 

265 

266 @staticmethod 

267 def ahuora_metadata(): 

268 return _osmotically_assisted_reverse_osmosis_0d_metadata() 

269 

270 

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

272 try: 

273 return value(component, exception=False) 

274 except Exception: 

275 return None 

276 

277 

278def _safe_degrees_of_freedom(block) -> int | None: 

279 try: 

280 return degrees_of_freedom(block.parent_block()) 

281 except Exception: 

282 try: 

283 return degrees_of_freedom(block) 

284 except Exception: 

285 return None 

286 

287 

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

289 return getattr(component, "fixed", False) or _has_external_equality_spec( 

290 block, component 

291 ) 

292 

293 

294def _has_external_equality_spec(block, component) -> bool: 

295 target_vars = ComponentSet(identify_variables(component, include_fixed=False)) 

296 if not target_vars and hasattr(component, "is_variable_type"): 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 target_vars = ComponentSet([component]) 

298 if not target_vars: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 return False 

300 

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

302 Constraint, active=True, descend_into=True 

303 ): 

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

305 continue 

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

307 continue 

308 

309 repn = generate_standard_repn(constraint.body) 

310 if repn.is_linear() and len(repn.linear_vars) == 1: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 if repn.linear_vars[0] in target_vars: 

312 return True 

313 

314 constraint_vars = ComponentSet( 

315 identify_variables(constraint.body, include_fixed=False) 

316 ) 

317 if constraint_vars and constraint_vars == target_vars: 

318 return True 

319 

320 return False 

321 

322 

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

324 block = child_block 

325 while block is not None: 

326 if block is parent_block: 

327 return True 

328 block = block.parent_block() 

329 return False 

330 

331 

332def _numeric_property(display_name, unit_type, *, group="default"): 

333 from ahuora_unit_ops.json_config import JsonPropertyConfig 

334 

335 return JsonPropertyConfig( 

336 propertySetGroup=group, 

337 displayName=display_name, 

338 indexSets=None, 

339 sumToOne=False, 

340 value=None, 

341 unit=None, 

342 unitType=unit_type, 

343 description=None, 

344 type="numeric", 

345 many=False, 

346 default=1, 

347 options={}, 

348 hasTimeIndex=True, 

349 ) 

350 

351 

352def _osmotically_assisted_reverse_osmosis_0d_metadata(): 

353 from ahuora_unit_ops.json_config import ( 

354 JsonAdapterArgConfig, 

355 JsonFrontendConfig, 

356 JsonGraphicObjectConfig, 

357 JsonIdaesAdapterConfig, 

358 JsonPortConfig, 

359 JsonPropertySetGroupConfig, 

360 JsonUnitOpConfig, 

361 ) 

362 

363 return JsonUnitOpConfig( 

364 key="osmotically_assisted_reverse_osmosis_0d", 

365 objectType="osmotically_assisted_reverse_osmosis_0d", 

366 enumMember="OsmoticallyAssistedReverseOsmosis0D", 

367 displayType="Osmotically Assisted Reverse Osmosis (0D)", 

368 displayName="Osmotically Assisted Reverse Osmosis (0D)", 

369 categoryPath=["chemical", "separation"], 

370 ports={ 

371 "feed_inlet": JsonPortConfig( 

372 displayName="Feed Inlet", 

373 type="inlet", 

374 streamType="stream", 

375 many=False, 

376 default=1, 

377 minimum=1, 

378 makeStream=True, 

379 streamOffset=0.75, 

380 streamName="Feed S", 

381 ), 

382 "feed_outlet": JsonPortConfig( 

383 displayName="Feed Outlet", 

384 type="outlet", 

385 streamType="stream", 

386 many=False, 

387 default=1, 

388 minimum=1, 

389 makeStream=True, 

390 streamOffset=0.75, 

391 streamName="Feed Outlet S", 

392 ), 

393 "permeate_inlet": JsonPortConfig( 

394 displayName="Permeate Inlet", 

395 type="inlet", 

396 streamType="stream", 

397 many=False, 

398 default=1, 

399 minimum=1, 

400 makeStream=True, 

401 streamOffset=0.75, 

402 streamName="Permeate S", 

403 ), 

404 "permeate_outlet": JsonPortConfig( 

405 displayName="Permeate Outlet", 

406 type="outlet", 

407 streamType="stream", 

408 many=False, 

409 default=1, 

410 minimum=1, 

411 makeStream=True, 

412 streamOffset=0.75, 

413 streamName="Permeate Outlet S", 

414 ), 

415 }, 

416 propertyPackagePorts={ 

417 "": [ 

418 "feed_inlet", 

419 "feed_outlet", 

420 "permeate_inlet", 

421 "permeate_outlet", 

422 ], 

423 }, 

424 graphicObject=JsonGraphicObjectConfig(kind="unitop_graphic"), 

425 indexSets=[], 

426 properties={ 

427 "A_comp": _numeric_property( 

428 "Membrane Water Permeability (A)", "solventPermeability" 

429 ), 

430 "B_comp": _numeric_property("Membrane Salt Permeability (B)", "velocity"), 

431 "area": _numeric_property("Membrane Area", "area"), 

432 "structural_parameter": _numeric_property( 

433 "Membrane Structural Parameter", "thickness" 

434 ), 

435 "feed_side.spacer_porosity": _numeric_property( 

436 "Feed Spacer Porosity", "ratio" 

437 ), 

438 "feed_side.channel_height": _numeric_property( 

439 "Feed Channel Height", "distance" 

440 ), 

441 "permeate_side.spacer_porosity": _numeric_property( 

442 "Permeate Spacer Porosity", "ratio" 

443 ), 

444 "permeate_side.channel_height": _numeric_property( 

445 "Permeate Channel Height", "distance" 

446 ), 

447 "length": _numeric_property("Membrane Length", "distance"), 

448 "width": _numeric_property("Membrane Width", "distance"), 

449 "feed_inlet_reynolds_number": _numeric_property( 

450 "Feed Inlet Reynolds Number", "dimensionless" 

451 ), 

452 "permeate_inlet_reynolds_number": _numeric_property( 

453 "Permeate Inlet Reynolds Number", "dimensionless" 

454 ), 

455 "volumetric_recovery": _numeric_property("Volumetric Recovery", "ratio"), 

456 "water_mass_recovery": _numeric_property("Water Mass Recovery", "ratio"), 

457 "tds_mass_recovery": _numeric_property("TDS Mass Recovery", "ratio"), 

458 "tds_observed_rejection": _numeric_property( 

459 "Observed Solute Rejection", "ratio" 

460 ), 

461 "average_water_mass_flux": _numeric_property( 

462 "Average Water Mass Flux", "massFlux" 

463 ), 

464 "average_tds_mass_flux": _numeric_property( 

465 "Average TDS Mass Flux", "massFlux" 

466 ), 

467 }, 

468 propertySetGroups={ 

469 "default": JsonPropertySetGroupConfig( 

470 type="stateVars", 

471 displayName="Properties", 

472 stateVars=[ 

473 "A_comp", 

474 "B_comp", 

475 "area", 

476 "structural_parameter", 

477 "feed_side.spacer_porosity", 

478 "feed_side.channel_height", 

479 "permeate_side.spacer_porosity", 

480 "permeate_side.channel_height", 

481 "length", 

482 ], 

483 toggle=None, 

484 ), 

485 }, 

486 keyProperties=[ 

487 "volumetric_recovery", 

488 "water_mass_recovery", 

489 "tds_mass_recovery", 

490 "tds_observed_rejection", 

491 "average_water_mass_flux", 

492 "feed_inlet_reynolds_number", 

493 "permeate_inlet_reynolds_number", 

494 ], 

495 splitterFractionName=None, 

496 idaesAdapter=JsonIdaesAdapterConfig( 

497 constructor=( 

498 "ahuora_builder.custom.watertap." 

499 "osmotically_assisted_reverse_osmosis_0d." 

500 "OsmoticallyAssistedReverseOsmosis0D" 

501 ), 

502 args={ 

503 "property_package": JsonAdapterArgConfig( 

504 kind="property_package", 

505 label=None, 

506 ), 

507 }, 

508 ports=None, 

509 properties=None, 

510 ), 

511 frontend=JsonFrontendConfig(showInPanel=True, variant=None), 

512 )