fugacio-copilot 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/PKG-INFO +16 -11
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/README.md +13 -8
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/pyproject.toml +2 -2
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/__init__.py +1 -1
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/tools.py +258 -22
- fugacio_copilot-0.3.0/tests/test_flowsheet_tools.py +126 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/.gitignore +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/agent.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/dynamics_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/integration_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/__init__.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/anthropic.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/base.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/mock.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/openai.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/mpc_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/py.typed +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/report.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/saft_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_agent.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_copilot.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_design_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_dynamics_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_integration_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_llm_agent.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_mpc_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_nonideal_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_property_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_reaction_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_report.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_saft_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_steam_tools.py +0 -0
- {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: fugacio-copilot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Chemical-engineering design copilot/agent for the Fugacio stack.
|
|
5
5
|
Author-email: Owen Carey <37121709+owenthcarey@users.noreply.github.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -10,7 +10,7 @@ Classifier: Intended Audience :: Science/Research
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
12
12
|
Requires-Python: >=3.11
|
|
13
|
-
Requires-Dist: fugacio-sim==0.
|
|
13
|
+
Requires-Dist: fugacio-sim==0.3.0
|
|
14
14
|
Provides-Extra: anthropic
|
|
15
15
|
Requires-Dist: anthropic>=0.34; extra == 'anthropic'
|
|
16
16
|
Provides-Extra: llm
|
|
@@ -33,17 +33,22 @@ registry**, deterministic, JSON-in/JSON-out functions exposed with the same
|
|
|
33
33
|
function-calling schemas OpenAI/Anthropic expect:
|
|
34
34
|
|
|
35
35
|
- **Properties & equilibrium**: `list_components`, `component_properties`,
|
|
36
|
-
`saturation_pressure`, `bubble_pressure`, `flash_drum`.
|
|
36
|
+
`saturation_pressure`, `bubble_pressure`, `flash_drum`. The flash, the
|
|
37
|
+
heater, the columns, and the exchanger accept a `method` (`pr`, `srk`, `nrtl`,
|
|
38
|
+
`unifac`, `pcsaft`, `iapws`, ...) that selects the property package.
|
|
37
39
|
- **Molecular PC-SAFT**: `saft_flash`, `saft_density`, `saft_saturation_pressure`,
|
|
38
40
|
`saft_bubble_pressure`, and `saft_residual_enthalpy`, the molecular EOS preferred
|
|
39
41
|
for associating fluids (water, alcohols).
|
|
40
|
-
- **Unit operations**: `heat_exchanger
|
|
41
|
-
`
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- **
|
|
45
|
-
`
|
|
46
|
-
|
|
42
|
+
- **Unit operations**: `heat_exchanger` (one-sided heater/cooler),
|
|
43
|
+
`two_sided_heat_exchanger` (hot and cold streams, rigorous T-Q curves, one
|
|
44
|
+
closing spec), `compressor` (and turbine), `pump`, `valve`, each closing a
|
|
45
|
+
rigorous energy balance.
|
|
46
|
+
- **Distillation**: `shortcut_distillation` (Fenske-Underwood-Gilliland),
|
|
47
|
+
`rigorous_distillation` (simultaneous-correction MESH column with stage
|
|
48
|
+
energy balances, profiles, and duties), and `absorber`.
|
|
49
|
+
- **Gradient-based optimization**: `optimize_flash_temperature` differentiates
|
|
50
|
+
straight through the equilibrium flash; `optimize_column_reflux` imposes the
|
|
51
|
+
purity as a specification of the MESH column and reports the reflux it takes.
|
|
47
52
|
|
|
48
53
|
A model-agnostic **agent loop** (`run_agent`) drives plan→act→answer; the planner
|
|
49
54
|
is injected, so the loop is fully testable with a scripted planner while a real
|
|
@@ -11,17 +11,22 @@ registry**, deterministic, JSON-in/JSON-out functions exposed with the same
|
|
|
11
11
|
function-calling schemas OpenAI/Anthropic expect:
|
|
12
12
|
|
|
13
13
|
- **Properties & equilibrium**: `list_components`, `component_properties`,
|
|
14
|
-
`saturation_pressure`, `bubble_pressure`, `flash_drum`.
|
|
14
|
+
`saturation_pressure`, `bubble_pressure`, `flash_drum`. The flash, the
|
|
15
|
+
heater, the columns, and the exchanger accept a `method` (`pr`, `srk`, `nrtl`,
|
|
16
|
+
`unifac`, `pcsaft`, `iapws`, ...) that selects the property package.
|
|
15
17
|
- **Molecular PC-SAFT**: `saft_flash`, `saft_density`, `saft_saturation_pressure`,
|
|
16
18
|
`saft_bubble_pressure`, and `saft_residual_enthalpy`, the molecular EOS preferred
|
|
17
19
|
for associating fluids (water, alcohols).
|
|
18
|
-
- **Unit operations**: `heat_exchanger
|
|
19
|
-
`
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **
|
|
23
|
-
`
|
|
24
|
-
|
|
20
|
+
- **Unit operations**: `heat_exchanger` (one-sided heater/cooler),
|
|
21
|
+
`two_sided_heat_exchanger` (hot and cold streams, rigorous T-Q curves, one
|
|
22
|
+
closing spec), `compressor` (and turbine), `pump`, `valve`, each closing a
|
|
23
|
+
rigorous energy balance.
|
|
24
|
+
- **Distillation**: `shortcut_distillation` (Fenske-Underwood-Gilliland),
|
|
25
|
+
`rigorous_distillation` (simultaneous-correction MESH column with stage
|
|
26
|
+
energy balances, profiles, and duties), and `absorber`.
|
|
27
|
+
- **Gradient-based optimization**: `optimize_flash_temperature` differentiates
|
|
28
|
+
straight through the equilibrium flash; `optimize_column_reflux` imposes the
|
|
29
|
+
purity as a specification of the MESH column and reports the reflux it takes.
|
|
25
30
|
|
|
26
31
|
A model-agnostic **agent loop** (`run_agent`) drives plan→act→answer; the planner
|
|
27
32
|
is injected, so the loop is fully testable with a scripted planner while a real
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fugacio-copilot"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Chemical-engineering design copilot/agent for the Fugacio stack."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
"Topic :: Scientific/Engineering :: Chemistry",
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
|
-
"fugacio-sim==0.
|
|
25
|
+
"fugacio-sim==0.3.0",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[project.optional-dependencies]
|
|
@@ -26,8 +26,11 @@ from fugacio.copilot.integration_tools import integration_tool_specs
|
|
|
26
26
|
from fugacio.copilot.mpc_tools import mpc_tool_specs
|
|
27
27
|
from fugacio.copilot.saft_tools import saft_tool_specs
|
|
28
28
|
from fugacio.sim import (
|
|
29
|
+
METHODS,
|
|
29
30
|
STEAM_LEVELS,
|
|
31
|
+
ColumnFeed,
|
|
30
32
|
Stream,
|
|
33
|
+
absorber,
|
|
31
34
|
annualized_capital,
|
|
32
35
|
azeotrope_pressure,
|
|
33
36
|
azeotrope_temperature,
|
|
@@ -41,19 +44,21 @@ from fugacio.sim import (
|
|
|
41
44
|
cylinder_volume,
|
|
42
45
|
equilibrium_reactor,
|
|
43
46
|
flash_drum,
|
|
47
|
+
heat_exchanger,
|
|
44
48
|
heat_exchanger_area,
|
|
45
49
|
heater,
|
|
46
50
|
lmtd,
|
|
47
51
|
npv,
|
|
48
52
|
nrtl_model_for,
|
|
53
|
+
package_for,
|
|
49
54
|
pfr,
|
|
50
55
|
pump,
|
|
51
56
|
pxy_diagram,
|
|
52
57
|
reactive_flash,
|
|
53
58
|
relative_volatility,
|
|
54
59
|
residue_curve_map,
|
|
60
|
+
rigorous_column,
|
|
55
61
|
shortcut_column,
|
|
56
|
-
solve_column,
|
|
57
62
|
steam_heating,
|
|
58
63
|
steam_turbine,
|
|
59
64
|
stoichiometric_reactor,
|
|
@@ -66,6 +71,9 @@ from fugacio.sim import (
|
|
|
66
71
|
valve,
|
|
67
72
|
vapor_molar_volume_ideal,
|
|
68
73
|
)
|
|
74
|
+
from fugacio.sim import distillate_rate as _spec_distillate_rate
|
|
75
|
+
from fugacio.sim import purity as _spec_purity
|
|
76
|
+
from fugacio.sim import reflux_ratio as _spec_reflux_ratio
|
|
69
77
|
from fugacio.thermo import (
|
|
70
78
|
PR,
|
|
71
79
|
PowerLaw,
|
|
@@ -209,15 +217,25 @@ def _bubble_pressure(components: list[str], x: list[float], temperature: float)
|
|
|
209
217
|
}
|
|
210
218
|
|
|
211
219
|
|
|
220
|
+
def _package(components: list[str], method: str | None) -> Any:
|
|
221
|
+
"""Property package for ``method`` (``None`` keeps the Peng-Robinson default)."""
|
|
222
|
+
if method is None or method.lower() == "pr":
|
|
223
|
+
return None
|
|
224
|
+
if method.lower() not in METHODS:
|
|
225
|
+
raise ValueError(f"unknown thermodynamic method {method!r}; choose from {METHODS}")
|
|
226
|
+
return package_for(components, method.lower())
|
|
227
|
+
|
|
228
|
+
|
|
212
229
|
def _flash(
|
|
213
230
|
components: list[str],
|
|
214
231
|
z: list[float],
|
|
215
232
|
flow: float,
|
|
216
233
|
temperature: float,
|
|
217
234
|
pressure: float,
|
|
235
|
+
method: str | None = None,
|
|
218
236
|
) -> JsonDict:
|
|
219
237
|
feed = Stream.from_fractions(tuple(components), jnp.asarray(z), flow, temperature, pressure)
|
|
220
|
-
vapor, liquid = flash_drum(feed, temperature, pressure)
|
|
238
|
+
vapor, liquid = flash_drum(feed, temperature, pressure, model=_package(components, method))
|
|
221
239
|
return {
|
|
222
240
|
"vapor_fraction": float(vapor.total / feed.total),
|
|
223
241
|
"vapor": {"flow_mol_s": float(vapor.total), "composition": [float(v) for v in vapor.z]},
|
|
@@ -237,9 +255,10 @@ def _heat_exchanger(
|
|
|
237
255
|
pressure: float,
|
|
238
256
|
t_out: float | None = None,
|
|
239
257
|
duty: float | None = None,
|
|
258
|
+
method: str | None = None,
|
|
240
259
|
) -> JsonDict:
|
|
241
260
|
feed = _stream(components, z, flow, temperature, pressure)
|
|
242
|
-
res = heater(feed, t_out=t_out, duty=duty)
|
|
261
|
+
res = heater(feed, t_out=t_out, duty=duty, model=_package(components, method))
|
|
243
262
|
return {
|
|
244
263
|
"outlet_temperature_k": float(res.outlet.t),
|
|
245
264
|
"duty_w": float(res.duty),
|
|
@@ -358,6 +377,15 @@ def _shortcut_distillation(
|
|
|
358
377
|
}
|
|
359
378
|
|
|
360
379
|
|
|
380
|
+
def _stream_dict(s: Stream) -> JsonDict:
|
|
381
|
+
return {
|
|
382
|
+
"flow_mol_s": float(s.total),
|
|
383
|
+
"composition": [float(v) for v in s.z],
|
|
384
|
+
"temperature_k": float(s.t),
|
|
385
|
+
"pressure_pa": float(s.p),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
361
389
|
def _rigorous_distillation(
|
|
362
390
|
components: list[str],
|
|
363
391
|
z: list[float],
|
|
@@ -368,11 +396,28 @@ def _rigorous_distillation(
|
|
|
368
396
|
feed_stage: int,
|
|
369
397
|
reflux: float,
|
|
370
398
|
distillate_rate: float,
|
|
371
|
-
q: float =
|
|
399
|
+
q: float | None = None,
|
|
400
|
+
condenser: str = "total",
|
|
401
|
+
efficiency: float = 1.0,
|
|
402
|
+
method: str | None = None,
|
|
372
403
|
) -> JsonDict:
|
|
404
|
+
del q # the MESH model takes the feed state from its temperature and pressure
|
|
373
405
|
feed = _stream(components, z, feed_flow, feed_temperature, pressure)
|
|
374
|
-
res =
|
|
406
|
+
res = rigorous_column(
|
|
407
|
+
[ColumnFeed(feed, feed_stage)],
|
|
408
|
+
n_stages,
|
|
409
|
+
p=pressure,
|
|
410
|
+
condenser=condenser,
|
|
411
|
+
specs=[_spec_reflux_ratio(reflux), _spec_distillate_rate(distillate_rate)],
|
|
412
|
+
efficiency=efficiency,
|
|
413
|
+
model=_package(components, method),
|
|
414
|
+
)
|
|
375
415
|
return {
|
|
416
|
+
"converged": bool(res.residual_norm < 1e-6),
|
|
417
|
+
"residual_norm": float(res.residual_norm),
|
|
418
|
+
"stage_temperatures_k": [float(v) for v in res.t],
|
|
419
|
+
"liquid_flows_mol_s": [float(v) for v in res.liquid_flow],
|
|
420
|
+
"vapor_flows_mol_s": [float(v) for v in res.vapor_flow],
|
|
376
421
|
"distillate": {
|
|
377
422
|
"flow_mol_s": float(res.distillate.total),
|
|
378
423
|
"composition": [float(v) for v in res.distillate.z],
|
|
@@ -390,6 +435,83 @@ def _rigorous_distillation(
|
|
|
390
435
|
}
|
|
391
436
|
|
|
392
437
|
|
|
438
|
+
def _two_sided_heat_exchanger(
|
|
439
|
+
hot_components: list[str],
|
|
440
|
+
hot_z: list[float],
|
|
441
|
+
hot_flow: float,
|
|
442
|
+
hot_temperature: float,
|
|
443
|
+
hot_pressure: float,
|
|
444
|
+
cold_components: list[str],
|
|
445
|
+
cold_z: list[float],
|
|
446
|
+
cold_flow: float,
|
|
447
|
+
cold_temperature: float,
|
|
448
|
+
cold_pressure: float,
|
|
449
|
+
duty: float | None = None,
|
|
450
|
+
t_hot_out: float | None = None,
|
|
451
|
+
t_cold_out: float | None = None,
|
|
452
|
+
min_approach: float | None = None,
|
|
453
|
+
ua: float | None = None,
|
|
454
|
+
zones: int = 4,
|
|
455
|
+
flow: str = "counter",
|
|
456
|
+
hot_method: str | None = None,
|
|
457
|
+
cold_method: str | None = None,
|
|
458
|
+
) -> JsonDict:
|
|
459
|
+
hot = _stream(hot_components, hot_z, hot_flow, hot_temperature, hot_pressure)
|
|
460
|
+
cold = _stream(cold_components, cold_z, cold_flow, cold_temperature, cold_pressure)
|
|
461
|
+
res = heat_exchanger(
|
|
462
|
+
hot,
|
|
463
|
+
cold,
|
|
464
|
+
duty=duty,
|
|
465
|
+
t_hot_out=t_hot_out,
|
|
466
|
+
t_cold_out=t_cold_out,
|
|
467
|
+
min_approach=min_approach,
|
|
468
|
+
ua=ua,
|
|
469
|
+
zones=zones,
|
|
470
|
+
flow=flow,
|
|
471
|
+
model_hot=_package(hot_components, hot_method),
|
|
472
|
+
model_cold=_package(cold_components, cold_method),
|
|
473
|
+
)
|
|
474
|
+
return {
|
|
475
|
+
"duty_w": float(res.duty),
|
|
476
|
+
"ua_w_per_k": _finite(float(res.ua)),
|
|
477
|
+
"lmtd_k": _finite(float(res.lmtd)),
|
|
478
|
+
"approach_hot_end_k": float(res.approach_hot_end),
|
|
479
|
+
"approach_cold_end_k": float(res.approach_cold_end),
|
|
480
|
+
"min_approach_k": float(res.min_approach),
|
|
481
|
+
"hot_out": _stream_dict(res.hot_out),
|
|
482
|
+
"cold_out": _stream_dict(res.cold_out),
|
|
483
|
+
"hot_curve_k": [float(v) for v in res.hot_curve],
|
|
484
|
+
"cold_curve_k": [float(v) for v in res.cold_curve],
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _absorber(
|
|
489
|
+
components: list[str],
|
|
490
|
+
gas_z: list[float],
|
|
491
|
+
gas_flow: float,
|
|
492
|
+
gas_temperature: float,
|
|
493
|
+
solvent_z: list[float],
|
|
494
|
+
solvent_flow: float,
|
|
495
|
+
solvent_temperature: float,
|
|
496
|
+
pressure: float,
|
|
497
|
+
n_stages: int,
|
|
498
|
+
method: str | None = None,
|
|
499
|
+
) -> JsonDict:
|
|
500
|
+
gas = _stream(components, gas_z, gas_flow, gas_temperature, pressure)
|
|
501
|
+
solvent = _stream(components, solvent_z, solvent_flow, solvent_temperature, pressure)
|
|
502
|
+
res = absorber(gas, solvent, n_stages, p=pressure, model=_package(components, method))
|
|
503
|
+
absorbed = gas.n - res.distillate.n
|
|
504
|
+
return {
|
|
505
|
+
"converged": bool(res.residual_norm < 1e-6),
|
|
506
|
+
"treated_gas": _stream_dict(res.distillate),
|
|
507
|
+
"rich_solvent": _stream_dict(res.bottoms),
|
|
508
|
+
"fraction_absorbed": [
|
|
509
|
+
float(a / g) if float(g) > 0.0 else 0.0 for a, g in zip(absorbed, gas.n, strict=True)
|
|
510
|
+
],
|
|
511
|
+
"stage_temperatures_k": [float(v) for v in res.t],
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
393
515
|
def _safeguarded_newton(
|
|
394
516
|
f: Callable[[float], Any], lo: float, hi: float, iters: int = 40, tol: float = 1e-7
|
|
395
517
|
) -> float:
|
|
@@ -450,22 +572,29 @@ def _optimize_column_reflux(
|
|
|
450
572
|
distillate_rate: float,
|
|
451
573
|
light_key: int,
|
|
452
574
|
target_purity: float,
|
|
453
|
-
q: float =
|
|
454
|
-
iters: int =
|
|
575
|
+
q: float | None = None,
|
|
576
|
+
iters: int | None = None,
|
|
577
|
+
method: str | None = None,
|
|
455
578
|
) -> JsonDict:
|
|
579
|
+
del q, iters # the purity is imposed directly as a MESH specification
|
|
456
580
|
feed = _stream(components, z, feed_flow, feed_temperature, pressure)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
581
|
+
res = rigorous_column(
|
|
582
|
+
[ColumnFeed(feed, feed_stage)],
|
|
583
|
+
n_stages,
|
|
584
|
+
p=pressure,
|
|
585
|
+
specs=[
|
|
586
|
+
_spec_distillate_rate(distillate_rate),
|
|
587
|
+
_spec_purity("distillate", light_key, target_purity),
|
|
588
|
+
],
|
|
589
|
+
model=_package(components, method),
|
|
464
590
|
)
|
|
465
591
|
return {
|
|
466
|
-
"reflux_ratio":
|
|
467
|
-
"achieved_purity": float(
|
|
592
|
+
"reflux_ratio": float(res.reflux_ratio),
|
|
593
|
+
"achieved_purity": float(res.distillate.z[light_key]),
|
|
468
594
|
"target_purity": target_purity,
|
|
595
|
+
"converged": bool(res.residual_norm < 1e-6),
|
|
596
|
+
"condenser_duty_w": float(res.condenser_duty),
|
|
597
|
+
"reboiler_duty_w": float(res.reboiler_duty),
|
|
469
598
|
}
|
|
470
599
|
|
|
471
600
|
|
|
@@ -1335,6 +1464,11 @@ def default_registry() -> dict[str, ToolSpec]:
|
|
|
1335
1464
|
"flow": {"type": "number", "description": "Total feed flow (mol/s)"},
|
|
1336
1465
|
"temperature": {"type": "number"},
|
|
1337
1466
|
"pressure": {"type": "number"},
|
|
1467
|
+
"method": {
|
|
1468
|
+
"type": "string",
|
|
1469
|
+
"enum": list(METHODS),
|
|
1470
|
+
"description": "Thermodynamic method (default pr)",
|
|
1471
|
+
},
|
|
1338
1472
|
},
|
|
1339
1473
|
"required": ["components", "z", "flow", "temperature", "pressure"],
|
|
1340
1474
|
},
|
|
@@ -1356,11 +1490,63 @@ def default_registry() -> dict[str, ToolSpec]:
|
|
|
1356
1490
|
"pressure": {"type": "number"},
|
|
1357
1491
|
"t_out": {"type": "number", "description": "Target outlet temperature (K)"},
|
|
1358
1492
|
"duty": {"type": "number", "description": "Signed heat added (W)"},
|
|
1493
|
+
"method": {
|
|
1494
|
+
"type": "string",
|
|
1495
|
+
"enum": list(METHODS),
|
|
1496
|
+
"description": "Thermodynamic method (default pr)",
|
|
1497
|
+
},
|
|
1359
1498
|
},
|
|
1360
1499
|
"required": ["components", "z", "flow", "temperature", "pressure"],
|
|
1361
1500
|
},
|
|
1362
1501
|
run=_heat_exchanger,
|
|
1363
1502
|
),
|
|
1503
|
+
ToolSpec(
|
|
1504
|
+
name="two_sided_heat_exchanger",
|
|
1505
|
+
description=(
|
|
1506
|
+
"Countercurrent (or parallel) two-stream exchanger with rigorous enthalpy "
|
|
1507
|
+
"curves on both sides. Give exactly one closing spec: duty (W), t_hot_out, "
|
|
1508
|
+
"t_cold_out, min_approach (K), or ua (W/K). Returns duty, UA, LMTD, "
|
|
1509
|
+
"approaches, both outlets, and the T-Q curves. Each side may use its own "
|
|
1510
|
+
"method (e.g. iapws for steam, nrtl for aqueous)."
|
|
1511
|
+
),
|
|
1512
|
+
parameters={
|
|
1513
|
+
"type": "object",
|
|
1514
|
+
"properties": {
|
|
1515
|
+
"hot_components": {"type": "array", "items": {"type": "string"}},
|
|
1516
|
+
"hot_z": {"type": "array", "items": {"type": "number"}},
|
|
1517
|
+
"hot_flow": {"type": "number"},
|
|
1518
|
+
"hot_temperature": {"type": "number"},
|
|
1519
|
+
"hot_pressure": {"type": "number"},
|
|
1520
|
+
"cold_components": {"type": "array", "items": {"type": "string"}},
|
|
1521
|
+
"cold_z": {"type": "array", "items": {"type": "number"}},
|
|
1522
|
+
"cold_flow": {"type": "number"},
|
|
1523
|
+
"cold_temperature": {"type": "number"},
|
|
1524
|
+
"cold_pressure": {"type": "number"},
|
|
1525
|
+
"duty": {"type": "number"},
|
|
1526
|
+
"t_hot_out": {"type": "number"},
|
|
1527
|
+
"t_cold_out": {"type": "number"},
|
|
1528
|
+
"min_approach": {"type": "number"},
|
|
1529
|
+
"ua": {"type": "number"},
|
|
1530
|
+
"zones": {"type": "integer"},
|
|
1531
|
+
"flow": {"type": "string", "enum": ["counter", "parallel"]},
|
|
1532
|
+
"hot_method": {"type": "string", "enum": list(METHODS)},
|
|
1533
|
+
"cold_method": {"type": "string", "enum": list(METHODS)},
|
|
1534
|
+
},
|
|
1535
|
+
"required": [
|
|
1536
|
+
"hot_components",
|
|
1537
|
+
"hot_z",
|
|
1538
|
+
"hot_flow",
|
|
1539
|
+
"hot_temperature",
|
|
1540
|
+
"hot_pressure",
|
|
1541
|
+
"cold_components",
|
|
1542
|
+
"cold_z",
|
|
1543
|
+
"cold_flow",
|
|
1544
|
+
"cold_temperature",
|
|
1545
|
+
"cold_pressure",
|
|
1546
|
+
],
|
|
1547
|
+
},
|
|
1548
|
+
run=_two_sided_heat_exchanger,
|
|
1549
|
+
),
|
|
1364
1550
|
ToolSpec(
|
|
1365
1551
|
name="compressor",
|
|
1366
1552
|
description=(
|
|
@@ -1457,8 +1643,9 @@ def default_registry() -> dict[str, ToolSpec]:
|
|
|
1457
1643
|
ToolSpec(
|
|
1458
1644
|
name="rigorous_distillation",
|
|
1459
1645
|
description=(
|
|
1460
|
-
"Rigorous multistage column
|
|
1461
|
-
"
|
|
1646
|
+
"Rigorous multistage column: simultaneous-correction MESH (Naphtali-Sandholm) "
|
|
1647
|
+
"with full stage energy balances and the chosen property package; returns "
|
|
1648
|
+
"product streams, stage temperature and flow profiles, and column duties."
|
|
1462
1649
|
),
|
|
1463
1650
|
parameters={
|
|
1464
1651
|
"type": "object",
|
|
@@ -1472,7 +1659,13 @@ def default_registry() -> dict[str, ToolSpec]:
|
|
|
1472
1659
|
"feed_stage": {"type": "integer"},
|
|
1473
1660
|
"reflux": {"type": "number"},
|
|
1474
1661
|
"distillate_rate": {"type": "number"},
|
|
1475
|
-
"
|
|
1662
|
+
"condenser": {"type": "string", "enum": ["total", "partial"]},
|
|
1663
|
+
"efficiency": {"type": "number", "description": "Murphree efficiency"},
|
|
1664
|
+
"method": {
|
|
1665
|
+
"type": "string",
|
|
1666
|
+
"enum": list(METHODS),
|
|
1667
|
+
"description": "Thermodynamic method (default pr)",
|
|
1668
|
+
},
|
|
1476
1669
|
},
|
|
1477
1670
|
"required": [
|
|
1478
1671
|
"components",
|
|
@@ -1488,6 +1681,45 @@ def default_registry() -> dict[str, ToolSpec]:
|
|
|
1488
1681
|
},
|
|
1489
1682
|
run=_rigorous_distillation,
|
|
1490
1683
|
),
|
|
1684
|
+
ToolSpec(
|
|
1685
|
+
name="absorber",
|
|
1686
|
+
description=(
|
|
1687
|
+
"Countercurrent absorber (no condenser or reboiler): gas enters the bottom "
|
|
1688
|
+
"stage, lean solvent the top; returns treated gas, rich solvent, the "
|
|
1689
|
+
"fraction of each component absorbed, and the stage temperatures."
|
|
1690
|
+
),
|
|
1691
|
+
parameters={
|
|
1692
|
+
"type": "object",
|
|
1693
|
+
"properties": {
|
|
1694
|
+
"components": {"type": "array", "items": {"type": "string"}},
|
|
1695
|
+
"gas_z": {"type": "array", "items": {"type": "number"}},
|
|
1696
|
+
"gas_flow": {"type": "number"},
|
|
1697
|
+
"gas_temperature": {"type": "number"},
|
|
1698
|
+
"solvent_z": {"type": "array", "items": {"type": "number"}},
|
|
1699
|
+
"solvent_flow": {"type": "number"},
|
|
1700
|
+
"solvent_temperature": {"type": "number"},
|
|
1701
|
+
"pressure": {"type": "number"},
|
|
1702
|
+
"n_stages": {"type": "integer"},
|
|
1703
|
+
"method": {
|
|
1704
|
+
"type": "string",
|
|
1705
|
+
"enum": list(METHODS),
|
|
1706
|
+
"description": "Thermodynamic method (default pr)",
|
|
1707
|
+
},
|
|
1708
|
+
},
|
|
1709
|
+
"required": [
|
|
1710
|
+
"components",
|
|
1711
|
+
"gas_z",
|
|
1712
|
+
"gas_flow",
|
|
1713
|
+
"gas_temperature",
|
|
1714
|
+
"solvent_z",
|
|
1715
|
+
"solvent_flow",
|
|
1716
|
+
"solvent_temperature",
|
|
1717
|
+
"pressure",
|
|
1718
|
+
"n_stages",
|
|
1719
|
+
],
|
|
1720
|
+
},
|
|
1721
|
+
run=_absorber,
|
|
1722
|
+
),
|
|
1491
1723
|
ToolSpec(
|
|
1492
1724
|
name="optimize_flash_temperature",
|
|
1493
1725
|
description=(
|
|
@@ -1509,8 +1741,8 @@ def default_registry() -> dict[str, ToolSpec]:
|
|
|
1509
1741
|
ToolSpec(
|
|
1510
1742
|
name="optimize_column_reflux",
|
|
1511
1743
|
description=(
|
|
1512
|
-
"
|
|
1513
|
-
"
|
|
1744
|
+
"Reflux ratio that achieves a target distillate purity of the light key, "
|
|
1745
|
+
"imposed directly as a specification of the rigorous MESH column."
|
|
1514
1746
|
),
|
|
1515
1747
|
parameters={
|
|
1516
1748
|
"type": "object",
|
|
@@ -1525,7 +1757,11 @@ def default_registry() -> dict[str, ToolSpec]:
|
|
|
1525
1757
|
"distillate_rate": {"type": "number"},
|
|
1526
1758
|
"light_key": {"type": "integer"},
|
|
1527
1759
|
"target_purity": {"type": "number"},
|
|
1528
|
-
"
|
|
1760
|
+
"method": {
|
|
1761
|
+
"type": "string",
|
|
1762
|
+
"enum": list(METHODS),
|
|
1763
|
+
"description": "Thermodynamic method (default pr)",
|
|
1764
|
+
},
|
|
1529
1765
|
},
|
|
1530
1766
|
"required": [
|
|
1531
1767
|
"components",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Copilot tools backed by the property packages, the MESH column, and the exchanger."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from fugacio.copilot import call_tool, tool_schemas
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_new_flowsheet_tools_are_registered() -> None:
|
|
9
|
+
names = {s["name"] for s in tool_schemas()}
|
|
10
|
+
assert names >= {"two_sided_heat_exchanger", "absorber"}
|
|
11
|
+
by_name = {s["name"]: s for s in tool_schemas()}
|
|
12
|
+
for tool in ("flash_drum", "heat_exchanger", "rigorous_distillation", "absorber"):
|
|
13
|
+
assert "method" in by_name[tool]["parameters"]["properties"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_flash_drum_accepts_a_method() -> None:
|
|
17
|
+
base = {
|
|
18
|
+
"components": ["ethanol", "water"],
|
|
19
|
+
"z": [0.3, 0.7],
|
|
20
|
+
"flow": 10.0,
|
|
21
|
+
"temperature": 360.0,
|
|
22
|
+
"pressure": 1.013e5,
|
|
23
|
+
}
|
|
24
|
+
ideal = call_tool("flash_drum", base)
|
|
25
|
+
nrtl = call_tool("flash_drum", {**base, "method": "nrtl"})
|
|
26
|
+
assert 0.0 < nrtl["vapor_fraction"] < 1.0
|
|
27
|
+
# NRTL knows about the ethanol/water non-ideality; PR alone gives a different split.
|
|
28
|
+
assert nrtl["vapor"]["composition"][0] != pytest.approx(
|
|
29
|
+
ideal["vapor"]["composition"][0], abs=1e-3
|
|
30
|
+
)
|
|
31
|
+
with pytest.raises(ValueError, match="unknown thermodynamic method"):
|
|
32
|
+
call_tool("flash_drum", {**base, "method": "voodoo"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_rigorous_distillation_reports_profiles_and_converges() -> None:
|
|
36
|
+
result = call_tool(
|
|
37
|
+
"rigorous_distillation",
|
|
38
|
+
{
|
|
39
|
+
"components": ["benzene", "toluene"],
|
|
40
|
+
"z": [0.5, 0.5],
|
|
41
|
+
"feed_flow": 100.0,
|
|
42
|
+
"feed_temperature": 365.0,
|
|
43
|
+
"pressure": 1.013e5,
|
|
44
|
+
"n_stages": 12,
|
|
45
|
+
"feed_stage": 6,
|
|
46
|
+
"reflux": 2.5,
|
|
47
|
+
"distillate_rate": 50.0,
|
|
48
|
+
"method": "srk",
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
assert result["converged"]
|
|
52
|
+
assert len(result["stage_temperatures_k"]) == 12
|
|
53
|
+
assert result["stage_temperatures_k"][0] < result["stage_temperatures_k"][-1]
|
|
54
|
+
assert result["distillate"]["composition"][0] > 0.95
|
|
55
|
+
assert result["bottoms"]["composition"][1] > 0.95
|
|
56
|
+
assert result["condenser_duty_w"] < 0.0 < result["reboiler_duty_w"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_optimize_column_reflux_imposes_purity_spec() -> None:
|
|
60
|
+
result = call_tool(
|
|
61
|
+
"optimize_column_reflux",
|
|
62
|
+
{
|
|
63
|
+
"components": ["propane", "n-butane"],
|
|
64
|
+
"z": [0.5, 0.5],
|
|
65
|
+
"feed_flow": 100.0,
|
|
66
|
+
"feed_temperature": 320.0,
|
|
67
|
+
"pressure": 10e5,
|
|
68
|
+
"n_stages": 8,
|
|
69
|
+
"feed_stage": 4,
|
|
70
|
+
"distillate_rate": 50.0,
|
|
71
|
+
"light_key": 0,
|
|
72
|
+
"target_purity": 0.9,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
assert result["converged"]
|
|
76
|
+
assert result["achieved_purity"] == pytest.approx(0.9, abs=1e-6)
|
|
77
|
+
assert result["reflux_ratio"] > 0.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_two_sided_heat_exchanger_closes_energy_balance() -> None:
|
|
81
|
+
result = call_tool(
|
|
82
|
+
"two_sided_heat_exchanger",
|
|
83
|
+
{
|
|
84
|
+
"hot_components": ["n-hexane"],
|
|
85
|
+
"hot_z": [1.0],
|
|
86
|
+
"hot_flow": 20.0,
|
|
87
|
+
"hot_temperature": 380.0,
|
|
88
|
+
"hot_pressure": 5e5,
|
|
89
|
+
"cold_components": ["water"],
|
|
90
|
+
"cold_z": [1.0],
|
|
91
|
+
"cold_flow": 100.0,
|
|
92
|
+
"cold_temperature": 300.0,
|
|
93
|
+
"cold_pressure": 3e5,
|
|
94
|
+
"t_hot_out": 330.0,
|
|
95
|
+
"cold_method": "iapws",
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
assert result["duty_w"] > 0.0
|
|
99
|
+
assert result["hot_out"]["temperature_k"] == pytest.approx(330.0, abs=1e-6)
|
|
100
|
+
assert 300.0 < result["cold_out"]["temperature_k"] < 380.0
|
|
101
|
+
assert result["min_approach_k"] > 0.0
|
|
102
|
+
assert result["ua_w_per_k"] is not None and result["ua_w_per_k"] > 0.0
|
|
103
|
+
assert len(result["hot_curve_k"]) == len(result["cold_curve_k"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_absorber_tool_recovers_heavy_component() -> None:
|
|
107
|
+
result = call_tool(
|
|
108
|
+
"absorber",
|
|
109
|
+
{
|
|
110
|
+
"components": ["methane", "propane", "n-decane"],
|
|
111
|
+
"gas_z": [0.9, 0.1, 0.0],
|
|
112
|
+
"gas_flow": 100.0,
|
|
113
|
+
"gas_temperature": 300.0,
|
|
114
|
+
"solvent_z": [0.0, 0.0, 1.0],
|
|
115
|
+
"solvent_flow": 60.0,
|
|
116
|
+
"solvent_temperature": 300.0,
|
|
117
|
+
"pressure": 20e5,
|
|
118
|
+
"n_stages": 6,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
assert result["converged"]
|
|
122
|
+
assert result["fraction_absorbed"][1] > 0.5 # propane captured
|
|
123
|
+
assert result["fraction_absorbed"][0] < 0.2 # methane mostly passes through
|
|
124
|
+
assert result["treated_gas"]["flow_mol_s"] + result["rich_solvent"][
|
|
125
|
+
"flow_mol_s"
|
|
126
|
+
] == pytest.approx(160.0, rel=1e-6)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|