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.
Files changed (33) hide show
  1. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/PKG-INFO +16 -11
  2. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/README.md +13 -8
  3. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/pyproject.toml +2 -2
  4. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/__init__.py +1 -1
  5. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/tools.py +258 -22
  6. fugacio_copilot-0.3.0/tests/test_flowsheet_tools.py +126 -0
  7. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/.gitignore +0 -0
  8. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/agent.py +0 -0
  9. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/dynamics_tools.py +0 -0
  10. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/integration_tools.py +0 -0
  11. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/__init__.py +0 -0
  12. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/anthropic.py +0 -0
  13. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/base.py +0 -0
  14. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/mock.py +0 -0
  15. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/llm/openai.py +0 -0
  16. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/mpc_tools.py +0 -0
  17. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/py.typed +0 -0
  18. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/report.py +0 -0
  19. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/src/fugacio/copilot/saft_tools.py +0 -0
  20. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_agent.py +0 -0
  21. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_copilot.py +0 -0
  22. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_design_tools.py +0 -0
  23. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_dynamics_tools.py +0 -0
  24. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_integration_tools.py +0 -0
  25. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_llm_agent.py +0 -0
  26. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_mpc_tools.py +0 -0
  27. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_nonideal_tools.py +0 -0
  28. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_property_tools.py +0 -0
  29. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_reaction_tools.py +0 -0
  30. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_report.py +0 -0
  31. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_saft_tools.py +0 -0
  32. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_steam_tools.py +0 -0
  33. {fugacio_copilot-0.2.0 → fugacio_copilot-0.3.0}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: fugacio-copilot
3
- Version: 0.2.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.2.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`, `compressor` (and turbine), `pump`,
41
- `valve`, each closing a rigorous energy balance.
42
- - **Distillation**: `shortcut_distillation` (Fenske-Underwood-Gilliland) and
43
- `rigorous_distillation` (multistage column with duties).
44
- - **Gradient-based optimization**: `optimize_flash_temperature` and
45
- `optimize_column_reflux` solve for the operating variable that hits a target by
46
- differentiating straight through the equilibrium flash and the column.
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`, `compressor` (and turbine), `pump`,
19
- `valve`, each closing a rigorous energy balance.
20
- - **Distillation**: `shortcut_distillation` (Fenske-Underwood-Gilliland) and
21
- `rigorous_distillation` (multistage column with duties).
22
- - **Gradient-based optimization**: `optimize_flash_temperature` and
23
- `optimize_column_reflux` solve for the operating variable that hits a target by
24
- differentiating straight through the equilibrium flash and the column.
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.2.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.2.0",
25
+ "fugacio-sim==0.3.0",
26
26
  ]
27
27
 
28
28
  [project.optional-dependencies]
@@ -83,4 +83,4 @@ __all__ = [
83
83
  "tool_schemas",
84
84
  ]
85
85
 
86
- __version__ = "0.2.0"
86
+ __version__ = "0.3.0"
@@ -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 = 1.0,
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 = solve_column(feed, n_stages, feed_stage, reflux, distillate_rate, q=q)
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 = 1.0,
454
- iters: int = 8,
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
- def purity_of_reflux(reflux: float) -> Any:
459
- res = solve_column(feed, n_stages, feed_stage, reflux, distillate_rate, q=q)
460
- return res.distillate.z[light_key]
461
-
462
- reflux_opt = _safeguarded_newton(
463
- lambda r: purity_of_reflux(r) - target_purity, 0.2, 25.0, iters=iters
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": reflux_opt,
467
- "achieved_purity": float(purity_of_reflux(reflux_opt)),
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 (Wang-Henke, constant molar overflow) with EOS "
1461
- "K-values; returns product compositions, temperatures, and column duties."
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
- "q": {"type": "number"},
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
- "Gradient-based solve for the reflux ratio that achieves a target distillate "
1513
- "purity of the light key, differentiating through the rigorous column."
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
- "q": {"type": "number"},
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)