Coverage for backend/ahuora-builder/src/ahuora_builder/custom/salt/crystallizer.py: 65%

208 statements  

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

1"""Generic indirect-heated evaporator / crystallizer unit model for IDAES. 

2 

3This unit is intended for a *process-side* property package with liquid, 

4vapor, and solid phases named ``Liq``, ``Vap``, and ``Sol``. It assumes the 

5process state block exposes: 

6 

7* ``flow_mass_phase_comp``, 

8* ``temperature``, 

9* ``pressure``, and 

10* ``get_enthalpy_flow_terms(phase)``. 

11 

12The unit also contains a *utility side* with an inlet and outlet state pair. 

13This is aimed at steam / condensate service and works naturally with 

14IDAES Helmholtz/IAPWS pressure-enthalpy states on ``flow_mol``, ``pressure``, 

15and ``enth_mol`` state variables. 

16 

17Model structure 

18--------------- 

191. One brine/feed inlet. 

202. One concentrate outlet that contains the liquid + solid phases. 

213. One vapor outlet that contains the boiled-off vapor phase. 

224. Phase equilibrium is enforced directly on both process-side outlet states by 

23 the process property package, while unit-level balances couple the outlets. 

245. One utility inlet and one utility outlet linked by enthalpy drop. 

25 

26The model does *not* include an explicit UA/LMTD heat-transfer equation. 

27Instead, it represents an ideal indirect heater where the utility-side 

28enthalpy decrease exactly matches the process-side heat duty. To close the 

29model, the user must provide one thermal specification, for example by fixing 

30``heat_duty[t]``, fixing a utility-outlet state variable (such as outlet 

31enthalpy or vapor fraction), or adding an external UA/LMTD-style relation in 

32an enclosing flowsheet. 

33""" 

34 

35from copy import deepcopy 

36 

37from pyomo.common.config import Bool, ConfigBlock, ConfigValue 

38from pyomo.environ import Constraint, Expression, Param, Set, Var, check_optimal_termination 

39from pyomo.environ import units as pyunits 

40from pyomo.core.base.component import Component 

41import pyomo.environ as pyo 

42 

43from idaes.core import UnitModelBlockData, declare_process_block_class, useDefault 

44from idaes.core.util.config import is_physical_parameter_block 

45from idaes.core.util.initialization import fix_state_vars, revert_state_vars 

46from idaes.core.util.model_statistics import degrees_of_freedom 

47from idaes.core.util.tables import create_stream_table_dataframe 

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

49import idaes.core.util.scaling as iscale 

50import idaes.logger as idaeslog 

51from ahuora_builder.state_args import extract_state_args 

52 

53try: # WaterTAP installs a compatible convenience wrapper. 

54 from watertap.core.solvers import get_solver 

55except ImportError: # pragma: no cover - fallback when WaterTAP is absent. 

56 from idaes.core.solvers import get_solver 

57 

58 

59_log = idaeslog.getLogger(__name__) 

60 

61 

62@declare_process_block_class("Crystallizer") 

63class CrystallizerData(UnitModelBlockData): 

64 """Indirect-heated evaporator / crystallizer with explicit utility side.""" 

65 

66 LIQUID_PHASE = "Liq" 

67 VAPOR_PHASE = "Vap" 

68 SOLID_PHASE = "Sol" 

69 

70 CONFIG = UnitModelBlockData.CONFIG() 

71 

72 CONFIG.declare( 

73 "process_property_package", 

74 ConfigValue( 

75 default=useDefault, 

76 domain=is_physical_parameter_block, 

77 description="Process-side property package", 

78 ), 

79 ) 

80 CONFIG.declare( 

81 "process_property_package_args", 

82 ConfigBlock( 

83 implicit=True, 

84 description="Arguments used when constructing process-side state blocks", 

85 ), 

86 ) 

87 

88 CONFIG.declare( 

89 "utility_property_package", 

90 ConfigValue( 

91 default=useDefault, 

92 domain=is_physical_parameter_block, 

93 description="Utility-side property package", 

94 ), 

95 ) 

96 CONFIG.declare( 

97 "utility_property_package_args", 

98 ConfigBlock( 

99 implicit=True, 

100 description="Arguments used when constructing utility-side state blocks", 

101 ), 

102 ) 

103 

