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
« 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)
13from ahuora_builder.methods.scaling_suffix import sanitize_scaling_suffix
16@declare_process_block_class("OsmoticallyAssistedReverseOsmosis0D")
17class OsmoticallyAssistedReverseOsmosis0DData(OsmoticallyAssistedReverseOsmosisData):
18 """
19 Ahuora wrapper around WaterTAP's Osmotically Assisted Reverse Osmosis 0D model.
20 """
22 def build(self):
23 super().build()
24 time = self.flowsheet().time
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 )
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)
74 def diagnose(self) -> list[tuple[Component, str]]:
75 """
76 Return user-facing hints for common OARO specification issues.
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]] = []
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 )
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 )
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 )
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 )
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 )
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 )
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."))
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."))
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 )
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 )
264 return problems
266 @staticmethod
267 def ahuora_metadata():
268 return _osmotically_assisted_reverse_osmosis_0d_metadata()
271def _safe_value(component) -> float | None:
272 try:
273 return value(component, exception=False)
274 except Exception:
275 return None
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
288def _is_specified(block, component) -> bool:
289 return getattr(component, "fixed", False) or _has_external_equality_spec(
290 block, component
291 )
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
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
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
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
320 return False
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
332def _numeric_property(display_name, unit_type, *, group="default"):
333 from ahuora_unit_ops.json_config import JsonPropertyConfig
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 )
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 )
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 )