Optimization, design specs & economics¶
The fugacio.sim layer adds gradient-based optimization, design
specifications / controllers, and process economics on top of the
differentiable flowsheet engine, and fugacio.copilot exposes all of it to an
LLM design agent through a JSON tool registry. Like the rest of the stack, every
solver carries implicit-function-theorem gradient rules, so you can differentiate
through an optimum, a met spec, or an annual-cost estimate, with respect to
prices, feed conditions, or model parameters.
Differentiable optimization¶
minimize solves min_x f(x, theta) over an arbitrary decision pytree, with
optional box bounds and equality / inequality constraints. The unconstrained
inner method is BFGS (also "gradient-descent" and "newton"); bounds switch to
spectral projected gradient, and constraints to an augmented-Lagrangian outer
loop. least_squares wraps a Levenberg–Marquardt solver for residual problems.
import jax.numpy as jnp
from fugacio.sim import minimize
# Rosenbrock, unconstrained:
def rosen(x, _):
return (1 - x[0]) ** 2 + 100 * (x[1] - x[0] ** 2) ** 2
res = minimize(rosen, jnp.array([-1.2, 1.0]))
res.x # -> [1, 1]
res.converged # True
The headline is argmin: it returns only the optimal x*(theta), but with a
custom VJP that differentiates the solution through the optimality (KKT)
conditions by the implicit function theorem, exact and cheap, with no
backprop-through-iterations. That makes an optimizer just another differentiable
layer you can nest inside a larger gradient.
import jax
import jax.numpy as jnp
from fugacio.sim import argmin
# Ridge fit: x*(theta) = (A^T A + theta I)^{-1} A^T b. Differentiate the
# *solution* with respect to the regularization strength theta.
A = jnp.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
b = jnp.array([1.0, 2.0, 3.0])
def loss(x, theta):
return jnp.sum((A @ x - b) ** 2) + theta * jnp.sum(x**2)
dx_dtheta = jax.jacobian(lambda th: argmin(loss, jnp.zeros(2), th))(0.5)
Bounds and constraints carry gradients too: for an active bound the sensitivity collapses to the constraint, for an interior solution it follows the reduced Hessian.
from fugacio.sim import minimize
# min (x-3)^2 + (y-3)^2 s.t. x + y <= 4, 0 <= x,y
res = minimize(
lambda v, _: (v[0] - 3) ** 2 + (v[1] - 3) ** 2,
jnp.array([0.0, 0.0]),
bounds=(0.0, None),
ineq_constraints=lambda v, _: jnp.atleast_1d(v[0] + v[1] - 4.0),
)
res.x # -> [2, 2] on the active constraint
Design specifications & controllers¶
A design spec adjusts a manipulated variable until a controlled variable hits
a target, the bread-and-butter of flowsheeting. meet_spec is the single-variable
solver (bracketed bisection when given [lo, hi], else damped Newton);
controller builds a DesignSpec that reads like control language; and
solve_design satisfies several (generally coupled) specs at once with a Newton
system, re-running the flowsheet (recycles and all) at each step. The converged
manipulated values and the streams computed from them stay differentiable with
respect to the unmanipulated parameters.
import jax.numpy as jnp
from fugacio.sim import Stream, flash_drum, controller, solve_design
feed = Stream.from_fractions(
("propane", "n-butane", "n-pentane"),
jnp.array([0.4, 0.35, 0.25]), flow=100.0, t=330.0, p=8e5,
)
def simulate(theta): # one flash drum at (T, P)
vapor, liquid = flash_drum(feed, theta["T"], theta["P"])
return {"vapor": vapor, "liquid": liquid}
# "Move the drum temperature in [300, 360] K to vaporise 40 mol/s."
spec = controller(
simulate, manipulated="T",
controlled=lambda s: s["vapor"].total, set_point=40.0,
lo=300.0, hi=360.0,
)
out = solve_design(simulate, {"T": 330.0, "P": 8e5}, [spec])
out.theta["T"], out.converged # the temperature that hits 40 mol/s
Flowsheet optimization¶
optimize_flowsheet ties the pieces together: choose design_vars out of the
parameter mapping, pass an economic (or any) objective(streams, theta), and it
optimizes end to end, differentiating straight through the converged flowsheet.
import jax.numpy as jnp
from fugacio.sim import optimize_flowsheet
def objective(streams, theta): # e.g. a total annual cost
return cost(streams, theta)
res = optimize_flowsheet(
simulate, objective,
theta0={"T": 330.0, "P": 8e5}, design_vars=["T"],
bounds={"T": (300.0, 360.0)},
)
res.theta["T"], res.objective, res.converged
Process economics¶
fugacio.sim includes differentiable equipment sizing, Turton bare-module
costing, utility costing, and the usual financial metrics, so an
objective can be a real screening economics number, and its gradient with respect
to a design variable is exact.
| Step | Functions |
|---|---|
| Sizing | lmtd, heat_exchanger_area, column_diameter, column_height, vessel_volume |
| Capital | purchased_cost, pressure_factor, bare_module_cost (Turton, CEPCI-escalated) |
| Utilities | utility_cost (cooling water, steam levels, refrigeration, electricity, …) |
| Finance | capital_recovery_factor, annualized_capital, total_annual_cost, npv, discounted_payback |
import jax
from fugacio.sim import heat_exchanger_area, bare_module_cost, total_annual_cost, utility_cost
area = heat_exchanger_area(duty=1.0e6, u=500.0, dt_hot=60.0, dt_cold=40.0) # m^2 via LMTD
capex = bare_module_cost("heat_exchanger", area).bare_module # installed $
opex = utility_cost(1.0e6, "cooling_water") # $/yr
tac = total_annual_cost(capex, opex, rate=0.1, years=10.0) # TAC $/yr
d_tac_d_area = jax.grad(
lambda a: total_annual_cost(bare_module_cost("heat_exchanger", a).bare_module, opex)
)(area) # exact
The AI design copilot¶
fugacio.copilot wraps the whole engine in a registry of deterministic,
JSON-in/JSON-out tools (properties, flash/units, distillation, reactors,
optimization, sizing, costing, sensitivities) and drives them with an LLM. The
provider layer is vendor-neutral (a small LLMProvider protocol with OpenAI,
Anthropic, and Mock implementations), so the agent core never imports a
specific SDK.
from fugacio.copilot import default_registry, tool_schemas, call_tool
reg = default_registry()
tool_schemas(reg) # JSON schemas to hand an LLM
call_tool("heat_exchanger_cost",
{"duty": 1e6, "u": 500.0, "dt_hot": 60.0, "dt_cold": 40.0}, reg)
run_llm_agent runs the multi-turn tool-calling loop: the model plans, calls
tools, sees their results (and any errors, with schema validation), and returns a
final answer plus a full transcript. Swap in a real provider to go live.
from fugacio.copilot import run_llm_agent
from fugacio.copilot.llm import OpenAIProvider # needs the `openai` extra
result = run_llm_agent(
"Size and cost a cooler that removes 1 MW with cooling water.",
OpenAIProvider(model="gpt-4o-mini"),
)
result.answer # natural-language summary
result.transcript # ordered tool calls + results
Finally, fugacio.copilot.report turns results into Markdown an engineer expects:
stream_table, summarize_optimization, summarize_economics, and
summarize_transcript.