104 CONFIG.declare( 

105 "absent_phase_flow", 

106 ConfigValue( 

107 default=1e-6, 

108 domain=float, 

109 description="Small numeric flow assigned to excluded outlet phases", 

110 ), 

111 ) 

112 CONFIG.declare( 

113 "has_pressure_change", 

114 ConfigValue( 

115 default=False, 

116 domain=Bool, 

117 description="Whether to include explicit process- and utility-side dP vars", 

118 ), 

119 ) 

120 

121 def build(self): 

122 super().build() 

123 

124 process_pp = self.config.process_property_package 

125 utility_pp = self.config.utility_property_package 

126 

127 if process_pp is useDefault: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 raise ConfigurationError( 

129 f"{self.name}: process_property_package must be provided explicitly." 

130 ) 

131 

132 if utility_pp is useDefault: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true

133 raise ConfigurationError( 

134 f"{self.name}: utility_property_package must be provided explicitly." 

135 ) 

136 

137 missing_phases = [ 

138 p 

139 for p in ( 

140 self.LIQUID_PHASE, 

141 self.VAPOR_PHASE, 

142 self.SOLID_PHASE, 

143 ) 

144 if p not in process_pp.phase_list 

145 ] 

146 if missing_phases: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true

147 raise ConfigurationError( 

148 f"{self.name}: process property package is missing expected phases: " 

149 f"{missing_phases}. Available phases: {list(process_pp.phase_list)}" 

150 ) 

151 

152 time = self.flowsheet().time 

153 

154 self.absent_phase_flow = Param( 

155 initialize=self.config.absent_phase_flow, 

156 mutable=True, 

157 units=pyunits.dimensionless, 

158 doc="Small numeric flow used for outlet phases that are excluded by the separator", 

159 ) 

160 

161 

162 # ------------------------------------------------------------------ 

163 # Process-side state blocks 

164 proc_in_args = dict(**self.config.process_property_package_args) 

165 proc_in_args.setdefault("defined_state", True) 

166 proc_in_args.setdefault("has_phase_equilibrium", False) 

167 self.process_in = process_pp.build_state_block(time, **proc_in_args) 

168 

169 proc_sep_args = dict(**self.config.process_property_package_args) 

170 proc_sep_args.setdefault("defined_state", False) 

171 proc_sep_args.setdefault("has_phase_equilibrium", True) 

172 self.concentrate_state = process_pp.build_state_block(time, **proc_sep_args) 

173 self.vapor_state = process_pp.build_state_block(time, **proc_sep_args) 

174 

175 # ------------------------------------------------------------------ 

176 # Utility-side state blocks 

177 util_in_args = dict(**self.config.utility_property_package_args) 

178 util_in_args.setdefault("defined_state", True) 

179 self.utility_in = utility_pp.build_state_block(time, **util_in_args) 

180 

181 util_out_args = dict(**self.config.utility_property_package_args) 

182 util_out_args.setdefault("defined_state", False) 

183 self.utility_out = utility_pp.build_state_block(time, **util_out_args) 

184 

185 # ------------------------------------------------------------------ 

186 # Convenience references to variable units / phase-component indexing. 

187 t0 = time.first() 

188 self._phase_component_set = Set( 

189 initialize=list(self.concentrate_state[t0].flow_mass_phase_comp.keys()), 

190 dimen=2, 

191 ordered=True, 

192 doc="Phase-component pairs used in the process-side property package", 

193 ) 

194 

195 first_pc = next(iter(self._phase_component_set)) 

196 flow_ref = self.concentrate_state[t0].flow_mass_phase_comp[first_pc] 

197 flow_units = pyunits.get_units(flow_ref) 

198 self._process_flow_units = pyunits.dimensionless if flow_units is None else flow_units 

199 

200 # ------------------------------------------------------------------ 

201 # Ports 

202 self.add_port(name="feed_inlet", block=self.process_in) 

203 self.add_port(name="utility_inlet", block=self.utility_in) 

204 self.add_port(name="concentrate_outlet", block=self.concentrate_state) 

205 self.add_port(name="vapor_outlet", block=self.vapor_state) 

206 self.add_port(name="utility_outlet", block=self.utility_out) 

207 

208 # ------------------------------------------------------------------ 

209 # Unit variables and convenience expressions 

210 self.heat_duty = Var( 

211 time, 

212 initialize=1e5, 

213 units=pyunits.J / pyunits.s, 

214 doc="Heat transferred from the utility side to the process side", 

215 ) 

216 

217 self.deltaP_process = Var( 

218 time, 

219 initialize=0.0, 

220 units=pyunits.Pa, 

221 doc="Process-side pressure change, outlet - inlet", 

222 ) 

223 self.deltaP_utility = Var( 

224 time, 

225 initialize=0.0, 

226 units=pyunits.Pa, 

227 doc="Utility-side pressure change, outlet - inlet", 

228 ) 

229 

230 if not self.config.has_pressure_change: 230 ↛ 235line 230 didn't jump to line 235 because the condition on line 230 was always true

231 for t in time: 

232 self.deltaP_process[t].fix(0.0) 

233 self.deltaP_utility[t].fix(0.0) 

234 

235 self.utility_enthalpy_drop = Expression( 

236 time, 

237 rule=lambda b, t: b.utility_in[t].flow_mol * b.utility_in[t].enth_mol 

238 - b.utility_out[t].flow_mol * b.utility_out[t].enth_mol, 

239 doc="Utility-side enthalpy flow decrease", 

240 ) 

241 

242 self.concentrate_liquid_phase_flow = Expression( 

243 time, 

244 rule=lambda b, t: sum( 

245 b.concentrate_state[t].flow_mass_phase_comp[p, j] 

246 for p, j in b._phase_component_set 

247 if p == b.LIQUID_PHASE 

248 ), 

249 doc="Total liquid-phase flow in the concentrate outlet", 

250 ) 

251 self.concentrate_solid_phase_flow = Expression( 

252 time, 

253 rule=lambda b, t: sum( 

254 b.concentrate_state[t].flow_mass_phase_comp[p, j] 

255 for p, j in b._phase_component_set 

256 if p == b.SOLID_PHASE 

257 ), 

258 doc="Total solid-phase flow in the concentrate outlet", 

259 ) 

260 self.vapor_phase_flow = Expression( 

261 time, 

262 rule=lambda b, t: sum( 

263 b.vapor_state[t].flow_mass_phase_comp[p, j] 

264 for p, j in b._phase_component_set 

265 if p == b.VAPOR_PHASE 

266 ), 

267 doc="Total vapor-phase flow in the vapor outlet", 

268 ) 

269 

270 # ------------------------------------------------------------------ 

271 # Process-side balances 

272 @self.Constraint(time, process_pp.component_list, doc="Total component balances") 

273 def process_component_balances(b, t, j): 

274 return sum( 

275 b.process_in[t].flow_mass_phase_comp[p, j] 

276 for p in process_pp.phase_list 

277 if (p, j) in b.process_in[t].phase_component_set 

278 ) == sum( 

279 b.concentrate_state[t].flow_mass_phase_comp[p, j] 

280 for p in (b.LIQUID_PHASE, b.SOLID_PHASE) 

281 if (p, j) in b.concentrate_state[t].phase_component_set 

282 ) + sum( 

283 b.vapor_state[t].flow_mass_phase_comp[p, j] 

284 for p in (b.VAPOR_PHASE,) 

285 if (p, j) in b.vapor_state[t].phase_component_set 

286 ) 

287 

288 @self.Constraint(time, doc="Process-side total enthalpy balance") 

289 def process_energy_balance(b, t): 

290 return sum( 

291 b.concentrate_state[t].get_enthalpy_flow_terms(p) 

292 for p in (b.LIQUID_PHASE, b.SOLID_PHASE) 

293 if p in process_pp.phase_list 

294 ) + sum( 

295 b.vapor_state[t].get_enthalpy_flow_terms(p) 

296 for p in (b.VAPOR_PHASE,) 

297 if p in process_pp.phase_list 

298 ) == sum( 

299 b.process_in[t].get_enthalpy_flow_terms(p) 

300 for p in process_pp.phase_list 

301 ) + b.heat_duty[t] 

302 

303 @self.Constraint(time, doc="Concentrate pressure balance") 

304 def process_pressure_balance(b, t): 

305 return ( 

306 b.concentrate_state[t].pressure 

307 == b.process_in[t].pressure + b.deltaP_process[t] 

308 ) 

309 

310 @self.Constraint(time, doc="Outlet pressure equality") 

311 def outlet_pressure_equality(b, t): 

312 return b.vapor_state[t].pressure == b.concentrate_state[t].pressure 

313 

314 @self.Constraint(time, doc="Outlet temperature equality") 

315 def outlet_temperature_equality(b, t): 

316 return b.vapor_state[t].temperature == b.concentrate_state[t].temperature 

317 

318 # ------------------------------------------------------------------ 

319 # Utility-side balances 

320 @self.Constraint(time, doc="Utility total-flow continuity") 

321 def utility_flow_balance(b, t): 

322 return b.utility_out[t].flow_mol == b.utility_in[t].flow_mol 

323 

324 @self.Constraint(time, doc="Utility pressure balance") 

325 def utility_pressure_balance(b, t): 

326 return b.utility_out[t].pressure == b.utility_in[t].pressure + b.deltaP_utility[t] 

327 

328 @self.Constraint(time, doc="Utility-to-process heat balance") 

329 def utility_energy_balance(b, t): 

330 return b.heat_duty[t] == b.utility_enthalpy_drop[t] 

331 

332 # ------------------------------------------------------------------ 

333 # Direct outlet phase exclusions. The active phases in each outlet are 

334 # determined by the property package, but the stream identity is 

335 # enforced here by excluding phases that do not belong in that outlet. 

336 @self.Constraint( 

337 time, 

338 doc="Exclude vapor and other non-concentrate phases from the concentrate outlet", 

339 ) 

340 def eq_concentrate_phase_exclusion(b, t,): 

341 return ( 

342 (sum(b.concentrate_state[t].flow_mass_phase_comp[p, j] 

343 for p, j in b._phase_component_set 

344 if p != b.LIQUID_PHASE and p != b.SOLID_PHASE 

345 ) 

346 - b.absent_phase_flow * b._process_flow_units 

347 )/1e5 # Scale this down to avoid numerical issues. 

348 == 0 

349 ) 

350 

351 @self.Constraint( 

352 time, 

353 doc="Exclude liquid and solid phases from the vapor outlet", 

354 ) 

355 def eq_vapor_phase_exclusion(b, t,): 

356 return ( 

357 (sum(b.vapor_state[t].flow_mass_phase_comp[p, j] 

358 for p, j in b._phase_component_set 

359 if p != b.VAPOR_PHASE 

360 ) 

361 - b.absent_phase_flow * b._process_flow_units 

362 )/1e5 # Scale this down to avoid numerical issues. 

363 == 0 

364 ) 

365 

366 

367 

368 # ------------------------------------------------------------------ 

369 # Initialization and reporting 

370 def initialize_build( 

371 self, 

372 process_state_args=None, 

373 utility_state_args=None, 

374 outlvl=idaeslog.NOTSET, 

375 solver=None, 

376 optarg=None, 

377 ): 

378 """Initialize the unit model. 

379 

380 The routine initializes the process and utility inlet states, copies 

381 those states to outlet-side state blocks as seeds, and then solves the 

382 full unit model. 

383 """ 

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

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

386 opt = get_solver(solver, optarg) 

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

388 outlvl=idaeslog.DEBUG 

389 process_flags = self.process_in.initialize( 

390 state_args=process_state_args, 

391 hold_state=True, 

392 outlvl=outlvl, 

393 solver=solver, 

394 optarg=optarg, 

395 ) 

396 utility_flags = self.utility_in.initialize( 

397 state_args=utility_state_args, 

398 hold_state=True, 

399 outlvl=outlvl, 

400 solver=solver, 

401 optarg=optarg, 

402 ) 

403 init_log.info_high("Initialization Step 1: inlet states initialized.") 

404 

405 if process_state_args is None: 405 ↛ 407line 405 didn't jump to line 407 because the condition on line 405 was always true

406 process_state_args = extract_state_args(self.process_in[t0]) 

407 if utility_state_args is None: 407 ↛ 410line 407 didn't jump to line 410 because the condition on line 407 was always true

408 utility_state_args = extract_state_args(self.utility_in[t0]) 

409 

410 self.concentrate_state.initialize( 

411 state_args=deepcopy(process_state_args), 

412 hold_state=False, 

413 outlvl=outlvl, 

414 solver=solver, 

415 optarg=optarg, 

416 ) 

417 self.vapor_state.initialize( 

418 state_args=deepcopy(process_state_args), 

419 hold_state=False, 

420 outlvl=outlvl, 

421 solver=solver, 

422 optarg=optarg, 

423 ) 

424 

425 self.utility_out.initialize( 

426 state_args=deepcopy(utility_state_args), 

427 hold_state=False, 

428 outlvl=outlvl, 

429 solver=solver, 

430 optarg=optarg, 

431 ) 

432 init_log.info_high("Initialization Step 2: outlet states seeded.") 

433 

434 if degrees_of_freedom(self) != 0: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true

435 raise InitializationError( 

436 f"{self.name}: degrees of freedom are {degrees_of_freedom(self)} during initialization; expected 0. " 

437 "Fix heat_duty, fix a utility-outlet state, or add an external heat-transfer relation." 

438 ) 

439 

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

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

442 init_log.info_high(f"Initialization Step 3: {idaeslog.condition(res)}.") 

443 

444 self.process_in.release_state(process_flags, outlvl=outlvl) 

445 self.utility_in.release_state(utility_flags, outlvl=outlvl) 

446 

447 if not check_optimal_termination(res): 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 raise InitializationError( 

449 f"{self.name} failed to initialize successfully; solver did not terminate optimally." 

450 ) 

451 

452 init_log.info("Initialization complete.") 

453 

454 def _get_stream_table_contents(self, time_point=0): 

455 return create_stream_table_dataframe( 

456 { 

457 "Feed Inlet": self.feed_inlet, 

458 "Utility Inlet": self.utility_inlet, 

459 "Concentrate Outlet": self.concentrate_outlet, 

460 "Vapor Outlet": self.vapor_outlet, 

461 "Utility Outlet": self.utility_outlet, 

462 }, 

463 time_point=time_point, 

464 ) 

465 

466 def _get_performance_contents(self, time_point=0): 

467 data = { 

468 "Heat Duty": self.heat_duty[time_point], 

469 # "Utility Enthalpy Drop": self.utility_enthalpy_drop[time_point], 

470 # "Concentrate Liquid-Phase Flow": self.concentrate_liquid_phase_flow[time_point], 

471 # "Concentrate Solid-Phase Flow": self.concentrate_solid_phase_flow[time_point], 

472 # "Vapor Outlet Flow": self.vapor_phase_flow[time_point], 

473 "Process dP": self.deltaP_process[time_point], 

474 "Utility dP": self.deltaP_utility[time_point], 

475 } 

476 

477 return {"vars": data} 

478 

479 def calculate_scaling_factors(self): 

480 super().calculate_scaling_factors() 

481 

482 process_pp = self.config.process_property_package 

483 

484 for t in self.flowsheet().time: 

485 if iscale.get_scaling_factor(self.heat_duty[t]) is None: 

486 sf_flow = iscale.get_scaling_factor( 

487 self.utility_in[t].flow_mol, 

488 default=1.0, 

489 ) 

490 sf_enth = iscale.get_scaling_factor( 

491 self.utility_in[t].enth_mol, 

492 default=1e-4, 

493 ) 

494 iscale.set_scaling_factor(self.heat_duty[t], sf_flow * sf_enth) 

495 

496 if iscale.get_scaling_factor(self.deltaP_process[t]) is None: 

497 iscale.set_scaling_factor(self.deltaP_process[t], 1e-5) 

498 if iscale.get_scaling_factor(self.deltaP_utility[t]) is None: 

499 iscale.set_scaling_factor(self.deltaP_utility[t], 1e-5) 

500 

501 iscale.constraint_scaling_transform( 

502 self.process_energy_balance[t], 

503 iscale.get_scaling_factor(self.heat_duty[t], default=1e-5), 

504 overwrite=False, 

505 ) 

506 iscale.constraint_scaling_transform( 

507 self.utility_energy_balance[t], 

508 iscale.get_scaling_factor(self.heat_duty[t], default=1e-5), 

509 overwrite=False, 

510 ) 

511 iscale.constraint_scaling_transform( 

512 self.process_pressure_balance[t], 

513 iscale.get_scaling_factor(self.deltaP_process[t], default=1e-5), 

514 overwrite=False, 

515 ) 

516 iscale.constraint_scaling_transform( 

517 self.outlet_pressure_equality[t], 

518 iscale.get_scaling_factor(self.deltaP_process[t], default=1e-5), 

519 overwrite=False, 

520 ) 

521 iscale.constraint_scaling_transform( 

522 self.outlet_temperature_equality[t], 

523 iscale.get_scaling_factor(self.concentrate_state[t].temperature, default=1e-2), 

524 overwrite=False, 

525 ) 

526 iscale.constraint_scaling_transform( 

527 self.utility_pressure_balance[t], 

528 iscale.get_scaling_factor(self.deltaP_utility[t], default=1e-5), 

529 overwrite=False, 

530 ) 

531 

532 for j in process_pp.component_list: 

533 sf_j = 1.0 

534 for p in process_pp.phase_list: 

535 if (p, j) in self.process_in[t].phase_component_set: 

536 sf_j = iscale.get_scaling_factor( 

537 self.process_in[t].flow_mass_phase_comp[p, j], default=1.0 

538 ) 

539 break 

540 iscale.constraint_scaling_transform( 

541 self.process_component_balances[t, j], sf_j, overwrite=False 

542 ) 

543 

544 for t, c in self.eq_concentrate_phase_exclusion.items(): 

545 sf = 1.0 

546 for p, j in self._phase_component_set: 

547 if p not in (self.LIQUID_PHASE, self.SOLID_PHASE): 

548 sf = iscale.get_scaling_factor( 

549 self.concentrate_state[t].flow_mass_phase_comp[p, j], 

550 default=1.0, 

551 ) 

552 break 

553 iscale.constraint_scaling_transform(c, sf, overwrite=False) 

554 

555 for t, c in self.eq_vapor_phase_exclusion.items(): 

556 sf = 1.0 

557 for p, j in self._phase_component_set: 

558 if p != self.VAPOR_PHASE: 

559 sf = iscale.get_scaling_factor( 

560 self.vapor_state[t].flow_mass_phase_comp[p, j], 

561 default=1.0, 

562 ) 

563 break 

564 iscale.constraint_scaling_transform(c, sf, overwrite=False) 

565 

566 

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

568 """ 

569 Test a few common issues with the heat exchanger model and provide hints to the user. 

570 returns a list with the variable the it is most relevant to and a message describing the issue 

571 """ 

572 problems = [] 

573 utility_vap_frac = pyo.value(self.utility_in[0].vapor_frac) or -1 

574 if utility_vap_frac < 0.1: 574 ↛ 583line 574 didn't jump to line 583 because the condition on line 574 was always true

575 problems.append( 

576 ( 

577 self.utility_in[0].vapor_frac, 

578 f"""Utility inlet vapor fraction is {utility_vap_frac:.2f}.  

579 This model is intended for steam/condensate service; if there is no steam it is unlikely you will have 

580 sufficient driving force for heat transfer.""" 

581 ) 

582 ) 

583 process_vap_frac = pyo.value(self.process_in[0].vapor_frac) or -1 

584 if process_vap_frac > 0.9: 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true

585 problems.append( 

586 ( 

587 self.process_in[0].vapor_frac, 

588 f"""Process inlet vapor fraction is {process_vap_frac:.2f}.  

589 There is not much to evaporate; this model requires liquid to be present in the feedstock.""" 

590 ) 

591 ) 

592 vap_flow = pyo.value(self.vapor_state[0].flow_mass) or -1 

593 if vap_flow < 0.1: 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true

594 problems.append( 

595 ( 

596 self.vapor_state[0].flow_mass, 

597 f"""Vapor flow is {vap_flow:.2f}.  

598 This model requires a sufficient vapor flow for proper operation.  

599 Perhaps there is not enough heat to create vapor?""" 

600 ) 

601 ) 

602 flow_in = pyo.value(self.process_in[0].flow_mass) or -1 

603 utility_flow_in = pyo.value(self.utility_in[0].flow_mass) or -1 

604 flow_ratio = flow_in / utility_flow_in 

605 if flow_ratio > 1000 or flow_ratio < 0.001: 605 ↛ 606line 605 didn't jump to line 606 because the condition on line 605 was never true

606 problems.append( 

607 ( 

608 self.utility_in[0].flow_mol, 

609 f"""Process inlet mass flow is {flow_in:.2f} kg/s, while utility inlet molar flow is {utility_flow_in:.2f} mol/s,  

610 giving a flow ratio of {flow_ratio:.2f}.  

611 This model assumes the utility is steam; if the utility flow is very low compared to the process flow,  

612 you may not have enough heat transfer driving force for the model to work well.""" 

613 ) 

614 ) 

615 

616 utility_temp_out = pyo.value(self.utility_out[0].temperature) or 0 

617 if utility_temp_out < 280: 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true

618 problems.append( 

619 ( 

620 self.utility_out[0].temperature, 

621 f"""Utility outlet temperature is {utility_temp_out:.2f} K.  

622 This probably means there is insufficient utility flow or temperature to provide the necessary heat duty.""" 

623 ) 

624 ) 

625 

626 utility_temp_in = pyo.value(self.utility_in[0].temperature) or 0 

627 if utility_temp_in < utility_temp_out: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 problems.append( 

629 ( 

630 self.utility_in[0].temperature, 

631 f"""Utility inlet temperature is {utility_temp_in:.2f} K, which is less than the outlet temperature of {utility_temp_out:.2f} K.  

632 This is not physically consistent; check your utility inlet conditions. Are you trying to cool the process instead of heat it? This model is intended for heating applications with steam as the utility.""" 

633 ) 

634 ) 

635 

636 return problems 

637 

638 @staticmethod 

639 def ahuora_metadata(): 

640 from ahuora_unit_ops.json_config import ( 

641 JsonAdapterArgConfig, 

642 JsonFrontendConfig, 

643 JsonGraphicObjectConfig, 

644 JsonIdaesAdapterConfig, 

645 JsonPortConfig, 

646 JsonPropertyConfig, 

647 JsonPropertySetGroupConfig, 

648 JsonUnitOpConfig, 

649 ) 

650 

651 return JsonUnitOpConfig( 

652 key='crystallizer', 

653 objectType='crystallizer', 

654 enumMember='Crystallizer', 

655 displayType='Crystallizer', 

656 displayName='Crystallizer', 

657 categoryPath=['chemical', 'separation'], 

658 ports={ 

659 'feed_inlet': JsonPortConfig( 

660 displayName='Feed Inlet', 

661 type='inlet', 

662 streamType='stream', 

663 many=False, 

664 default=1, 

665 minimum=1, 

666 makeStream=True, 

667 streamOffset=0.75, 

668 streamName='Feed S', 

669 ), 

670 'utility_inlet': JsonPortConfig( 

671 displayName='Utility Inlet', 

672 type='inlet', 

673 streamType='stream', 

674 many=False, 

675 default=1, 

676 minimum=1, 

677 makeStream=True, 

678 streamOffset=0.75, 

679 streamName='Utility S', 

680 ), 

681 'concentrate_outlet': JsonPortConfig( 

682 displayName='Concentrate Outlet', 

683 type='outlet', 

684 streamType='stream', 

685 many=False, 

686 default=1, 

687 minimum=1, 

688 makeStream=True, 

689 streamOffset=0.75, 

690 streamName='Concentrate S', 

691 ), 

692 'vapor_outlet': JsonPortConfig( 

693 displayName='Vapor Outlet', 

694 type='outlet', 

695 streamType='stream', 

696 many=False, 

697 default=1, 

698 minimum=1, 

699 makeStream=True, 

700 streamOffset=0.75, 

701 streamName='Vapor S', 

702 ), 

703 'utility_outlet': JsonPortConfig( 

704 displayName='Utility Outlet', 

705 type='outlet', 

706 streamType='stream', 

707 many=False, 

708 default=1, 

709 minimum=1, 

710 makeStream=True, 

711 streamOffset=0.75, 

712 streamName='Utility S', 

713 ), 

714 }, 

715 propertyPackagePorts={ 

716 'Process': ['feed_inlet', 'concentrate_outlet', 'vapor_outlet'], 

717 'Utility': ['utility_inlet', 'utility_outlet'], 

718 }, 

719 graphicObject=JsonGraphicObjectConfig( 

720 kind='unitop_graphic', 

721 ), 

722 indexSets=[], 

723 properties={ 

724 'heat_duty': JsonPropertyConfig( 

725 propertySetGroup='default', 

726 displayName='Heat Duty', 

727 indexSets=None, 

728 sumToOne=False, 

729 value=None, 

730 unit=None, 

731 unitType='heatflow', 

732 description=None, 

733 type='numeric', 

734 many=False, 

735 default=1, 

736 options={}, 

737 hasTimeIndex=True, 

738 ), 

739 'deltaP_process': JsonPropertyConfig( 

740 propertySetGroup='pressure_change', 

741 displayName='Process Pressure Change', 

742 indexSets=None, 

743 sumToOne=False, 

744 value=None, 

745 unit=None, 

746 unitType='pressure', 

747 description=None, 

748 type='numeric', 

749 many=False, 

750 default=1, 

751 options={}, 

752 hasTimeIndex=True, 

753 ), 

754 'deltaP_utility': JsonPropertyConfig( 

755 propertySetGroup='pressure_change', 

756 displayName='Utility Pressure Change', 

757 indexSets=None, 

758 sumToOne=False, 

759 value=None, 

760 unit=None, 

761 unitType='pressure', 

762 description=None, 

763 type='numeric', 

764 many=False, 

765 default=1, 

766 options={}, 

767 hasTimeIndex=True, 

768 ), 

769 'utility_enthalpy_drop': JsonPropertyConfig( 

770 propertySetGroup='default', 

771 displayName='Utility Enthalpy Drop', 

772 indexSets=None, 

773 sumToOne=False, 

774 value=None, 

775 unit=None, 

776 unitType='heatflow', 

777 description=None, 

778 type='numeric', 

779 many=False, 

780 default=1, 

781 options={}, 

782 hasTimeIndex=True, 

783 ), 

784 'concentrate_liquid_phase_flow': JsonPropertyConfig( 

785 propertySetGroup='default', 

786 displayName='Concentrate Liquid Flow', 

787 indexSets=None, 

788 sumToOne=False, 

789 value=None, 

790 unit=None, 

791 unitType='massflow', 

792 description=None, 

793 type='numeric', 

794 many=False, 

795 default=1, 

796 options={}, 

797 hasTimeIndex=True, 

798 ), 

799 'concentrate_solid_phase_flow': JsonPropertyConfig( 

800 propertySetGroup='default', 

801 displayName='Concentrate Solid Flow', 

802 indexSets=None, 

803 sumToOne=False, 

804 value=None, 

805 unit=None, 

806 unitType='massflow', 

807 description=None, 

808 type='numeric', 

809 many=False, 

810 default=1, 

811 options={}, 

812 hasTimeIndex=True, 

813 ), 

814 'vapor_phase_flow': JsonPropertyConfig( 

815 propertySetGroup='default', 

816 displayName='Vapor Flow', 

817 indexSets=None, 

818 sumToOne=False, 

819 value=None, 

820 unit=None, 

821 unitType='massflow', 

822 description=None, 

823 type='numeric', 

824 many=False, 

825 default=1, 

826 options={}, 

827 hasTimeIndex=True, 

828 ), 

829 'has_pressure_change': JsonPropertyConfig( 

830 propertySetGroup='pressure_change', 

831 displayName='Enable Pressure Change', 

832 indexSets=None, 

833 sumToOne=False, 

834 value=False, 

835 unit=None, 

836 unitType='dimensionless', 

837 description=None, 

838 type='checkbox', 

839 many=False, 

840 default=1, 

841 options={}, 

842 hasTimeIndex=True, 

843 ), 

844 }, 

845 propertySetGroups={ 

846 'default': JsonPropertySetGroupConfig( 

847 type='stateVars', 

848 displayName='Properties', 

849 stateVars=['heat_duty'], 

850 toggle=None, 

851 ), 

852 'pressure_change': JsonPropertySetGroupConfig( 

853 type='stateVars', 

854 displayName='Pressure Change', 

855 stateVars=['has_pressure_change', 'deltaP_process', 'deltaP_utility'], 

856 toggle='has_pressure_change', 

857 ), 

858 }, 

859 keyProperties=[ 

860 'utility_enthalpy_drop', 

861 'concentrate_liquid_phase_flow', 

862 'concentrate_solid_phase_flow', 

863 'vapor_phase_flow', 

864 ], 

865 splitterFractionName=None, 

866 idaesAdapter=JsonIdaesAdapterConfig( 

867 constructor='ahuora_builder.custom.salt.crystallizer.Crystallizer', 

868 args={ 

869 'process_property_package': JsonAdapterArgConfig( 

870 kind='property_package', 

871 label='Process', 

872 ), 

873 'utility_property_package': JsonAdapterArgConfig( 

874 kind='property_package', 

875 label='Utility', 

876 ), 

877 'has_pressure_change': JsonAdapterArgConfig( 

878 kind='toggle', 

879 property='has_pressure_change', 

880 ), 

881 }, 

882 ports=None, 

883 properties=None, 

884 ), 

885 frontend=JsonFrontendConfig( 

886 showInPanel=True, 

887 variant=None, 

888 ), 

889 )