Anvil Framework Developer Wiki, v1.3
Getting Started
Overview Quick Start
Examples
Examples
Core Primitives
Quantity (Q) Unit Engine Relation System
Computation
Solvers Registry Project Registry
RSQs & Adapters
Built-in RSQs Adapters
Analysis
Sweep & Sensitivity Visualization
Reference
Databases Limits & Gotchas Advanced Decomposition Abel Transform CFD Solver Signal Processing
Propulsion
Propulsion Cycles
Physics
Physics
Chemistry
Chemistry
Workbench & Dev
Web Workbench Contributing
Anvil Wiki › Overview

Anvil Framework, Complete Reference Wiki¶

Version 1.3.0 | Python 3.10+ | GitHub


Anvil is an engineering computation framework: write physics as plain Python functions, wire them into solvable systems, and get results with automatic unit tracking.

Three primitives: - Q (Quantity), a number + physical dimension. Arithmetic propagates units automatically. - Relation, a computation block: keyword inputs → dict of outputs. - System, a solvable graph of Quantities and Relations with built-in solvers, sweep, and sensitivity.

Three ways to run it, each tier is optional and builds on the previous:

  1. Bare package, pip install, then Q, relations, systems, solvers, sweep, sensitivity, units. No database, no server.
  2. + Project database, anvil.project() adds a local per-project SQLite store for your own RSQs, alongside the global registry (101 built-in RSQs).
  3. + Web Workbench, python -m anvil_server serves a browser UI (calculator + visual canvas) that reads both the project and global databases. See Web Workbench.

Priorities: the native core comes first, units, RSQs, sweep, sensitivity, project database, reusability, robustness. Adapters to external tools (XFOIL, SU2, Cantera, ...) are second-class conveniences: they are real-only (no mock fallbacks) and never required for the core to work.


Navigation¶

Page What it covers
Quick Start Installation, first examples, outputs
Quantity Q, Dim, UnitStub, arithmetic, conversions, complete API
Unit Engine All 101 units, compound parsing, categories, custom dims
Relation Relation, @anvil.relation, Relation.block, input/output detection
System add, set, use, solve, sweep, sensitivity, optimize, as_relation, full API
Solvers find_root, solve_nonlinear, solve_ode, solve_ode_stiff, solve_bvp, solve_pde_heat_1d, minimize, minimize_global
Registry SQLite store, push, update, search, list, info, export, remove, check
Project Registry anvil.project(), isolated stores, context manager, promote
Built-in RSQs All 101 RSQs, signatures, domains, example outputs (includes misc domain: pod_analysis, dmd_analysis, abel_inverse, abel_forward, fft_spectrum, welch_psd, stft_spectrogram, bandpass_filter, envelope_detection, cross_correlation, signal_statistics)
Adapters Adapter, python/CLI backends, unit handling, real-only policy
Sweep & Sensitivity sys.sweep(), sys.sensitivity(), result objects, parallel
Visualization viz.convergence, viz.sweep_plot, viz.variable_trace, viz.dependency_graph, viz.pod_energy, viz.dmd_spectrum
Databases fluids, materials, const, built-in property tables
Limits & Gotchas What fails, edge cases, accuracy, known issues
Advanced Composition, cycles, block relations, CFD module, Watchdog
Decomposition anvil.decomp, POD, DMD, Hankel embedding, signal analysis
Abel Transform abel_forward, abel_three_point, abel_onion, abel_image, abel_center
Signal Processing fft_spectrum, welch_psd, stft_spectrogram, bandpass_filter, envelope_detection, cross_correlation, signal_statistics
Web Workbench Browser UI: calculator, visual canvas, examples, server API
Contributing How to add units, RSQs, adapters, wiki pages; project conventions

At a Glance¶

import anvil
from anvil import Q, K, Pa, m, s, kg, N, J, W, kPa, MPa

# ── Quantities with units ──────────────────────────────────────
T   = 300 * K           # Q(300, "K")
P   = 6.9 * MPa         # Q(6900000, "Pa")
rho = 1.225 * kg/m**3   # Q(1.225, "kg/m^3")

# ── Unit arithmetic ───────────────────────────────────────────
F   = Q(100, "N")
A   = Q(0.01, "m^2")
sig = F / A             # → Q(10000, "Pa"), dim auto-detected
KE  = 0.5 * Q(10,"kg") * Q(30,"m/s")**2  # → Q(4500, "J")

# ── Unit conversions ──────────────────────────────────────────
P.to("psi")             # Q(1000.7, "psi")
Q(300, "K").to("R")     # Q(540, "R")

# ── Call a built-in RSQ directly ─────────────────────────────
r = anvil.R.isentropic_ratios(M=2.0, gamma=1.4)
# → {"T0_T": 1.8, "P0_P": 7.824, "rho0_rho": 4.347}

# ── Load a pre-built System ───────────────────────────────────
nozzle = anvil.S.rocket_nozzle.copy()
nozzle.set(P0=10e6, T0=3500)
result = nozzle.solve_forward()
result.summary()

# ── Build your own System ─────────────────────────────────────
sys = anvil.system("rayleigh_duct")
sys.add("M1",    0.3)
sys.add("T01",   400.0, "K")
sys.add("P1",    200e3, "Pa")
sys.add("q_heat", 300e3, "J/kg")
sys.add("cp",    1005.0, "J/kg/K")
sys.use(my_relation)
result = sys.solve_forward()

# ── Parametric sweep ──────────────────────────────────────────
import numpy as np
sweep = sys.sweep("q_heat", np.linspace(0, 500e3, 50))
sweep.summary(outputs=["M2", "T02", "P02_P01"])

# ── Register your own RSQ ─────────────────────────────────────
proj = anvil.project("my_study", path="./work")
proj.push(my_func, domain="aero", tags=["compressible"])
proj.R.my_func(M=2.0)
proj.promote("my_func")   # → global registry

Package Layout¶

src/anvil/
├── __init__.py          top-level API: system(), relation(), push(), solve(), R, S, QDB
├── quantity.py          Quantity (Q), Dim arithmetic
├── units.py             UnitDB, UnitStub, all unit definitions
├── relation.py          Relation class, Relation.block()
├── system.py            System, Result, SweepResult, SensitivityResult, OptimizeResult
├── solvers/
│   └── __init__.py      find_root, solve_nonlinear, solve_ode, solve_ode_stiff,
│                        solve_bvp, solve_pde_heat_1d, minimize, minimize_global
├── decomp.py            POD, DMD, hankel, pod_reconstruct, dmd_reconstruct, pod_rank
├── doe.py               full_factorial, latin_hypercube, sobol, run_doe
├── registry/
│   ├── __init__.py      push, search, list, info, export, remove
│   ├── store.py         SQLite backend (Store class)
│   ├── namespace.py     R., S., QDB. dot-access namespaces
│   └── loader.py        RSQ source → live object
├── seed.py              101 built-in RSQs seeded on first import
├── project.py           Project class (isolated registry)
├── adapter.py           Adapter class (python + cli backends)
├── adapters/            real-only wrappers: XFOIL, SU2, OpenFOAM, FEniCSx,
│                        pyNastran, OpenMDAO, Cantera, NASA CEA, CoolProp,
│                        gmsh, RocketCEA, poliastro, pykep, surrogates, UQ
├── viz.py               convergence, sweep_plot, variable_trace, dependency_graph,
│                        pod_energy, dmd_spectrum
├── inspect.py           anvil.check()
├── watchdog.py          Watchdog convergence tracker
├── db/
│   ├── __init__.py      const, fluids, materials
│   └── properties.py    FluidDB, MaterialDB data tables
├── help_.py             anvil.lookup(), in-REPL help
└── cfd/                 CFD solver (mesh, BCs, flux, viz)

anvil_server/            FastAPI backend for the Web Workbench (REST + WebSocket)
anvil_web/               React + TypeScript frontend (calculator + canvas)

Version History (relevant to this wiki)¶

Version Key additions
1.3.0 Current. pip-installable; 101 units / 101 RSQs; Web Workbench (FastAPI server + React calculator/canvas UI); all adapters real-only (no mock fallbacks, is_available() pattern); anvil.doe (full factorial, Latin hypercube, Sobol); poliastro/pykep adapters; angle Q(deg) inputs fixed in all RSQs; Wh/kWh energy units; degC/degF offset temperature units with correct add/sub display; beam RSQ unit bug fixed; auto-update seed on source change; +4 RSQs in misc domain; +7 signal processing RSQs; engineering adapters (XFOIL, OpenFOAM, SU2, OpenMDAO, FEniCSx, pyNastran, surrogates, CoolProp, gmsh, RocketCEA); full 2D Euler CFD docs
1.2.1 +19 RSQs: orbital extended, attitude/ADCS, mission budgets, controls extended. Total 76.
1.2.0 minimize_global (DE/DA/SHGO/BH), System.optimize(), OptimizeResult, anvil.decomp (POD/DMD), viz.pod_energy, viz.dmd_spectrum
1.1.0 CFD module, Watchdog, help_
1.0.0 Project registry, Jupyter display, parallel sweep, 57 RSQs
0.x Core Q/Relation/System, basic registry

Dependencies¶

Package Required Purpose
numpy Yes Array math
scipy Yes All 7 solvers
matplotlib Optional viz.* functions
cantera Optional Combustion adapters
jupyter Optional _repr_html_ display

Quick Start¶

Installation¶

Fastest: one command¶

From a fresh clone, this provisions a virtual environment, installs Anvil plus the web server, launches it, and opens the workbench in your browser, no Node, no npm:

git clone https://github.com/c-rk/anvil-framework-s
cd anvil-framework-s
python start_anvil.py

On Debian/Ubuntu/WSL you may first need sudo apt install python3-venv. The script self-heals a half-built environment and accepts --port, --host, --no-browser, and --no-venv.

Library only (pip)¶

If you just want the Python API, or you manage your own environment:

pip install -e .                 # core (numpy + scipy)
pip install -e ".[server]"       # + web workbench (FastAPI/uvicorn)
pip install -e ".[viz]"          # + matplotlib plots
pip install -e ".[poliastro]"    # poliastro + astropy
pip install -e ".[pykep]"        # pykep
pip install -e ".[adapters]"     # all adapter deps
pip install -e ".[all]"          # viz + all adapters + server
conda install -c cantera cantera # Cantera (conda recommended)

After install, import anvil works from any directory and any script. No sys.path manipulation needed.

Command line¶

Installing gives you an anvil command:

anvil doctor     # report which adapters are usable on this machine, with install hints
anvil serve      # start the web workbench (same as start_anvil.py, without the venv setup)
anvil version    # print the installed version

Call a running server from any code¶

start_anvil.py/anvil serve expose a REST API; drive it from any Python with the built-in client:

from anvil.client import AnvilClient

srv = AnvilClient("http://127.0.0.1:8000")
srv.health()                                    # {'status': 'ok', 'rsq_count': ...}
srv.call("isentropic_ratios", M=2.0, gamma=1.4) # -> {'T0_T': 1.8, ...}
Extra What it enables
(none) All built-in RSQs, all solvers, registry
viz anvil.viz.* plots (requires matplotlib)
poliastro anvil.adapters.poliastro_orbits
pykep anvil.adapters.pykep_trajectories
adapters All adapter optional deps
all viz + all adapters

5-Minute Tutorial¶

Step 1, Create Quantities¶

import anvil
from anvil import Q, K, Pa, m, s, kg, N, J, W, kPa, MPa

# Three equivalent styles
T = Q(300, "K")          # string unit
T = Q(300, "temperature") # category alias
T = 300 * K              # unit-stub syntax (cleanest)

P   = 101325 * Pa
v   = 340 * (m/s)
g   = 9.81 * m/s**2
rho = 1.225 * kg/m**3

Step 2, Arithmetic propagates units¶

F   = Q(100, "N")
A   = Q(0.01, "m^2")
sig = F / A              # → 10000.00 Pa  (dimension auto-detected)

KE  = 0.5 * Q(10,"kg") * Q(30,"m/s")**2  # → 4500.00 J

print(sig)               # 10000.00 Pa
print(KE)                # 4500.00 J
print(KE.to("kJ"))       # 4.5000 kJ
print(KE.si)             # 4500.0  (always SI float)

Step 3, Call a built-in RSQ¶

# Direct call, no System needed
r = anvil.R.isentropic_ratios(M=2.0, gamma=1.4)
print(r["T0_T"])    # 1.8
print(r["P0_P"])    # 7.8244
print(r["rho0_rho"]) # 4.3469

Step 4, Use a pre-built System¶

nozzle = anvil.S.rocket_nozzle.copy()   # deep copy, safe to modify
nozzle.set(P0=10e6, T0=3500)            # override values, keep units

result = nozzle.solve_forward()
result.summary()

Output:

--------------------------------------------------------
  rocket_nozzle -- results
--------------------------------------------------------
  P0                        1.0000e+07 Pa
  T0                        3500.00 K
  gamma                     1.2500
  R_gas                     320.00 J/kg/K
  A_throat                  0.01 m^2
  A_exit                    0.08 m^2
  P_amb                     101325.00 Pa
                            ---
  area_ratio                8.0000
  M_exit                    3.6423
  T0_T                      2.6469
  P0_P                      59.6093
  rho0_rho                  22.5243
  T_exit                    1322.66 K
  P_exit                    167773.97 Pa
  a_exit                    728.41 m/s
  V_exit                    2652.17 m/s
  mdot                      12.12 kg/s
  thrust                    32143.09 N
  Isp                       270.20 s
--------------------------------------------------------

Step 5, Build your own System¶

def lift_coeff(W, S_ref, V, rho):
    q = 0.5 * rho * V**2
    CL = W / (q * S_ref)
    return {"q_inf": Q(q, "Pa"), "CL": CL}

sys = anvil.system("wing_loading")
sys.add("W",    50000, "N")
sys.add("S_ref", 20,   "m^2")
sys.add("V",    80,   "m/s")
sys.add("rho",  1.225, "kg/m^3")
sys.use(lift_coeff)

result = sys.solve_forward()
result.summary()
# CL = 0.7994, q_inf = 3920.00 Pa

Step 6, Sweep a parameter¶

import numpy as np

sweep = sys.sweep("V", np.linspace(50, 150, 8))
sweep.summary(outputs=["CL", "q_inf"])

Output:

----------------------------------------------------------------------
  wing_loading -- sweep over V
----------------------------------------------------------------------
               V            CL         q_inf
            [m/s]                      [Pa]
  ----------------------------------------------------------------------
              50        2.0388     1531.25
           64.29        1.2337     2534.06
           78.57        0.8249     3783.89
           92.86        0.5913     5280.73
             107        0.4453     7024.58
           121.4        0.3460     9015.45
           135.7        0.2773     11253.3
             150        0.2269     13781.3
----------------------------------------------------------------------

Common Patterns¶

One-shot solve (no System object)¶

result = anvil.solve("isentropic_ratios", M=2.0, gamma=1.4)
print(result["P0_P"])   # 7.824

Register and reuse a custom RSQ¶

@anvil.relation(domain="aero", tags=["compressible"])
def dynamic_pressure(rho, V):
    return {"q_inf": Q(0.5 * rho * V**2, "Pa")}

# Now available globally
anvil.R.dynamic_pressure(rho=1.225, V=100)
# {"q_inf": 6125.00 Pa}

Coupled/iterative system (Gauss-Seidel)¶

# System with a feedback loop, use solve_gauss_seidel()
result = sys.solve_gauss_seidel(
    relaxation=0.8,
    max_iter=200,
    rtol=1e-8,
    monitor=True,      # prints live residuals
)

Project registry workflow¶

proj = anvil.project("my_study", path="./work")

proj.push(rayleigh_ratios, domain="aero.compressible")
proj.push(rayleigh_heat,  domain="aero.compressible")

result = proj.R.rayleigh_heat(M1=0.3, T01=300.0, P1=101325.0,
                               q_heat=200e3, cp=1005.0)
print(result["M2"])     # 0.428
print(result["T02"])    # 499.00 K

proj.promote("rayleigh_heat")  # → global registry

What Anvil Is NOT¶

  • Not a symbolic solver, no algebraic manipulation, no CAS. Relations must be written as explicit Python functions.
  • Not a FEA/FEM framework, the PDE solver is limited to 1D parabolic (heat/diffusion). Complex PDEs need an external adapter.
  • Not a unit checker for general code, units are tracked on Q objects only. Raw Python floats carry no dimension information.
  • Not fully DOF-aware, validate() warns when a declared variable is overwritten by a relation or goes unused, but does not verify that equations equal unknowns in the general case. A fully underdetermined system (where nothing downstream needs the missing variable) still produces no error.
  • Not parallel by default, solve_gauss_seidel and solve_newton are single-threaded. Use sweep(parallel=N) for concurrent parametric sweeps.

Quantity (Q)¶

Quantity (aliased as Q) is the core value type in Anvil. It stores: - _si_value, the numerical value in SI base units (float64 or ndarray) - _dim, a Dim object tracking physical dimensions - _unit_hint, the unit string the user originally specified (for display)

All arithmetic on Q objects propagates dimensions in parallel with the numerical computation.


Construction¶

String unit¶

from anvil import Q

p = Q(101325, "Pa")
T = Q(300, "K")
v = Q(340, "m/s")
mdot = Q(1.5, "g/s")       # compound unit, parsed automatically
vol  = Q(5,  "cm^3")      # ** also accepted: "cm**3"
flux = Q(200, "W/m^2")

Category alias¶

p = Q(101325, "pressure")   # → Pa internally
T = Q(300, "temperature")   # → K internally
v = Q(10, "velocity")       # → m/s internally

Category aliases resolve to the preferred SI unit for that dimension. Full list: see Unit Engine.

Raw dimension string¶

# Standard (separate brackets)
d1 = Q(1, "[L][M][T-2]")   # → 1 N
# Compact (all in one bracket)
d2 = Q(1, "[LMT-2]")       # → same
# Both produce identical Dim objects
assert d1._dim == d2._dim   # True

Unit-stub syntax¶

from anvil import K, Pa, m, s, kg, mol, N, J, W
from anvil import km, cm, mm, g, kPa, MPa, GPa, bar, atm, psi
from anvil import kN, kJ, MJ, kW, BTU, ft, inch, in_, lb, lbf, hr

T   = 300 * K           # Q(300, "K")
P   = 101325 * Pa       # Q(101325, "Pa")
v   = 340 * (m/s)       # Q(340, "m/s")
g   = 9.81 * m/s**2     # Q(9.81, "m/s^2"), parens optional
rho = 1.225 * kg/m**3   # Q(1.225, "kg/m^3")

Stubs can be combined with *, /, **:

Pa*s    # → unit for dynamic viscosity
J/kg/K  # → specific heat unit stub
m**3    # → volume unit stub

Numpy arrays¶

import numpy as np
from anvil import Q

pressures = Q(np.array([100e3, 150e3, 200e3]), "Pa")
print(pressures)         # array(3,) Pa
print(pressures.si)      # array([100000. 150000. 200000.])

# Linspace helper
qs = Q.linspace(100, 300, 5, unit="K")  # list of 5 Quantity objects

Q(None, unit), undefined quantity¶

q = Q(None, "Pa")
print(q.value)  # None
print(q.si)     # None

Used internally for variables declared but not yet computed. float(Q(None, ...)) raises ValueError.


Properties¶

Property Type Description
.si float or ndarray Value in SI base units (always)
.value float or ndarray Value in display unit
.unit str Display unit name, or raw dim string if no named unit
.dim Dim Physical dimension object
.dimensionless bool True if no dimensions
.name str Optional label (set by System or user)
.bounds tuple or None (lo, hi) bounds for validation
T = 300 * K
print(T.si)            # 300.0
print(T.value)         # 300.0 (K is SI for temperature)
print(T.unit)          # "K"
print(T.dim)           # [Θ]

p_psi = Q(14.696, "psi")
print(p_psi.si)        # 101325.0 (converted to Pa internally)
print(p_psi.value)     # 14.696  (display in psi)
print(p_psi.unit)      # "psi"

Display priority¶

  1. User's original unit hint (if dimensionally compatible with current value)
  2. Preferred unit from active unit system (SI or Imperial)
  3. Raw dimension string [L][M][T-2] as fallback
anvil.set_units("Imperial")
Q(101325, "Pa").unit   # "psi", preferred Imperial unit for pressure
anvil.set_units("SI")
Q(101325, "Pa").unit   # "Pa"

Arithmetic¶

All arithmetic propagates dimensions automatically. Operations work between: - Q × Q - Q × float/int - Q × UnitStub

Addition / subtraction¶

Same-dimension only:

Q(100, "Pa") + Q(50, "Pa")      # → 150.00 Pa
Q(1000, "N") - Q(200, "N")      # → 800.00 N

# Dimensionless Q + scalar
Q(1.4) + 0.1                    # → 1.5 (dimensionless)

# Cross-dimension → ValueError
Q(10, "N") + Q(5, "K")          # ValueError: incompatible dimensions

# Dimensional Q + scalar → ValueError
Q(10, "N") + 5                  # ValueError: Cannot add scalar to dimensional quantity

Multiplication / division¶

Dimensions combine:

Q(100, "N") * Q(0.1, "m")       # → Q(10, "N*m") → displayed as J (energy)
Q(100, "N") / Q(0.01, "m^2")    # → Q(10000, "Pa")
Q(10, "kg") * Q(9.81, "m/s^2")  # → Q(98.1, "N")

# Scale
Q(100, "Pa") * 2                 # → Q(200, "Pa")
Q(100, "Pa") / 4                 # → Q(25, "Pa")

Powers¶

Integer, float, dimensionless Q exponents:

Q(3, "m") ** 2                   # → Q(9, "m^2")
Q(9, "m^2") ** 0.5               # → Q(3, "m")
Q(3, "m") ** Q(2)                # → Q(9, "m^2"), Q must be dimensionless

Q(3, "m") ** Q(2, "N")          # ValueError: exponent must be dimensionless

Negation / abs¶

-Q(100, "N")          # → Q(-100, "N")
abs(Q(-100, "N"))     # → Q(100, "N")

Comparison¶

Q(100, "Pa") == Q(100, "Pa")    # True
Q(100, "Pa") < Q(200, "Pa")     # True
Q(100, "Pa") <= Q(200, "Pa")    # True  (__le__ supported)
Q(200, "Pa") >= Q(100, "Pa")    # True  (__ge__ supported)
Q(100, "Pa") == 100             # True (dimensionless check)

Comparison between Q objects uses SI values. Comparing incompatible dimensions raises TypeError. Comparing a dimensional Q with a plain float returns NotImplemented.

Q(10, "N") < Q(5, "K")
# TypeError: Cannot compare [L][M][T-2] < [Θ]: incompatible dimensions.
#            Convert to the same unit first.

Unit Conversion¶

q.to("unit_string")   # returns new Quantity; original unchanged
Q(101325, "Pa").to("atm")      # 1.0000 atm
Q(101325, "Pa").to("psi")      # 14.6959 psi
Q(101325, "Pa").to("kPa")      # 101.3250 kPa
Q(300,   "K").to("R")         # 540.00 R
Q(1000,  "N").to("lbf")       # 224.81 lbf
Q(10,    "m/s").to("cm/s")    # 1000.00 cm/s
Q(10,    "m/s").to("mph")     # 22.37 mph
Q(0.001, "kg/s").to("g/s")    # 1.0000 g/s
Q(1e-6,  "m^3").to("cm^3")    # 1.0000 cm^3
Q(100,   "J").to("BTU")       # 0.0948 BTU

Incompatible dimensions raise ValueError:

Q(100, "N").to("K")
# ValueError: Cannot convert [L][M][T-2] to 'K' ([Θ]): incompatible dimensions.

Bounds¶

T = Q(350, "K", bounds=(200, 500))
T.in_bounds()       # True

T_hot = Q(600, "K", bounds=(200, 500))
T_hot.in_bounds()   # False

# System validation checks bounds automatically
sys.add("T", 350, "K", bounds=(200, 500))
sys.validate()      # warns if any quantity is outside bounds

f-string formatting¶

T = 300 * K
print(f"Temperature: {T:.2f}")   # Temperature: 300.00 (formats display value)
print(f"Temperature: {T:.4e}")   # Temperature: 3.0000e+02

Jupyter / Notebook display¶

In Jupyter cells, Q objects render as styled inline badges:

Q(340, "m/s")     # → styled HTML: 340.0000 m/s

_repr_latex_() renders LaTeX: $340.0000\;\mathrm{m/s}$


Static constructors¶

Q.linspace(start, stop, num, unit="")¶

Returns a Python list of num Quantity objects:

temps = Q.linspace(200, 500, 4, unit="K")
# [200.00 K, 300.00 K, 400.00 K, 500.00 K]

Q.array(values, unit="")¶

Wraps a numpy array:

ps = Q.array([100e3, 200e3, 300e3], unit="Pa")
# array(3,) Pa
print(ps.si)   # [100000. 200000. 300000.]

Known Limits and Gotchas¶

Q(Q_obj, unit), not a double-wrap¶

Passing a Q as the value to Q() is not double-wrapping. The Quantity.__init__ always does float(value) * scale, which calls __float__ on the inner Q, returning its SI value. The result is dimensionally correct:

q1 = Q(100, "Pa")
q2 = Q(q1, "Pa")     # works: q2.si == 100.0

However, this is confusing and should be avoided. Always pass raw numbers.

Adding scalar to dimensional Q¶

Q(10, "N") + 5     # ValueError, non-dimensionless Q can't add to scalar
Q(1.4) + 0.1       # OK, dimensionless

Units not in database → custom dimension¶

q = Q(5, "flarps")
r = q * Q(3, "s")
print(r)            # 15.0000 [T][flarps], custom dim preserved

Custom dims propagate through arithmetic but can never be converted to a named unit.

Comparison across dimensions raises TypeError¶

Q(10, "N") < Q(5, "K")
# TypeError: Cannot compare [L][M][T-2] < [Θ]: incompatible dimensions.

Q(10, "N") >= Q(5, "K")
# TypeError: Cannot compare [L][M][T-2] >= [Θ]: incompatible dimensions.

<, <=, >, >= all raise TypeError when dimensions differ. == returns False (no exception) for dimension mismatches, this matches Python convention where equality across types is usually False, not an error.

Q ** non-dimensionless Q → ValueError¶

Q(3, "m") ** Q(2, "N")    # ValueError: Exponent must be dimensionless.

ndarray Quantities in System solvers¶

The _forward() method calls float(q._si_value) on all workspace values. This fails silently if the value is an ndarray with ndim > 0, it will be stored as-is in the workspace, and downstream float arithmetic will fail.

Only scalar Quantities should be system inputs.

Unit Engine¶

Anvil's unit engine is built around the Dim class and the UnitDB singleton (anvil.units.db). Every Quantity carries a Dim object that tracks physical dimensions independently of the numerical value.


Dim, Physical Dimension Object¶

Dim stores dimensions as a dict of {symbol: exponent}. Only non-zero exponents are stored.

Standard SI symbols:

Symbol Dimension
L Length
M Mass
T Time
TH (displayed as Θ) Temperature
N Amount of substance
I Electric current
J Luminous intensity

Creating Dim objects¶

from anvil.units import Dim

force     = Dim(L=1, M=1, T=-2)     # Newton: [L][M][T-2]
pressure  = Dim(L=-1, M=1, T=-2)    # Pascal: [L-1][M][T-2]
dimless   = Dim()                    # dimensionless (empty)
dimless   = Dim.dimensionless()      # singleton version

# Parse from string
d = Dim.parse("[L][M][T-2]")        # standard
d = Dim.parse("[LMT-2]")            # compact, same result
d = Dim.parse("[L2][T-2]")          # with exponents

Dim arithmetic¶

velocity  = Dim(L=1) / Dim(T=1)         # [L][T-1]
kinetic_e = Dim(M=1) * Dim(L=1, T=-1)**2 # [L2][M][T-2] = J

force = Dim(L=1, M=1, T=-2)
area  = Dim(L=2)
pressure = force / area                 # [L-1][M][T-2] = Pa
Operation Dim result
d1 * d2 Exponents add
d1 / d2 Exponents subtract
d ** n Exponents scale by n
~d Exponents negate (invert)

Dim comparison¶

Dim(L=1, M=1, T=-2) == Dim(L=1, M=1, T=-2)   # True
Dim(L=1) == Dim(T=1)                            # False
hash(d)                                          # hashable, usable as dict key

UnitDB, The Unit Database¶

anvil.units.db is a singleton UnitDB instance. It maps unit strings → (scale_to_SI, Dim) pairs and provides reverse lookup (Dim → named unit).

db.lookup(unit_str) → (scale, Dim)¶

from anvil.units import db

scale, dim = db.lookup("Pa")
# scale = 1.0, dim = Dim(L=-1, M=1, T=-2)

scale, dim = db.lookup("psi")
# scale = 6894.757, dim = Dim(L=-1, M=1, T=-2)

scale, dim = db.lookup("cm/s")
# scale = 0.01, dim = Dim(L=1, T=-1), parsed as compound

scale, dim = db.lookup("flarps")
# scale = 1.0, dim = Dim(flarps=1)  , custom dimension created

db.find_unit(dim, system) → (name, scale) or None¶

Reverse lookup: given a Dim, find the preferred display unit for "SI" or "Imperial".

from anvil.units import Dim
db.find_unit(Dim(L=1, M=1, T=-2), "SI")        # ("N", 1.0)
db.find_unit(Dim(L=1, M=1, T=-2), "Imperial")   # ("lbf", 4.448...)
db.find_unit(Dim(L=-1, M=1, T=-2), "SI")        # ("Pa", 1.0)

db.conversion_factor(from_unit, to_unit) → float¶

db.conversion_factor("Pa", "psi")   # 1/6894.757 ≈ 0.000145
db.conversion_factor("m/s", "ft/s") # 1/0.3048  ≈ 3.28084

db.get_offset(unit_str) → float¶

Returns the additive SI offset for a unit. Returns 0.0 for all non-offset units.

db.get_offset("degC")   # 273.15
db.get_offset("°C")     # 273.15
db.get_offset("degF")   # 255.3722...  (= 459.67 × 5/9)
db.get_offset("K")      # 0.0
db.get_offset("Pa")     # 0.0

db.compatible(a, b) → bool¶

db.compatible("Pa", "psi")  # True, same dimension
db.compatible("Pa", "K")    # False
db.compatible("degC", "K")  # True, same [Θ] dimension
db.compatible("degF", "R")  # True

Compound Unit Parser¶

Units not in the database are parsed at runtime from their components using _parse_compound_unit().

Rules: - * separates numerator factors - / separates denominator factors (negates exponents of all following terms) - ^ or ** raises base to power - Exponents can be negative or float: s^-2, m^0.5 - Each base unit must be individually registered

Examples:

from anvil import Q

Q(1.5, "g/s")       # → 0.0015 kg/s     [M][T-1]
Q(10, "cm/s")      # → 0.1 m/s         [L][T-1]
Q(5,  "cm^3")      # → 5e-6 m^3        [L3]
Q(5,  "cm**3")     # ** also accepted
Q(200, "W/m^2")     # → W/m^2           [M][T-3]
Q(1e-3,"Pa*s")      # → Pa·s            [L-1][M][T-1]
Q(1,  "kg*m/s^2")  # → 1 N             [L][M][T-2]
Q(1,  "J/kg/K")    # → J/kg/K          [L2][T-2][Θ-1]
Q(1,  "m^0.5")     # fractional exponent OK

Limitation: Every base token must be a known unit. Q(1, "flarps/s") creates a custom dimension flarps and parses /s correctly, but flarps will have an unknown dimension unless explicitly registered.


Category Aliases¶

Pass a category name as the unit string to get the default SI unit for that dimension:

Category Resolves to Dim
"length" m [L]
"area" m^2 [L2]
"volume" m^3 [L3]
"mass" kg [M]
"time" s [T]
"temperature" K [Θ]
"velocity" m/s [L][T-1]
"acceleration" m/s^2 [L][T-2]
"force" N [L][M][T-2]
"pressure" Pa [L-1][M][T-2]
"stress" Pa [L-1][M][T-2]
"energy" J [L2][M][T-2]
"power" W [L2][M][T-3]
"density" kg/m^3 [L-3][M]
"dynamic_viscosity" Pa*s [L-1][M][T-1]
"kinematic_viscosity" m^2/s [L2][T-1]
"frequency" Hz [T-1]
"mass_flow" kg/s [M][T-1]
"specific_energy" J/kg [L2][T-2]
"specific_heat" J/kg/K [L2][T-2][Θ-1]
"thermal_conductivity" W/m/K [L][M][T-3][Θ-1]
"molar_mass" kg/mol [M][N-1]
"angle" (dimensionless) {}

Complete Unit Table¶

Length¶

Unit SI scale Notes
m 1.0 SI preferred
km 1000.0
cm 0.01
mm 0.001
um 1e-6 micrometers
in 0.0254 also inch, in_ (Python keyword workaround)
ft 0.3048 Imperial preferred
mi 1609.344 miles
nmi 1852.0 nautical miles

Mass¶

Unit SI scale Notes
kg 1.0 SI preferred
g 0.001
mg 1e-6
lb 0.45359237 pound-mass
lbm 0.45359237 alias
slug 14.5939 Imperial preferred
oz 0.028350 ounce
tonne 1000.0 metric ton

Time¶

Unit SI scale
s 1.0
ms 0.001
us 1e-6
min 60.0
hr 3600.0

Temperature¶

Unit Scale (K per unit) SI offset (K) Notes
K 1.0 0 Kelvin, SI preferred
R 5/9 ≈ 0.5556 0 Rankine, Imperial preferred
degC 1.0 +273.15 Celsius, full offset arithmetic
°C 1.0 +273.15 Unicode alias for degC
degF 5/9 +255.372 Fahrenheit, full offset arithmetic
°F 5/9 +255.372 Unicode alias for degF

Storage formula: SI_value = input_value × scale + offset Display formula: display_value = (SI_value − offset) / scale

from anvil import Q

Q(25, "degC").si          # 298.15  (stored as K)
Q(25, "degC").value       # 25.0    (display in °C)
Q(25, "degC").to("K")     # 298.15 K
Q(25, "degC").to("degF")  # 77.00 degF
Q(100, "degC").to("degF") # 212.00 degF
Q(32, "degF").to("K")    # 273.15 K
Q(373.15, "K").to("degC") # 100.00 degC

# Unicode forms
Q(0, "°C").si             # 273.15
Q(32, "°F").to("°C")      # 0.00 °C

Note: degC/degF share the [Θ] dimension with K and R, all conversions and dimension checks work normally. Arithmetic between temperature quantities (e.g., Q(100,"degC") + Q(50,"degC")) operates on the SI (Kelvin) values, which is the physically correct behaviour for absolute-scale arithmetic. For temperature differences the result is correct; for absolute sums the meaning is ambiguous (as it is in any unit system).

Force¶

Unit SI scale
N 1.0
kN 1000.0
MN 1e6
lbf 4.4482216

Pressure¶

Unit SI scale
Pa 1.0
kPa 1000.0
MPa 1e6
GPa 1e9
bar 1e5
atm 101325.0
psi 6894.757
psia 6894.757 (alias)
torr 133.322

Energy¶

Unit SI scale Notes
J 1.0
kJ 1000.0
MJ 1e6
Wh 3600.0 watt-hour
kWh 3.6e6 kilowatt-hour
cal 4.184
kcal 4184.0
BTU 1055.06
eV 1.602e-19

Power¶

Unit SI scale
W 1.0
kW 1000.0
MW 1e6
hp 745.70

Velocity¶

Unit SI scale
m/s 1.0
km/s 1000.0
km/hr 0.27778
ft/s 0.3048
mph 0.44704
kn 0.51444

Density¶

Unit SI scale
kg/m^3 1.0
g/cm^3 1000.0
lb/ft^3 16.0185
slug/ft^3 515.379

Specific heat / specific energy¶

Unit SI scale
J/kg/K 1.0
kJ/kg/K 1000.0
BTU/lb/R 4186.8
J/kg 1.0
kJ/kg 1000.0
BTU/lb 2326.0

Viscosity¶

Unit SI scale Dim
Pa*s 1.0 dynamic
poise 0.1 dynamic
m^2/s 1.0 kinematic

Molar¶

Unit SI scale
mol 1.0
kmol 1000.0
kg/mol 1.0
g/mol 0.001
J/mol/K 1.0

Other¶

Unit SI scale Dim
V 1.0 Voltage [L2][M][T-3][I-1]
Hz 1.0 Frequency [T-1]
kHz 1000.0
MHz 1e6
rad 1.0 Angle (dimensionless); SI value is radians
deg π/180 Angle (dimensionless); .si returns radians
A 1.0 Current [I]
mA 0.001

Acceleration, Area, Volume¶

Unit SI scale
m/s^2 1.0
ft/s^2 0.3048
m^2 1.0
cm^2 1e-4
mm^2 1e-6
ft^2 0.0929
in^2 6.4516e-4
m^3 1.0
cm^3 1e-6
L 0.001
ft^3 0.0283
gal 3.785e-3

Mass flow¶

Unit SI scale
kg/s 1.0
lb/s 0.45359

UnitStub, Value × Unit Syntax¶

UnitStub objects can be combined to form compound stubs before multiplying by a value:

from anvil import m, s, kg, Pa

v_stub   = m/s          # UnitStub("m/s")
g_stub   = m/s**2       # UnitStub("m/s^2")
rho_stub = kg/m**3      # UnitStub("kg/m^3")

v   = 340 * v_stub      # Q(340, "m/s")
g   = 9.81 * g_stub     # Q(9.81, "m/s^2")
rho = 1.225 * rho_stub  # Q(1.225, "kg/m^3")

Available stubs (directly importable from anvil):

K, Pa, m, s, kg, mol, A, N, J, W, rad, deg
km, cm, mm, um, g, tonne, ms, us, hr
kPa, MPa, GPa, bar, atm, psi
kN, kJ, MJ, kW, BTU
ft, inch, in_, lb, lbf
kmol, g_mol, kg_mol

in vs inch vs in_: in is a Python keyword. Use inch or in_ as stubs. String form Q(5, "in") still works.


Display System¶

anvil.set_units("SI")        # Pa, N, m/s, K, kg, J, W  (default)
anvil.set_units("Imperial")  # psi, lbf, ft/s, R, slug, BTU, hp

Affects only display (.value, .unit, repr()). Internal SI storage is unchanged.


Custom Dimensions¶

Unknown unit strings automatically create custom dimensions:

from anvil import Q
q1 = Q(5, "flarps")     # custom dim: Dim(flarps=1)
q2 = Q(3, "widgets")    # custom dim: Dim(widgets=1)

r = q1 * q2             # Dim(flarps=1, widgets=1) → displayed as "[flarps][widgets]"
r2 = q1 / Q(2, "s")    # Dim(T=-1, flarps=1) → "[T-1][flarps]"

Custom dims propagate correctly through all arithmetic but can never be displayed as a named unit.


Angles and RSQ Conventions¶

Angles are dimensionless in Anvil's dimension system. Q(45, "deg").si returns 0.7854 (radians). Q(1, "rad").si returns 1.0.

Built-in RSQs that accept angles in degrees (parameters named _deg) accept either a plain float (treated as degrees) or a Q(value, "deg") object:

# Both are equivalent:
anvil.R.j2_precession(a=6878e3, e=0.001, i_deg=97.4)
anvil.R.j2_precession(a=6878e3, e=0.001, i_deg=Q(97.4, "deg"))

# Also works from a System:
sys.add("i_deg", 97.4, "deg")   # stored as Q(97.4, "deg")
sys.use("j2_precession")         # RSQ receives the Q and converts correctly

When a System stores an angle as Q(97.4, "deg"), .si = 1.699 rad. The RSQ uses .si directly (skips the degrees→radians conversion) so results are correct either way.


Module-level Functions¶

from anvil import units

units.resolve("Pa")                  # (1.0, Dim(L=-1, M=1, T=-2))
units.find_unit(Dim(L=1, M=1, T=-2)) # ("N", 1.0)
units.compatible("Pa", "psi")        # True
units.conversion_factor("m", "ft")   # 3.28084
units.list_units()                   # sorted list of all registered unit strings
units.list_categories()              # sorted list of category names
units.set_system("Imperial")
units.get_system()                   # "Imperial"

Relation¶

A Relation is a computation block: it accepts keyword arguments and returns a dict of outputs. It's the fundamental "equation" unit in Anvil.


Rules for Relation Functions¶

Every function used as a Relation must: 1. Accept all inputs as keyword arguments (no positional-only args) 2. Return a dict mapping output names (strings) to values 3. Return Q(value, "unit") for dimensional outputs, this enables unit propagation 4. Default parameter values work: def fn(M, gamma=1.4):

from anvil import Q

def nozzle_thrust(mdot, V_exit, P_exit, P_amb, A_exit):
    F = mdot * V_exit + (P_exit - P_amb) * A_exit
    return {"thrust": Q(F, "N")}

def isentropic(M, gamma=1.4):
    T_ratio = 1 + ((gamma - 1) / 2) * M**2
    P_ratio = T_ratio ** (gamma / (gamma - 1))
    return {"T0_T": T_ratio, "P0_P": P_ratio}

Creating Relations¶

1. Implicit, system.use(func)¶

The most common approach. The System wraps any callable automatically:

import anvil

def lift(rho, V, S_ref, CL):
    return {"lift": Q(0.5 * rho * V**2 * S_ref * CL, "N")}

sys = anvil.system("wing")
sys.add("rho", 1.225, "kg/m^3")
sys.add("V", 80, "m/s")
sys.add("S_ref", 20, "m^2")
sys.add("CL", 0.5)
sys.use(lift)

2. Explicit, Relation(func, **options)¶

from anvil.relation import Relation

thrust_rel = Relation(
    nozzle_thrust,
    name="nozzle_thrust",
    tags=["propulsion", "nozzle"],
    desc="Rocket thrust from momentum and pressure.",
)

print(thrust_rel._inputs)   # ['mdot', 'V_exit', 'P_exit', 'P_amb', 'A_exit']
print(thrust_rel._outputs)  # [], discovered on first call
thrust_rel(mdot=10, V_exit=3000, P_exit=50000, P_amb=0, A_exit=0.5)
print(thrust_rel._outputs)  # ['thrust']

3. Decorator, @anvil.relation¶

@anvil.relation(domain="propulsion", tags=["nozzle"])
def rocket_thrust(mdot, V_exit, P_exit, P_amb, A_exit):
    """Rocket thrust from momentum and pressure terms."""
    F = mdot * V_exit + (P_exit - P_amb) * A_exit
    return {"thrust": Q(F, "N")}

# Now callable via anvil.R.rocket_thrust and usable in any system
r = anvil.R.rocket_thrust(mdot=10, V_exit=3000, P_exit=50000, P_amb=0, A_exit=0.5)
print(r["thrust"])  # 30000.00 N

Without parentheses (no metadata):

@anvil.relation
def speed_of_sound(gamma, R_gas, T):
    return {"a": Q((gamma * R_gas * T) ** 0.5, "m/s")}

register=False, wrap without pushing to registry:

@anvil.relation(domain="test", register=False)
def draft_relation(UA, C_min):
    return {"NTU": UA / C_min}

4. Block Relation, Relation.block()¶

Groups multiple functions into a single Relation that shares a workspace. Each step's outputs become available to subsequent steps.

from anvil.relation import Relation
from anvil import Q

def area_ratio(A_exit, A_throat):
    return {"area_ratio": A_exit / A_throat}

def mach_from_area(area_ratio, gamma=1.4):
    from anvil import solvers
    def residual(M):
        t = (2/(gamma+1))*(1+(gamma-1)/2*M**2)
        return (1/M)*t**((gamma+1)/(2*(gamma-1))) - area_ratio
    M = solvers.find_root(residual, bracket=(1.001, 30.0))
    return {"M_exit": M}

def exit_velocity(M_exit, T0, gamma=1.4, R_gas=287.0):
    T0_T = 1 + (gamma-1)/2 * M_exit**2
    T_e = T0 / T0_T
    a_e = (gamma * R_gas * T_e) ** 0.5
    V = M_exit * a_e
    return {"V_exit": Q(V, "m/s"), "T_exit": Q(T_e, "K")}

nozzle_chain = Relation.block("nozzle_physics",
    steps=[area_ratio, mach_from_area, exit_velocity],
    tags=["propulsion", "nozzle"],
)

print(nozzle_chain._inputs)   # ['A_exit', 'A_throat', 'T0', 'R_gas', 'gamma']
print(nozzle_chain._outputs)  # ['M_exit', 'T_exit', 'V_exit', 'area_ratio']

r = nozzle_chain(A_exit=0.08, A_throat=0.01, T0=3500, gamma=1.25, R_gas=320)
print(r["M_exit"])    # ~3.3
print(r["V_exit"])    # ~2600 m/s

How block works internally: 1. Calls each step function in order 2. Merges step outputs into a shared workspace dict 3. Each step sees all previously computed values 4. Quantities in the workspace are extracted to SI floats before passing to next step 5. Returns the full workspace as the final dict

Caveat: Block relations strip units from intermediate Quantity results (converted to SI floats). The final outputs that are Q objects are preserved.


Input / Output Detection¶

Inputs¶

Detected at construction time by inspecting the function signature:

import inspect
sig = inspect.signature(func)
inputs = list(sig.parameters.keys())
defaults = {k: p.default for k, p in sig.parameters.items()
            if p.default is not inspect.Parameter.empty}

All parameters become inputs. Parameters with defaults become optional.

Outputs¶

Detected lazily (on first call or during System validation). Two-pass strategy in _discover_outputs():

Pass 1, AST inspection (preferred, avoids side effects):

import ast, inspect
source = inspect.getsource(func)
tree = ast.parse(source)
# Walk return statements, collect string keys from dict literals

This works for plain return {"key": value} statements. Fails if the dict is constructed dynamically (e.g., d = {}; d["key"] = val; return d).

Pass 2, Runtime probe (fallback): Calls the function with 1.0 for every unknown input. Reads the returned dict keys. Can fail if the function raises with dummy inputs (e.g., division by zero, domain errors).

Implication: If output detection fails silently, the Relation has _outputs = [] and downstream relations that need those outputs will fail at validation.


Relation Object Properties¶

rel = Relation(func)

rel.name        # str, function name or explicitly set
rel.func        # the callable
rel._inputs     # list of input parameter names
rel._outputs    # list of output dict keys (populated lazily)
rel._defaults   # dict of default values
rel.tags        # list of search tags
rel.desc        # description string
rel.info()      # formatted print of inputs/outputs/tags

Calling a Relation Directly¶

r = anvil.R.isentropic_ratios(M=2.0, gamma=1.4)
# Returns the raw dict, same as calling the function

r["T0_T"]   # 1.8
r["P0_P"]   # 7.8244

Defaults are filled automatically:

r = anvil.R.isentropic_ratios(M=2.0)  # gamma=1.4 from default

Unit Propagation in Relations¶

When a System calls a Relation, it tries to pass Quantity objects instead of raw floats. If the function's arithmetic is compatible (uses +, -, *, /, ** on Q objects), units propagate through to the outputs.

If the function raises when receiving Q objects (e.g., calls a NumPy ufunc directly on a Q), the system falls back to passing raw SI floats. This is cached per-relation on rel._qty_compatible.

def good(F, A):
    return {"sigma": F / A}    # Q/Q → Quantity with Pa dim

def bad_numpy(x):
    import numpy as np
    return {"y": np.sqrt(x)}  # np.sqrt(Q) fails → falls back to float

In bad_numpy, the output y will be a plain float, no unit. It will display with the raw dimension dimensionless since no dim info is attached.


Name Mapping¶

When a Relation's parameter names don't match the System workspace, use map=:

# isentropic_ratios expects 'M' but workspace has 'M_exit'
sys.use("isentropic_ratios", map={"M": "M_exit"})

# Map is {relation_param: workspace_name}

The mapping creates a wrapper function that translates names before calling the original.


What Can Go Wrong¶

Function returns non-dict¶

def bad(x):
    return x * 2   # not a dict

sys.use(bad)
sys.solve_forward()
# RuntimeError: Relation 'bad' returned 'Quantity' instead of a dict.
#   Relations must return a dict mapping output names to values.
#   Example: return {"result": Q(F, "N")}

Always return {"output_name": value}.

Exception, single-output convenience: If a relation has exactly one known output and returns a bare scalar, Anvil accepts it silently. This is narrow; prefer dict returns.

Quantity as dict key¶

def bad_keys(x):
    return {Q(1, "Pa"): x * 2}   # Q as key

# Q is not hashable → TypeError: cannot use Quantity as dict key
# System solve crashes with TypeError

Non-keyword arguments¶

def bad_positional(x, y, /):   # positional-only (Python 3.8+)
    return {"z": x + y}

# Relation wraps it, but calling with **kwargs → TypeError
# inspect.signature() still captures the names, but calling fails

Side effects in relations¶

Relations should be pure functions. Side effects (file I/O, global state) in iterative solvers (Gauss-Seidel, Newton) cause N calls per iteration, the side effect runs N × max_iter times.

System¶

System is Anvil's solvable engineering problem: a named workspace of Quantity values wired to Relation computations. It contains the five solvers, parametric sweep, sensitivity analysis, and composition API.

import anvil
sys = anvil.system("my_problem")

Building a System¶

.add(), declare inputs¶

# Classic style: name, value, unit
sys.add("P0",   6.9e6, "Pa")
sys.add("T0",   3500,  "K")
sys.add("gamma", 1.25)           # dimensionless
sys.add("T0",   Q(3500, "K"))   # Quantity object

# kwargs style (unit inferred from Q)
sys.add(T0=3500*K, P0=6.9*MPa, gamma=1.25)

# With metadata
sys.add("T0", 3500, "K",
        desc="Chamber stagnation temperature",
        bounds=(200, 5000))

# Chain-able
sys.add("M", 2.0).add("gamma", 1.4)

What .add() does internally: - Creates a Quantity and stores it in _quantities[name] - Converts value to SI and records the unit hint for display - Sets _validated = False (forces re-validation before next solve)

.set(), override values¶

sys.set(P0=8e6)              # bare number: keeps existing unit (Pa)
sys.set(P0=Q(1000, "psi"))   # Q object: overrides value AND unit/dim
sys.set(P0=8e6, T0=3200, gamma=1.3)  # multiple at once

Difference from .add(): - .add() creates a new quantity (or replaces if name exists) - .set() fails if name not already in system (use .add() first) - .set(P0=8e6) with a bare number keeps the original unit hint (Pa), only changes the SI value

sys.add("T", 300, "K")
sys.set(T=400)     # OK, 400 K
sys.set(T=Q(200, "R"))  # OK, 111.11 K internally, displays as 200 R

sys.set(new_var=5)  # KeyError: 'new_var' not in system

.use(), add computation¶

sys.use(my_function)              # plain function
sys.use(my_relation)             # Relation object
sys.use(other_system)            # System, inherits its defaults, wraps as Relation
sys.use("isentropic_ratios")     # registry name (string lookup)
sys.use(my_adapter)              # Adapter object

# With name mapping
sys.use("isentropic_ratios", map={"M": "M_exit"})
# map = {relation_param_name: workspace_variable_name}

When passing a System to .use(): 1. All its _quantities not already in the host system are inherited as defaults 2. It's wrapped via .as_relation() into a single callable Relation

.copy(), safe duplication¶

nozzle_a = anvil.S.rocket_nozzle.copy()
nozzle_b = anvil.S.rocket_nozzle.copy()
nozzle_a.set(P0=10e6)    # does not affect nozzle_b

Deep-copies all quantities and relations. Shared relation function objects are referenced, not copied, pure functions are safe. Stateful relations (adapters with side effects) are not.

.validate(), pre-solve check¶

warnings = sys.validate()   # raises ValidationError on errors; returns warning list

Validates: 1. No NaN/Inf in any declared quantity 2. No two relations produce the same output (conflict) 3. Every relation's required inputs are either declared or produced by another relation 4. Quantity bounds are satisfied (warning only, not error)

After validation, calls _build_exec_order() to determine topological order.

# Example: missing input
sys = anvil.system("bad")
sys.add("M", 2.0)
sys.use("isentropic_ratios")  # needs M and gamma
try:
    sys.validate()
except ValidationError as e:
    print(e)
# ValidationError: Validation failed:
#   * 'isentropic_ratios' needs 'gamma' -- not provided.

Note: gamma has default=1.4 in the seed RSQ, so it actually WON'T raise here. Only truly un-defaulted inputs trigger this error.


Topology Detection¶

After validate(), _build_exec_order() runs Kahn's topological sort:

  1. Builds out_map[var] = relation_index, which relation produces each variable
  2. For each relation i, computes deps[i] = set of relations whose outputs are inputs to i
  3. BFS: processes zero-in-degree nodes first, building order
  4. If len(order) == n → acyclic → _has_cycles = False; solve() auto-selects forward
  5. If len(order) != n → cycles → _has_cycles = True; solve() auto-selects gauss_seidel

The _exec_order list is used by _forward() to call relations in dependency order.


Solvers¶

.solve_forward(), acyclic systems¶

Single pass through relations in topological order. Each relation called exactly once. No convergence needed.

result = sys.solve_forward()

When to use: Any system where outputs flow in one direction, no feedback loops. Fastest possible solve.

Output: Result object containing all workspace variables as Quantities.

.solve_gauss_seidel(), fixed-point iteration¶

For systems with cycles (coupled variables). Repeatedly runs _forward() until convergence.

result = sys.solve_gauss_seidel(
    relaxation=0.8,    # under-relaxation: 0 < ω ≤ 1. Lower = more stable, slower.
    max_iter=200,      # iteration limit
    rtol=1e-6,         # relative tolerance on max workspace change
    monitor=True,      # print live residuals per iteration
    verbose=False,     # brief residual print (less output than monitor)
)

Convergence criterion: max_change = max over all vars: |v_new - v_prev| / |v_prev|

Stop when max_change < rtol.

Example output with monitor=True:

  iter    0  |  residual = 2.0000e+00  |  t = 0.000s
  iter    1  |  residual = 2.3426e-01  |  t = 0.001s
  ...
  iter   32  |  residual = 6.9048e-11  |  t = 0.005s
  converged in 33 iterations

Failure: Raises RuntimeError: Not converged after N iters (residual: X.XXe+YY).

Relaxation guide: | System | Recommended ω | |--------|--------------| | Weakly coupled | 1.0 (no relaxation) | | Moderately coupled | 0.7-0.9 | | Strongly coupled | 0.3-0.6 | | Strongly nonlinear | Try Newton instead |

.solve_newton(), Newton-Raphson¶

For strongly nonlinear coupled systems. Identifies coupled = all_outputs ∩ all_inputs, sets them up as a nonlinear system F(x) = 0 (residual = new value minus previous), and solves with SciPy's Powell hybrid method.

result = sys.solve_newton(
    max_iter=50,
    rtol=1e-10,
    verbose=True,
)

When to use: - Gauss-Seidel oscillates or diverges - Strongly nonlinear relationships between coupled variables - Need quadratic convergence

Note: Each Newton iteration calls _forward() once per coupled variable (finite-difference Jacobian). For N coupled variables this is O(N) forward passes per iteration.

Failure: Raises RuntimeError from solve_nonlinear.

.solve(), auto-select¶

result = sys.solve()                   # auto: forward or gauss_seidel
result = sys.solve(method="gauss_seidel", relaxation=0.7)
result = sys.solve(method="newton")
result = sys.solve(method="forward")

Auto-selection: "forward" if acyclic, "gauss_seidel" if cycles detected.

History access¶

After monitor=True solve:

hist = sys.history()
# [{"iteration": 0, "residual": 0.5, "wallclock": 0.001, "variables": {...}}, ...]

for h in hist:
    print(h["iteration"], h["residual"], h["wallclock"])

Result Object¶

result = sys.solve_forward()

result["thrust"]              # Quantity(32143.09, "N")
result["thrust"].value        # 32143.09  (display value)
result["thrust"].si           # 32143.09  (SI float)
result["thrust"].to("kN")     # Quantity(32.143, "kN")
result["thrust"].to("lbf").value  # 7224.1

result.summary()              # formatted table
result.summary(keys=["thrust", "Isp"])  # subset

result.keys()                 # all variable names
result.to_dict()              # {name: display_value}
result.to_dict(si=True)       # {name: SI_float}
result.to_csv("out.csv")      # variable,value,unit per row
result.to_json("out.json")    # {name: {value, unit}}

Jupyter display: In a Jupyter cell, result renders as a styled HTML table with inputs and outputs separated.


.as_relation(), Composition¶

Wraps a solved System as a Relation for use inside larger Systems.

nozzle_rel = nozzle.as_relation(
    inputs=["P0", "T0", "gamma", "R_gas", "A_throat", "A_exit", "P_amb"],
    outputs=["thrust", "Isp", "mdot"],
)

rocket_stage = anvil.system("full_rocket")
rocket_stage.add("P0", 10e6, "Pa")
rocket_stage.add("T0", 3500, "K")
# ... other inputs ...
rocket_stage.use(nozzle_rel)
rocket_stage.use(tsiolkovsky_rel)  # uses mdot from nozzle
result = rocket_stage.solve_forward()

If inputs or outputs are omitted: - inputs defaults to all _quantities keys - outputs defaults to all relation outputs NOT in _quantities


.optimize(), Design Optimization¶

Vary design variables to minimize (or maximize) a system output, running solve() inside the optimizer loop.

opt = sys.optimize(
    objective,         # str, output quantity name to optimize
    design_vars,       # dict, {var_name: (lo, hi)}
    minimize=True,     # False = maximize
    method="differential_evolution",
    seed=None,         # int, random seed for reproducibility
    tol=1e-6,
    maxiter=1000,
    verbose=False,     # print progress every 25 evals
    **solver_kwargs,   # passed to System.solve() for every evaluation
)

design_vars bounds are in the declared display unit of each variable (same convention as sweep()). The variable must already exist in the system via .add().

**solver_kwargs are forwarded to every solve() call, use this for method="gauss_seidel", relaxation=0.7, max_iter=200 etc. on coupled systems.

Global methods (no gradient, find global optimum): - "differential_evolution" (default), robust, 10-20 variables - "dual_annealing", fewer evaluations, good for noisy objectives - "shgo", for tightly bounded or constrained problems - "basinhopping", smooth, multi-modal landscapes

Gradient methods (faster, smooth objectives only): - "L-BFGS-B", "SLSQP", "Nelder-Mead"

Solve failures: If solve() raises inside the optimizer loop, that evaluation returns a large penalty value (1e30) rather than crashing the optimization. Passes with verbose=True print the failure. If all evaluations fail, opt.success is still True (from the optimizer's perspective) but opt._result will be None.

Returns: OptimizeResult¶

opt.x           # dict: {var_name: optimal_value_in_display_units}
opt.fun         # objective value at optimum
opt.success     # bool
opt.message     # str, optimizer message
opt.nit         # optimizer iterations
opt.nfev        # total System.solve() calls performed

opt["thrust"]   # Quantity at optimum, same as result["thrust"]
"Isp" in opt    # bool, check if key exists in optimum result

opt.summary()   # print design vars + full result table

Jupyter display: opt renders as an HTML table showing status, objective, and design variables.

Examples¶

import numpy as np
import anvil

# --- Maximize nozzle thrust ---
nozzle = anvil.S.rocket_nozzle.copy()
nozzle.set(P0=8e6, T0=3200, gamma=1.25, R_gas=400, P_amb=101325)

opt = nozzle.optimize(
    objective="thrust",
    design_vars={
        "A_throat": (0.002, 0.030),  # bounds in m² (declared unit)
        "A_exit":   (0.010, 0.300),
    },
    minimize=False,
    method="differential_evolution",
    seed=42,
    maxiter=400,
    verbose=True,
)

opt.summary()
print(f"Thrust: {opt.fun/1000:.2f} kN")
print(f"A_throat: {opt.x['A_throat']*1e4:.1f} cm²")
print(f"Isp: {float(opt['Isp'].value):.1f} s")   # subscript access

# --- Gradient method for smooth landscapes ---
opt_fast = nozzle.optimize(
    objective="Isp",
    design_vars={"A_throat": (0.005, 0.020), "A_exit": (0.05, 0.25)},
    minimize=False,
    method="L-BFGS-B",
    maxiter=200,
)

# --- Coupled system: pass solver_kwargs ---
hx = anvil.system("heat_exchanger")
# ... setup ...
opt_hx = hx.optimize(
    objective="Q_duty",
    design_vars={"mdot_h": (0.1, 2.0), "mdot_c": (0.1, 2.0)},
    minimize=False,
    method="differential_evolution",
    # These go to System.solve():
    method_solve="gauss_seidel",  # ← note: solver_kwargs cannot reuse 'method'
    relaxation=0.7,
    max_iter=200,
)

method collision: System.optimize() takes its own method for the optimizer. To control the inner solve() method, wrap it: pass solve_method="gauss_seidel" and do sys.solve(method=solver_kwargs.pop("solve_method", None), **solver_kwargs). Or subclass with a custom solve_for_opt() method.

Performance¶

Situation Typical evals
2 design vars, DE 500-2000
4 design vars, DE 2000-8000
Any n vars, L-BFGS-B (smooth) 50-500
Noisy objective Prefer DA or increase DE population

Each eval is one System.solve() call. For coupled systems (Gauss-Seidel), this is O(max_iter × n_relations) function evaluations per optimizer step.


.info(), Print Summary¶

print(sys.info())
# System: my_nozzle
#   Inputs:
#     P0                    6.9e+06 Pa
#     T0                    3500 K
#   Relations:
#     nozzle_area_ratio
#     area_mach_supersonic
#     ...

__repr__()¶

repr(sys)
# System('rocket_nozzle', 7 vars, 8 relations)

anvil.solve(), one-shot solve¶

Creates a temporary System with no name, adds all kwargs as inputs:

result = anvil.solve(isentropic, M=2.0, gamma=1.4)
result["T0_T"]   # 1.8

result = anvil.solve("normal_shock", M1=2.5)
result["M2"]     # ~0.513

Validation Errors¶

Error Cause
ValidationError: X needs Y -- not provided Relation requires input Y, not declared and no default
ValidationError: X produced by both A and B Two relations write the same output name
ValidationError: X is NaN or Inf NaN/Inf in declared quantity
RuntimeError: Not converged after N iters Gauss-Seidel / Newton hit iteration limit

DOF (Degrees of Freedom) Warnings¶

validate() does not verify that the system is square (equal equations and unknowns), but it emits two targeted warnings to catch the most common mistakes:

Warning 1, declared variable overwritten by relation:

sys.add("T0_T", 999.0)       # declared as input
sys.use("isentropic_ratios")  # also produces T0_T

sys.validate()
# WARNING: variable(s) declared via .add() are also produced by a relation, 
#   declared value will be overwritten after solve: ['T0_T']
#   (This is intentional for iterative initial guesses; for forward
#   systems it may indicate a naming mismatch.)

This is intentional for Gauss-Seidel initial guesses. For forward-pass systems it usually indicates a naming bug.

Warning 2, declared variable never used:

sys.add("extra_var", 42.0)  # not an input to any relation

sys.validate()
# WARNING: variable(s) declared via .add() are not used by any relation: ['extra_var']
#   (Possible typo or unused parameter.)

Suppression: Both warnings fire only once per system instance, not on every sweep iteration or repeated solve. A fresh anvil.system() or sys.copy() resets the flag.

What is still NOT checked: Whether the number of relation outputs equals the number of coupled unknowns. An underdetermined system (e.g. 3 unknowns, 2 equations where nothing downstream needs the 3rd) silently produces incomplete results with no error.


Performance Notes¶

  • solve_forward() on a 10-relation acyclic system: <1 ms
  • solve_gauss_seidel() converging in 33 iterations (heat exchanger): ~5 ms
  • solve_newton() convergence in ~5 iterations for moderate systems: ~3 ms
  • sweep() with 50 points, single-thread: ~linear in n_points × solve_time
  • sweep(parallel=4) uses ThreadPoolExecutor, GIL-releasing NumPy/SciPy is faster; pure Python gains limited by GIL

The _qty_compatible cache on each Relation skips the try/except overhead after the first call. For iterative solvers calling relations hundreds of times, this matters.

Solvers¶

All solvers are in anvil.solvers (or from anvil import solvers). They are thin, opinionated wrappers around SciPy with sensible defaults and progress output.

from anvil import solvers
# or
import anvil
anvil.solvers.find_root(...)

find_root, Scalar Root Finding¶

Find x such that f(x) = 0.

x = solvers.find_root(func, x0=None, bracket=None, method="auto", tol=1e-12, maxiter=100)
Parameter Type Default Description
func callable required f(x) → float
x0 float None Initial guess (Newton/secant)
bracket (a, b) None Interval containing root (Brent)
method str "auto" "brent", "newton", "secant"
tol float 1e-12 Tolerance
maxiter int 100 Iteration limit

Method selection: - "auto" + bracket → "brent" - "auto" + x0 → "newton" - Neither → ValueError: Provide bracket=(a,b) or x0=guess.

SciPy backend: brentq (brent), newton (newton/secant)

Examples¶

import numpy as np

# Brent's method, needs sign change in bracket
f = lambda x: x**3 - 2*x - 5
root = solvers.find_root(f, bracket=(2.0, 3.0))
# root = 2.0945514815  (exact to 1e-12)

# Newton's method, needs initial guess
root = solvers.find_root(f, x0=2.5, method="newton")
# root = 2.0945514815

# Used in built-in RSQs (e.g., area_mach_supersonic)
def residual(M, area_ratio=8.0, gamma=1.4):
    t = (2/(gamma+1))*(1+(gamma-1)/2*M**2)
    return (1/M)*t**((gamma+1)/(2*(gamma-1))) - area_ratio
M = solvers.find_root(residual, bracket=(1.001, 30.0))
# M = 3.9155...

Performance¶

1000 calls (brent, 1e-12 tol): ~10 ms total (~10 µs/call)

Limits and Errors¶

Situation Result
No sign change in bracket ValueError: f(a) and f(b) must have different signs
Function has no root in bracket Same ValueError
Newton doesn't converge RuntimeError (from scipy)
No bracket and no x0 ValueError: Provide bracket=(a,b) or x0=guess.
Function with singularity in bracket Brent may return NaN or incorrect result

solve_nonlinear, System of Nonlinear Equations¶

Find x such that F(x) = 0 for vector-valued F.

x = solvers.solve_nonlinear(func, x0, method="hybr", tol=1e-10, maxiter=200, jac=None)
Parameter Type Default Description
func callable required F(x) → array same length as x
x0 array-like required Initial guess
method str "hybr" SciPy root method
tol float 1e-10 Tolerance
maxiter int 200 Iteration limit
jac callable None Jacobian df/dx; estimated by FD if None

Methods:

Method Best for
"hybr" General nonlinear systems (Powell hybrid), default
"lm" Overdetermined or ill-conditioned systems (Levenberg-Marquardt)
"broyden1" Large sparse systems (quasi-Newton)

SciPy backend: scipy.optimize.root

Returns: ndarray of solution x. Raises RuntimeError if not converged.

Example¶

import numpy as np

# Intersection of circle and line
def F(x):
    return [x[0]**2 + x[1]**2 - 1,  # x² + y² = 1
            x[0] - x[1]]               # x = y

x_sol = solvers.solve_nonlinear(F, [0.5, 0.5])
# x_sol = [0.70710678, 0.70710678]  (1/√2, 1/√2)

solve_ode, Explicit ODE (non-stiff)¶

Integrate dy/dt = f(t, y) using explicit Runge-Kutta methods.

r = solvers.solve_ode(func, t_span, y0,
    method="RK45",
    t_eval=None,
    rtol=1e-8,
    atol=1e-10,
    max_step=np.inf,
    events=None,
    verbose=False)
Parameter Default Description
func required f(t, y) → dy/dt array
t_span required (t0, tf) integration interval
y0 required Initial conditions, length n
method "RK45" Integration method (see table)
t_eval None Times to store output; None → adaptive
rtol 1e-8 Relative tolerance
atol 1e-10 Absolute tolerance
max_step np.inf Maximum step size
events None Event functions; zeros trigger stop
verbose False Print progress every 10% of interval

Methods:

Method Order Best for
"RK45" 4/5 General non-stiff (default)
"RK23" 2/3 Low accuracy or fast solutions needed
"DOP853" 8/5/3 High accuracy, smooth problems

Returns dict:

Key Description
"t" Time array, shape (n_steps,)
"y" Solution, shape (n_states, n_steps)
"success" bool, converged?
"message" str, solver message
"nfev" int, function evaluations
"sol" Dense output callable: sol.sol(t) → y(t)

Note: "sol" key contains the full SciPy OdeResult object. Call r["sol"].sol(t_array) for dense interpolation.

Examples¶

import numpy as np

# Harmonic oscillator: y'' = -y
def harmonic(t, y):
    return [y[1], -y[0]]

t_eval = np.linspace(0, 2*np.pi, 100)
r = solvers.solve_ode(harmonic, (0, 2*np.pi), [0.0, 1.0], t_eval=t_eval)
# r["y"][0, -1] ≈ 0.0  (y(2π) = sin(2π) = 0)
# r["nfev"] = 506

# Event-driven: stop when y[0] = 0.5
def hit_half(t, y): return y[0] - 0.5
hit_half.terminal = True
r = solvers.solve_ode(harmonic, (0, 10), [0.0, 1.0], events=hit_half)

Failure / success flag¶

Important: r["success"] may be True even when the solution is numerically garbage. Example: a function with a singularity may appear to succeed but produce nonsensical values:

# f blows up but ODE "succeeds"
r = solvers.solve_ode(lambda t, y: [1.0/(y[0]-0.5)], (0, 0.4), [0.0])
print(r["success"])   # True, SciPy says OK

Always sanity-check the output.


solve_ode_stiff, Implicit ODE (stiff)¶

r = solvers.solve_ode_stiff(func, t_span, y0,
    method="BDF",
    t_eval=None,
    rtol=1e-6,
    atol=1e-10,
    jac=None,
    events=None,
    verbose=False)

Same interface as solve_ode with two additional parameters:

Parameter Default Description
method "BDF" "BDF" or "Radau"
jac None Jacobian df/dy; estimated by FD if None

Methods:

Method Best for
"BDF" Large stiff systems, combustion kinetics (default)
"Radau" Small stiff systems, higher accuracy

Stiff systems: ODEs with widely separated time scales. Combustion (fast chemistry + slow heat release), electrical circuits, diffusion-reaction equations.

Failure: Raises RuntimeError with diagnostic message if not verbose:

RuntimeError: Stiff ODE solver (BDF) failed: [message]
  Try: looser tolerances (rtol=1e-4), different method, or provide a Jacobian via jac=.

With verbose=True: prints progress, returns result even on failure.

Example¶

# Stiff exponential decay with forcing
def stiff_decay(t, y):
    return [-1000*y[0] + 3000 - 2000*np.exp(-t),
            -1000*(y[0] - np.exp(-t))]

r = solvers.solve_ode_stiff(stiff_decay, (0, 0.05), [0.0, 0.0], rtol=1e-4)
print(r["success"])   # True
print(r["nfev"])      # much fewer evals than RK45 would need

solve_bvp, Boundary Value Problem¶

Solve dy/dx = f(x, y) with bc(y(a), y(b)) = 0.

r = solvers.solve_bvp(func, bc, x, y_init,
    tol=1e-3,
    max_nodes=1000,
    verbose=False)
Parameter Description
func f(x, y) → dy/dx, shape (n,) or (n, m)
bc bc(ya, yb) → residual, length n
x Initial mesh, 1D array from a to b
y_init Initial guess, shape (n, len(x))
tol Residual tolerance (default 1e-3)
max_nodes Maximum mesh refinement nodes
verbose Detailed solver output

Returns dict:

Key Description
"x" Final mesh, shape (m,)
"y" Solution at mesh, shape (n, m)
"success" bool
"message" str
"residual" RMS residuals array
"sol" Dense callable: sol(x) → y(x)

SciPy backend: scipy.integrate.solve_bvp

Failure: Raises RuntimeError if not verbose.

Example: y'' = -y, y(0) = 0, y(π) = 0¶

import numpy as np

def f(x, y):
    return np.vstack([y[1], -y[0]])    # [y', y''] = [y[1], -y[0]]

def bc(ya, yb):
    return np.array([ya[0], yb[0]])    # y(0)=0, y(π)=0

x_init = np.linspace(0, np.pi, 5)
y_init = np.zeros((2, 5))
y_init[0] = np.sin(x_init)            # initial guess: sin(x)

r = solvers.solve_bvp(f, bc, x_init, y_init)

x_fine = np.linspace(0, np.pi, 100)
y_fine = r["sol"](x_fine)[0]
# y_fine[50] ≈ 1.0  (sin(π/2) = 1)

Accuracy¶

With default tol=1e-3:

y(π/2) = 1.000000  (exact: 1.0)

BVP mesh adapts automatically, more nodes where curvature is high. max_nodes=1000 caps mesh refinement.


solve_pde_heat_1d, 1D Parabolic PDE¶

Solve ∂u/∂t = α · ∂²u/∂x² + f(x, t, u) using the Crank-Nicolson scheme.

r = solvers.solve_pde_heat_1d(
    alpha,          # diffusivity [m²/s]
    x_span,         # (x_left, x_right)
    t_span,         # (t_start, t_end)
    u_init,         # callable u(x) or array of length nx
    bc_left=None,   # Dirichlet left: float or callable(t); None = zero-flux Neumann
    bc_right=None,  # Dirichlet right: float or callable(t); None = zero-flux Neumann
    source=None,    # source term f(x, t, u) → array; None = no source
    nx=100,         # spatial grid points
    nt=None,        # time steps; auto if None (CFL-like estimate)
    verbose=False,
)

Returns dict:

Key Shape Description
"x" (nx,) Spatial grid
"t" (nt+1,) Time array
"u" (nt+1, nx) Solution at all times
"dx" float Grid spacing
"dt" float Time step used

Crank-Nicolson scheme¶

Unconditionally stable, 2nd-order accurate in both space and time. The tridiagonal system is solved at each time step using scipy.linalg.solve_banded.

Automatic nt selection: dt ≈ min(0.25 × dx²/α, (tf-t0)/100). This targets temporal accuracy, not just stability.

Boundary conditions: - Float or callable → Dirichlet (fixed value) - None → zero-flux Neumann (∂u/∂x = 0 at boundary via ghost point) - Callable signature: bc_left(t) → float

Source term: source(x, t, u) → ndarray of length nx. Half-step approximation: 0.5*dt*(f_n + f_{n+1}).

Examples¶

import numpy as np

# Heat pulse diffusing with Dirichlet = 0 at both ends
r = solvers.solve_pde_heat_1d(
    alpha=1e-5,
    x_span=(0, 1),
    t_span=(0, 10),
    u_init=lambda x: np.exp(-100*(x-0.5)**2),
    bc_left=0.0,
    bc_right=0.0,
    nx=100,
)
# r["u"][-1], final temperature profile at t=10s
# r["u"][0] , initial profile

# Accuracy check: sin(πx) initial condition decays as exp(-π²αt)sin(πx)
r2 = solvers.solve_pde_heat_1d(
    alpha=1e-4,
    x_span=(0, 1), t_span=(0, 1),
    u_init=lambda x: np.sin(np.pi*x),
    bc_left=0.0, bc_right=0.0, nx=50,
)
numerical  = r2["u"][-1].max()     # 0.998501
analytical = np.exp(-np.pi**2*1e-4)  # 0.999014
# Relative error: 5.13e-4 (0.05%)

# Time-varying boundary condition
r3 = solvers.solve_pde_heat_1d(
    alpha=1e-5,
    x_span=(0, 1), t_span=(0, 100),
    u_init=lambda x: np.zeros_like(x),
    bc_left=lambda t: 100.0 * np.sin(0.1*t), # oscillating left BC
    bc_right=0.0,
    nx=50,
)

Limits¶

Limit Value/Notes
Spatial accuracy 2nd order in dx
Temporal accuracy 2nd order in dt (Crank-Nicolson)
Stability Unconditionally stable (any dt)
Source accuracy Half-step (1st order in source nonlinearity)
Dimensions 1D only
Geometry Uniform Cartesian grid only
BC types Dirichlet or zero-flux Neumann only
Nonlinear diffusion Not supported (α must be constant)

minimize, Scalar Optimization¶

Minimize f(x) where x is a vector.

r = solvers.minimize(func, x0,
    method="L-BFGS-B",
    bounds=None,
    tol=1e-8,
    maxiter=500,
    jac=None)

Returns dict:

Key Description
"x" Optimal solution
"fun" Objective value at optimum
"success" bool
"message" str
"nit" Iterations

Methods:

Method Supports bounds Gradient Best for
"L-BFGS-B" Yes Numerical or analytical General, large-scale (default)
"SLSQP" Yes Numerical Equality + inequality constraints
"Nelder-Mead" No None Gradient-free, noisy functions
"COBYLA" No None Constrained, no gradient

Bounds: List of (lo, hi) per dimension. None in a tuple = unbounded:

bounds=[(0, None), (0, 10), (None, None)]  # x[0]≥0, 0≤x[1]≤10, x[2] unbounded

Examples¶

# Simple quadratic
r = solvers.minimize(lambda x: (x[0]-1)**2 + (x[1]-2)**2, [0.0, 0.0])
# r["x"] = [1.0, 2.0]  r["fun"] = 5e-17

# Rosenbrock, classic test
from scipy.optimize import rosen
r = solvers.minimize(rosen, [0.0, 0.0], method="L-BFGS-B")
# r["x"] ≈ [1.0, 1.0]   r["fun"] ≈ 0

# With bounds and analytical Jacobian
def f_and_grad(x):
    val = x[0]**2 + x[1]**2
    grad = [2*x[0], 2*x[1]]
    return val, grad
r = solvers.minimize(
    lambda x: f_and_grad(x)[0], [5.0, 5.0],
    bounds=[(1, None), (1, None)],
    jac=lambda x: f_and_grad(x)[1],
)
# r["x"] = [1.0, 1.0]  (constrained minimum)

minimize_global, Global Optimization¶

Minimize f(x) over bounded domains without requiring gradients. Uses population-based and stochastic methods that can escape local minima.

r = solvers.minimize_global(func, bounds,
    method="differential_evolution",
    seed=None,
    maxiter=1000,
    tol=1e-6,
    workers=1,
    callback=None,
    verbose=False)
Parameter Default Description
func required f(x) → float. Must accept a 1D numpy array
bounds required [(lo, hi), ...], one per dimension
method "differential_evolution" See table below
seed None Integer seed for reproducibility
maxiter 1000 Maximum iterations (DE) or function evals (DA/BH)
tol 1e-6 Convergence tolerance
workers 1 Parallel workers for "differential_evolution" only. -1 = all CPUs. Requires func to be picklable (module-level, not a closure).
callback None Called each iteration; signature is method-specific (passed through to scipy)
verbose False Print start/finish summary

Returns dict: same shape as minimize():

Key Description
"x" Optimal solution array
"fun" Objective value at optimum
"success" bool
"message" str, solver message
"nit" Iterations (0 for methods that don't track this)
"nfev" Function evaluations

Methods:

Method Best for Gradient Parallelizable
"differential_evolution" Continuous, multi-modal, general (default) No Yes (picklable func only)
"dual_annealing" Escaping deep local minima, fewer evals No No
"shgo" Constrained global opt, tight bounds No No
"basinhopping" Smooth multi-modal, needs gradient descent No No

SciPy backends: differential_evolution, dual_annealing, shgo, basinhopping

vs minimize(): Use minimize_global when you don't know where the global optimum is or when the objective has multiple local minima. It is slower (many function evaluations) but does not need a starting point or gradient. Use minimize() when you have a good initial guess and the landscape is smooth.

Examples¶

# Himmelblau's function, 4 global minima at f=0
def himmelblau(x):
    return (x[0]**2 + x[1] - 11)**2 + (x[0] + x[1]**2 - 7)**2

r = solvers.minimize_global(himmelblau, bounds=[(-5,5),(-5,5)], seed=0)
# r["fun"] ≈ 0.0, r["x"] ≈ [3.0, 2.0]  (one of four minima)

# Maximize thrust: pass negated objective
def neg_thrust(x):
    A_throat, A_exit = x[0], x[1]
    # ... compute thrust ...
    return -thrust

r = solvers.minimize_global(neg_thrust, bounds=[(0.002, 0.05), (0.01, 0.3)],
                            method="dual_annealing", seed=42)
best_thrust = -r["fun"]

# Reproducible: same seed → same result
r1 = solvers.minimize_global(rosen, [(-2,2),(-2,2)], seed=7)
r2 = solvers.minimize_global(rosen, [(-2,2),(-2,2)], seed=7)
assert r1["fun"] == r2["fun"]

# Verbose summary
r = solvers.minimize_global(func, bounds, method="differential_evolution",
                            verbose=True)
# Output:
#   minimize_global: method='differential_evolution'  ndim=2  maxiter=1000
#   minimize_global converged: 4023 evals  f_best = 1.23e-08

Limits¶

Issue Detail
workers != 1 + closure DE with workers>1 uses ProcessPoolExecutor; closures aren't picklable. Use workers=1 or a module-level function.
No gradient All methods are gradient-free → many more evaluations than minimize().
shgo on high dimensions SHGO memory usage grows exponentially with dimension. Use DE or DA for ndim > 6.
basinhopping Uses L-BFGS-B locally; very noisy objectives may not converge.

Summary Table¶

Solver Problem type SciPy backend Key params
find_root f(x)=0, scalar brentq, newton bracket, x0, method
solve_nonlinear F(x)=0, vector root method, jac
solve_ode dy/dt=f, non-stiff solve_ivp method, t_eval, rtol/atol
solve_ode_stiff dy/dt=f, stiff solve_ivp method, jac
solve_bvp BVP solve_bvp tol, max_nodes
solve_pde_heat_1d 1D parabolic PDE tridiagonal (scipy) alpha, nx, bc_*
minimize min f(x), gradient-based minimize method, bounds, jac
minimize_global min f(x), global de, da, shgo, bh method, bounds, seed

Accuracy Benchmarks¶

Test Numerical Analytical Relative error
find_root (brent, tol=1e-12) 2.09455148154 2.09455148154 <1e-13
solve_ode harmonic y(2π) 0.00000000 0.0 ~1e-12
solve_pde_heat_1d sin decay 0.998501 0.999014 5e-4
solve_nonlinear circle [0.7071068, 0.7071068] [1/√2, 1/√2] <1e-10

Registry¶

The Anvil registry is a SQLite database at ~/.anvil/registry.db. It stores RSQs (Relations, Systems, Quantities) with metadata, source code, and version info. It's auto-seeded with 57 built-in RSQs on first import.


RSQ Types¶

Type Description Accessed via
"R" Relation, a computation function anvil.R.<name>
"S" System, a pre-built solvable problem anvil.S.<name>
"Q" Quantity, a named constant anvil.QDB.<name>

Exploring the Registry¶

anvil.registry.list(), list RSQs¶

import anvil

anvil.registry.list()                      # all RSQs
anvil.registry.list(domain="aero")         # filter by domain
anvil.registry.list(type="R")             # only Relations
anvil.registry.list(tag="combustion")      # filter by tag

Output (excerpt):

[R] isentropic_ratios             [aero.compressible]  v0.1.0  (builtin)
    Isentropic flow relations: T0/T, P0/P, rho0/rho

[R] normal_shock                  [aero.compressible]  v0.1.0  (builtin)
    Normal shock relations
...

anvil.registry.search(keyword), fuzzy search¶

Searches name, description, domain, and tags:

anvil.registry.search("shock")
anvil.registry.search("pid")
anvil.registry.search("rayleigh")

Output:

[R] normal_shock                   [compressible, shock]
       Normal shock relations
[R] oblique_shock                  [compressible, shock, oblique, wedge]
       2D oblique shock: shock angle, downstream M, pressure/temperature ratios

  2 result(s)

anvil.registry.info(name), detailed metadata¶

anvil.registry.info("isentropic_ratios")

Output:

  isentropic_ratios
  --------------------------------------------------
  Domain:      aero.compressible
  Version:     0.1.0
  Origin:      builtin
  Author:      (none)
  Description: Isentropic flow relations: T0/T, P0/P, rho0/rho
  Tags:        compressible, isentropic, mach
  Inputs:
    M          (no default)
    gamma      default=1.4

anvil.registry.export(name), print source code¶

anvil.registry.export("isentropic_ratios")

Output:

# RSQ: isentropic_ratios (v0.1.0, R)
# Domain: aero.compressible
# Origin: builtin
# Tags: compressible, isentropic, mach

def isentropic_ratios(M, gamma=1.4):
    T_ratio = 1 + ((gamma - 1) / 2) * M**2
    P_ratio = T_ratio ** (gamma / (gamma - 1))
    rho_ratio = T_ratio ** (1 / (gamma - 1))
    return {"T0_T": T_ratio, "P0_P": P_ratio, "rho0_rho": rho_ratio}
export = isentropic_ratios

anvil.registry.remove(name)¶

anvil.registry.remove("my_rsq")
# Removed 'my_rsq'.

Permanently deletes from the SQLite database. Cannot be undone (re-push to restore).


Registering RSQs¶

anvil.push(), register¶

anvil.push(
    obj,                   # function, Relation, System, or Quantity
    name=None,             # defaults to function.__name__
    domain="",             # hierarchical: "aero.compressible"
    version="0.0.1",
    description="",
    author="",
    tags=None,             # list of strings
    tests=None,            # dict of test cases (future feature)
    depends=None,          # list of dependency RSQ names
)
def my_drag(CL, CD0, AR, e=0.85):
    CDi = CL**2 / (3.14159 * AR * e)
    return {"CD": CD0 + CDi, "CDi": CDi}

anvil.push(my_drag,
    domain="aero",
    description="Drag polar with induced drag",
    tags=["aerodynamics", "drag", "induced"],
    version="1.0.0",
)

# Now available globally
anvil.R.my_drag(CL=0.5, CD0=0.02, AR=8)

Origin: RSQs pushed via anvil.push() have origin="local". Built-ins have origin="builtin".

Duplicate warning: Pushing a name that already exists in the local registry raises UserWarning. Use anvil.update() to signal intentional overwrite, update() never shows this warning.

Namespace rebuild: anvil.push() and anvil.update() both rebuild anvil.R.* / anvil.S.* / anvil.QDB.* automatically. anvil.R.<name> is accessible immediately after the call, no session restart needed:

anvil.push(my_func, name="my_rsq")
anvil.R.my_rsq(x=1.0)   # works immediately

anvil.update(), update existing¶

anvil.update(
    obj,
    name=None,
    domain=None,        # None = keep existing
    version=None,       # None = keep existing
    description=None,
    author=None,
    tags=None,
)
# Fix a bug in my_drag
def my_drag_v2(CL, CD0, AR, e=0.85):
    CDi = CL**2 / (3.14159 * AR * e)
    CD = CD0 + CDi
    LoD = CL / CD
    return {"CD": CD, "CDi": CDi, "LoD": LoD}

anvil.update(my_drag_v2, name="my_drag", version="1.1.0")
# Updated 'my_drag'.

update() is semantically "intentional overwrite", no duplicate warning. Merges only the fields you pass; omitted fields keep their current values. Rebuilds anvil.R.* automatically.


Using Registry RSQs¶

Dot-namespace access¶

anvil.R.isentropic_ratios(M=2.0, gamma=1.4)
# → {"T0_T": 1.8, "P0_P": 7.824, "rho0_rho": 4.347}

anvil.S.rocket_nozzle.copy()
# → deep copy of the pre-built System

anvil.QDB.g0
# → Q(9.80665, "m/s^2")  (standard gravity)

String lookup in System¶

sys.use("isentropic_ratios")
sys.use("normal_shock", map={"M1": "M_upstream"})

anvil.R.<name> vs anvil.registry.search()¶

  • anvil.R.name, attribute access, returns the live Relation/System/Quantity object
  • anvil.registry.search("name"), returns metadata records (dicts from SQLite), not live objects

anvil.check(), Health Check¶

report = anvil.check("isentropic_ratios")
# or
report = anvil.check("rocket_nozzle")
# or
report = anvil.check(my_relation_object)

Output for a Relation:

============================================================
  anvil.check('isentropic_ratios')  [PASS]
============================================================
  Type:        Relation
  Domain:      aero.compressible
  Description: Isentropic flow relations: T0/T, P0/P, rho0/rho
  Version:     0.1.0
  Inputs:      M, gamma
  Outputs:     P0_P, T0_T, rho0_rho
  Defaults:    gamma=1.4

  --- Test Run ---
    T0_T: 1.2
    P0_P: 1.8929291587378542
    rho0_rho: 1.5774409656148785

  No issues found.
============================================================

Return value: dict with keys:

Key Type Description
"ok" bool Overall health
"name" str RSQ name
"type" str "R", "S", "Q"
"inputs" list Input names
"outputs" list Output names
"defaults" dict Default values
"depends" list Dependency names
"tree" str Printable dependency tree
"issues" list Problems found
"test_result" dict Test run outputs

verbose=False, suppresses print, returns report dict only:

report = anvil.check("isentropic_ratios", verbose=False)
report["ok"]       # True
report["inputs"]   # ['M', 'gamma']
report["outputs"]  # ['P0_P', 'T0_T', 'rho0_rho']

anvil.fetch(), Load by Name/Domain/Tag¶

anvil.fetch("pid_output")          # by name
anvil.fetch("aero.compressible")   # by domain, loads all in that domain
anvil.fetch("combustion")          # by tag

Primarily useful for refreshing the namespace after direct database manipulation. Not needed after anvil.push() or anvil.update(), those rebuild automatically.


Store, SQLite Backend¶

The underlying store is anvil.registry.store.Store, backed by ~/.anvil/registry.db.

Schema (RSQ table)¶

Column Type Description
id INTEGER Primary key
name TEXT RSQ name (unique per origin)
type TEXT "R", "S", "Q"
domain TEXT Hierarchical domain string
version TEXT Semantic version
description TEXT Human description
author TEXT Author name
source TEXT Python source code string
metadata JSON Input/output specs
tests JSON Test cases
hash TEXT SHA-256 of source
origin TEXT "builtin", "local", "project"
created_at TEXT ISO timestamp
updated_at TEXT ISO timestamp

Plus tags and dependencies tables linked by foreign key.

Upsert behavior¶

store.put() upserts on (name, origin). Two RSQs with the same name but different origins coexist. When store.get(name) is called, local-origin RSQs take priority over builtin-origin RSQs with the same name.


Seeding¶

The registry is seeded with 57 built-in RSQs via anvil.seed.seed() on first import:

from anvil.seed import seed
seed()          # skips if all builtins already present
seed(force=True)  # always upserts all builtins

Seed logic (post-fix): Compares {e["name"] for e in _SEED_ENTRIES} against existing builtin-origin names in the DB. Seeds only if builtins are missing. This ensures new RSQs added to seed.py are picked up on next import without nuking user's local RSQs.


Domain Conventions¶

Domains are hierarchical dotted strings. Convention used in built-ins:

Domain Contents
const Physical constants
aero General aerodynamics
aero.atmosphere ISA atmosphere
aero.compressible Isentropic, shock, Rayleigh
aero.performance Lift, drag, range
propulsion Nozzle, thrust, Isp
thermo Ideal gas, viscosity
heat_transfer Conduction, convection, radiation
structures Stress, beam, buckling
controls PID, step response, stability
materials Fatigue, fracture, composites
orbital Vis-viva, Hohmann, period

User domains can be any string; recommended to follow the category.subcategory pattern.

Project Registry¶

A project registry is an isolated SQLite store for RSQs that are under development. It prevents polluting the global registry (~/.anvil/registry.db) while you iterate on new relations.


Creating a Project¶

proj = anvil.project("my_study", path="./work")
# Output: Project 'my_study' opened  (work/.anvil/project_my_study.db)
Parameter Default Description
name required Project name (used in DB filename)
path os.getcwd() Directory for .anvil/ folder

Database location: {path}/.anvil/project_{name}.db

Opening the same project twice (same name + path) reopens the existing database.


Registering RSQs¶

proj.push(
    obj,                # function, Relation, System, or Quantity
    name=None,          # defaults to obj.__name__
    domain="",
    version="0.0.1",
    description="",
    author="",
    tags=None,
    tests=None,
    depends=None,
)

Accepts the same object types as anvil.push().

def rayleigh_ratios(M, gamma=1.4):
    g = float(gamma); M = float(M)
    gp1 = g + 1
    denom = 1 + g * M**2
    T_Tstar = (gp1 * M / denom)**2
    return {"T_Tstar": T_Tstar, "P_Pstar": gp1/denom}

proj.push(rayleigh_ratios,
          domain="aero.compressible",
          description="Rayleigh flow ratios",
          tags=["rayleigh", "compressible"])
# [my_study] Registered 'rayleigh_ratios' (R) in domain 'aero.compressible'.

Origin: Project RSQs have origin="project".


Using Project RSQs¶

Direct call¶

r = proj.R.rayleigh_ratios(M=0.5, gamma=1.4)
print(r["T_Tstar"])   # 0.790123

In a System¶

sys = anvil.system("duct")
sys.add("M", 0.5)
sys.add("gamma", 1.4)
sys.use(proj.R.rayleigh_ratios)    # pass the live Relation object
result = sys.solve_forward()

Global RSQs still accessible¶

Project and global registries are fully independent. While a project is open, anvil.R.* still accesses global RSQs:

# Global RSQs unaffected
anvil.R.isentropic_ratios(M=2.0)   # works fine
proj.R.rayleigh_ratios(M=0.5)      # project RSQ

Listing and Searching¶

proj.list()
# Project: my_study  (./work)
#
# Relations (2):
#   rayleigh_ratios              [aero.compressible]
#     Rayleigh flow ratios
#   rayleigh_heat                [aero.compressible]
#     Rayleigh flow with heat addition
#
# Total: 2 RSQs

proj.list(domain="aero.compressible")  # filter by domain
proj.list(rsq_type="R")                # filter by type

proj.search("rayleigh")
# [R] rayleigh_ratios              [rayleigh, compressible]
# [R] rayleigh_heat                [rayleigh, compressible, combustion]

Removing RSQs¶

proj.remove("old_draft")
# [my_study] Removed 'old_draft'.

Promoting to Global Registry¶

When an RSQ is ready, promote it to the global registry:

proj.promote("rayleigh_ratios")
# 'rayleigh_ratios' promoted from project 'my_study' to global registry.

# If the name already exists in global:
proj.promote("rayleigh_ratios", overwrite=True)   # replaces existing
proj.promote("rayleigh_ratios")                   # raises ValueError
# Promote everything
proj.promote_all(overwrite=False)   # skips existing
proj.promote_all(overwrite=True)    # replaces existing

After promotion, the RSQ is accessible via anvil.R.*:

anvil.R.rayleigh_ratios(M=0.5)   # now works globally

Context Manager, Auto-route anvil.push()¶

The context manager routes anvil.push() calls to the project store automatically:

with anvil.project("my_study", path="./work") as proj:
    # anvil.push() goes to project, not global
    @anvil.relation(domain="aero", register=False)
    def draft_relation(UA, C_min):
        return {"NTU": UA / C_min}

    proj.push(draft_relation)        # → project store
    anvil.R.isentropic_ratios(M=2)   # global still works

# Outside the with block, promote when satisfied
proj.promote("draft_relation")

How it works internally: - Project.__enter__() calls _set_active_project(self), sets a module-level _active_project - anvil.push() checks get_active_project(), if set, routes to project store - Project.__exit__() calls _set_active_project(None), clears active project

Warning: _active_project is a module-level global. Nested with anvil.project(...) blocks are not safe, the inner one overrides the outer. Use explicit proj.push() for nested workflows.


Full Workflow Example¶

From examples/ex17_rayleigh_flow.py:

import anvil
from anvil import Q, solvers
import numpy as np

# 1. Define RSQs as plain Python functions
def rayleigh_ratios(M, gamma=1.4):
    g = float(gamma); M = float(M)
    gp1 = g + 1; denom = 1 + g * M**2
    T_Tstar = (gp1 * M / denom)**2
    T0_T0star = 2 * gp1 * M**2 * (1 + (g-1)/2*M**2) / denom**2
    return {"T0_T0star": T0_T0star, "T_Tstar": T_Tstar,
            "P_Pstar": gp1/denom}

def rayleigh_heat(M1, T01, P1, q_heat, cp, gamma=1.4):
    g = float(gamma); M1 = float(M1)
    T01 = float(getattr(T01, "si", T01))
    # ... (see ex17_rayleigh_flow.py for full implementation)
    return {"M2": M2, "T02": Q(T02, "K"), "P02_P01": P02/P01}

# 2. Push to project
proj = anvil.project("rayleigh_study", path="./rayleigh_work")
proj.push(rayleigh_ratios, domain="aero.compressible", tags=["rayleigh"])
proj.push(rayleigh_heat,  domain="aero.compressible", tags=["rayleigh"])

# 3. Use in System
duct = anvil.system("rayleigh_duct")
duct.add("M1", 0.3); duct.add("T01", 400.0, "K")
duct.add("P1", 200e3, "Pa"); duct.add("q_heat", 300e3, "J/kg")
duct.add("cp", 1005.0, "J/kg/K"); duct.add("gamma", 1.4)
duct.use(proj.R.rayleigh_heat)
result = duct.solve_forward()
# M2 = 0.445, T02 = 698.5 K, P02_P01 = 0.949

# 4. Sweep
q_choke = 757e3  # from rayleigh_ratios
sweep = duct.sweep("q_heat", np.linspace(0, 0.8*q_choke, 30), skip_errors=True)
sweep.summary(outputs=["M2", "T02", "P02_P01"])

# 5. Promote when ready
# proj.promote("rayleigh_ratios")
# proj.promote("rayleigh_heat")

Project Object Attributes¶

proj.name        # str, project name
proj._store      # Store, SQLite backend
proj._path       # Path, filesystem path
proj.R           # Namespace, project Relation access
proj.S           # Namespace, project System access
proj.Q           # Namespace, project Quantity access

repr(proj)       # <Project 'my_study': 2 RSQs at ./work>

Comparison: Project vs Global Registry¶

Feature Global (anvil.push) Project (proj.push)
Database ~/.anvil/registry.db {path}/.anvil/project_{name}.db
Scope All sessions, all scripts This project only
Access anvil.R.* proj.R.*
Origin "local" "project"
Shared Yes (per user) No
Lifetime Until anvil.registry.remove() Until proj.remove() or DB deleted
Promote to global N/A proj.promote()

Built-in RSQs¶

166 RSQs (157 relations, 4 quantity sets, 5 systems) across 33 domains, auto-seeded into ~/.anvil/registry.db on first import. Access via anvil.R.*, anvil.S.*, anvil.QDB.*, or sys.use("name"). Domain-specific packs have their own pages: Physics and Chemistry.

All inputs are dimensionless scalars or SI floats unless noted. All outputs are SI units unless returned as Q(value, "unit").


Constants (const), Type: Q¶

Accessed via anvil.QDB.*:

Name Value Unit Description
g0 9.80665 m/s² Standard gravitational acceleration
R_universal 8.314462 J/mol/K Universal gas constant
atm_pressure 101325 Pa Standard atmosphere
sigma_sb 5.670374×10⁻⁸ W/m²K⁴ Stefan-Boltzmann constant
g0 = anvil.QDB.g0
print(g0)           # 9.8067 m/s^2
print(g0.to("ft/s^2"))  # 32.1740 ft/s^2

Aerodynamics, Atmosphere (aero.atmosphere)¶

isa_atmosphere¶

International Standard Atmosphere up to 86 km altitude.

Inputs:  h [m], geometric altitude
Outputs: T_atm [K], P_atm [Pa], rho_atm [kg/m³], a_atm [m/s],
         mu_atm [Pa*s], sigma (density ratio ρ/ρ₀)
r = anvil.R.isa_atmosphere(h=10000)
# T_atm = 223.15 K  (-50°C)
# P_atm = 26436.9 Pa
# rho_atm = 0.4127 kg/m³
# a_atm = 299.5 m/s

r = anvil.R.isa_atmosphere(h=0)
# T_atm = 288.15 K  P_atm = 101325 Pa  rho_atm = 1.2250 kg/m³

r = anvil.R.isa_atmosphere(h=85000)  # near model limit
# T_atm = 270.6 K  P_atm = 110.9 Pa

Layers implemented: - 0-11 km: Troposphere (T decreases at −6.5 K/km) - 11-20 km: Lower Stratosphere (T = 216.65 K, isothermal) - 20-32 km: Upper Stratosphere (T increases at +1 K/km) - Above 32 km: Extended with pressure extrapolation

Limit: Model extends to ~86 km. Outputs above this are extrapolated and should be treated as approximate.


Aerodynamics, Compressible (aero.compressible)¶

isentropic_ratios¶

Inputs:  M, gamma=1.4
Outputs: T0_T (T₀/T), P0_P (P₀/P), rho0_rho (ρ₀/ρ)
r = anvil.R.isentropic_ratios(M=2.0, gamma=1.4)
# T0_T = 1.8000    P0_P = 7.8244    rho0_rho = 4.3469

r = anvil.R.isentropic_ratios(M=0.0)
# T0_T = 1.0  P0_P = 1.0  rho0_rho = 1.0

r = anvil.R.isentropic_ratios(M=1.0)
# T0_T = 1.2000  P0_P = 1.8929  rho0_rho = 1.5774

r = anvil.R.isentropic_ratios(M=5.0)
# T0_T = 6.0  P0_P = 529.09  rho0_rho = 88.18

area_mach_supersonic¶

Find supersonic Mach from area ratio (A/A*). Uses Brent's method.

Inputs:  area_ratio, gamma=1.4
Outputs: M_exit
anvil.R.area_mach_supersonic(area_ratio=8.0, gamma=1.25)
# M_exit = 3.64  (approximate, depends on gamma)

anvil.R.area_mach_supersonic(area_ratio=1.0)
# M_exit = 1.001 (barely supersonic, root is very close to 1)

area_mach_subsonic¶

Find subsonic Mach from area ratio (A/A*). Uses Brent's method.

Inputs:  area_ratio, gamma=1.4
Outputs: M_sub
anvil.R.area_mach_subsonic(area_ratio=2.0)
# M_sub = 0.3059

anvil.R.area_mach_subsonic(area_ratio=1.0)
# M_sub = 0.999 (subsonic near-sonic)

normal_shock¶

Inputs:  M1, gamma=1.4
Outputs: M2, P2_P1, T2_T1, rho2_rho1, P02_P01
r = anvil.R.normal_shock(M1=2.0, gamma=1.4)
# M2 = 0.5774    P2_P1 = 4.5000    T2_T1 = 1.6875
# rho2_rho1 = 2.6667    P02_P01 = 0.7209

r = anvil.R.normal_shock(M1=1.0)
# M2 = 1.0  P2_P1 = 1.0  T2_T1 = 1.0  (no shock at M=1)

r = anvil.R.normal_shock(M1=5.0)
# M2 = 0.4152  P2_P1 = 29.0  T2_T1 = 5.800

Limit: M1 must be ≥ 1 for a physical shock. No input guard, passing M1<1 returns mathematical results that are not physically meaningful.

prandtl_meyer¶

Inputs:  M, gamma=1.4
Outputs: nu [rad], nu_deg [degrees]
r = anvil.R.prandtl_meyer(M=2.0)
# nu = 0.4602 rad  nu_deg = 26.38°

r = anvil.R.prandtl_meyer(M=1.0)
# nu = 0.0 rad  nu_deg = 0.0° (no expansion at M=1)

r = anvil.R.prandtl_meyer(M=10.0)
# nu_deg = 100.7°

oblique_shock¶

2D oblique shock for wedge half-angle theta_deg. Returns weak (attached) solution if it exists.

Inputs:  M1, theta_deg, gamma=1.4
Outputs: beta_deg, M2, p2_p1, T2_T1, rho2_rho1, attached [bool]
r = anvil.R.oblique_shock(M1=3.0, theta_deg=20.0)
# beta_deg = 37.76  M2 = 1.994  p2_p1 = 3.31  attached=True

r = anvil.R.oblique_shock(M1=2.0, theta_deg=35.0)
# attached = False  (detached shock, theta exceeds max deflection)
# p2_p1 = nan  T2_T1 = nan  (NaN outputs when detached)

Algorithm: Solves the theta-beta-M equation numerically using Brent's method on the weak shock branch. Checks if any real solution exists (sign change in residual). Returns attached=False with NaN outputs if no attached solution exists.


Aerodynamics, Performance (aero.performance)¶

dynamic_pressure¶

Inputs:  rho [kg/m³], V [m/s]
Outputs: q_inf [Pa]
anvil.R.dynamic_pressure(rho=1.225, V=100)
# q_inf = 6125.00 Pa

lift_force¶

Inputs:  rho, V, S_ref [m²], CL
Outputs: lift [N]

drag_force¶

Inputs:  rho, V, S_ref, CD
Outputs: drag [N]

thin_airfoil_cl¶

Thin airfoil theory, with Prandtl-Glauert compressibility correction.

Inputs:  alpha_deg, alpha_L0_deg=0, M=0
Outputs: CL, CL_alpha [per rad]
anvil.R.thin_airfoil_cl(alpha_deg=5.0)
# CL = 0.5483  CL_alpha = 6.2832 (2π)

anvil.R.thin_airfoil_cl(alpha_deg=5.0, M=0.6)
# CL = 0.6854  (Prandtl-Glauert compressibility correction)

Limit: M < 1 only. At M=1 the Prandtl-Glauert formula has a singularity (1/√(1-M²) → ∞).

induced_drag¶

Inputs:  CL, AR, e=0.85
Outputs: CDi
anvil.R.induced_drag(CL=0.5, AR=8, e=0.85)
# CDi = 0.01179

drag_polar¶

Inputs:  CL, CD0, AR, e=0.85
Outputs: CD, CDi, LoD
r = anvil.R.drag_polar(CL=0.5, CD0=0.02, AR=8)
# CD = 0.0318  CDi = 0.0118  LoD = 15.77

oswald_efficiency¶

Estimate Oswald span efficiency for straight wings.

Inputs:  AR, sweep_deg=0, taper=1
Outputs: e_oswald
anvil.R.oswald_efficiency(AR=8, sweep_deg=0, taper=0.5)
# e_oswald ≈ 0.87 (approximate empirical formula)

stall_speed¶

Inputs:  W [N], rho, S_ref [m²], CLmax
Outputs: V_stall [m/s]
anvil.R.stall_speed(W=50000, rho=1.225, S_ref=20, CLmax=1.5)
# V_stall = 58.5 m/s

range_breguet¶

Breguet range equation for jet aircraft.

Inputs:  V [m/s], TSFC [1/s], LoD, W_initial [N], W_final [N]
Outputs: range [m], range_km [km]
r = anvil.R.range_breguet(V=250, TSFC=1.5e-5, LoD=15, W_initial=70000, W_final=55000)
# range = 6.029e7 m   range_km = 60290.5 km

Note: TSFC in 1/s (= thrust-specific fuel consumption per second). Convert from kg/N/hr: TSFC_1s = TSFC_kgNhr / 3600.


Propulsion (propulsion)¶

nozzle_area_ratio¶

Inputs:  A_exit [m²], A_throat [m²]
Outputs: area_ratio

exit_conditions¶

Static conditions at nozzle exit from isentropic ratios.

Inputs:  T0, P0, T0_T, P0_P, gamma, R_gas
Outputs: T_exit [K], P_exit [Pa], a_exit [m/s]

exit_velocity¶

Inputs:  M_exit, a_exit [m/s]
Outputs: V_exit [m/s]

choked_mass_flow¶

Mass flow through a choked throat.

Inputs:  P0 [Pa], A_throat [m²], gamma, R_gas, T0 [K]
Outputs: mdot [kg/s]

rocket_thrust¶

Inputs:  mdot [kg/s], V_exit [m/s], P_exit [Pa], P_amb [Pa], A_exit [m²]
Outputs: thrust [N]

specific_impulse¶

Inputs:  thrust [N], mdot [kg/s]
Outputs: Isp [s]

tsiolkovsky¶

Inputs:  Isp [s], mass_ratio (m_wet/m_dry)
Outputs: delta_v [m/s]
anvil.R.tsiolkovsky(Isp=450, mass_ratio=3.0)
# delta_v = 4848.2 m/s

anvil.R.tsiolkovsky(Isp=315, mass_ratio=8.0)  # Falcon 9 approximate
# delta_v = 6429.9 m/s

rocket_nozzle ★, Pre-built System¶

The only built-in System (type "S"). A complete quasi-1D isentropic rocket nozzle.

Default inputs: | Name | Value | Unit | Desc | |------|-------|------|------| | P0 | 6.9e6 | Pa | Chamber pressure | | T0 | 3500 | K | Chamber temperature | | gamma | 1.25 |, | Specific heat ratio | | R_gas | 320 | J/kg/K | Gas constant | | A_throat | 0.01 | m² | Throat area | | A_exit | 0.08 | m² | Exit area | | P_amb | 101325 | Pa | Ambient pressure |

Computed outputs: area_ratio, M_exit, T0_T, P0_P, rho0_rho, T_exit, P_exit, a_exit, V_exit, mdot, thrust, Isp

Usage:

nozzle = anvil.S.rocket_nozzle.copy()  # always copy before modifying
nozzle.set(P0=10e6, T0=3500, A_exit=0.1, P_amb=0)
result = nozzle.solve_forward()

# Key outputs:
result["thrust"].to("kN")  # ~171 kN (vacuum)
result["Isp"]              # ~281 s
result["M_exit"]           # ~3.64
result["mdot"]             # ~12 kg/s

Dependency chain (8 relations):

nozzle_area_ratio → area_mach_supersonic → isentropic_ratios →
exit_conditions → exit_velocity + choked_mass_flow → rocket_thrust → specific_impulse

Thermodynamics (thermo)¶

ideal_gas_density¶

Inputs:  P [Pa], R_gas [J/kg/K], T [K]
Outputs: rho [kg/m³]
anvil.R.ideal_gas_density(P=101325, R_gas=287.058, T=288.15)
# rho = 1.2250 kg/m³

speed_of_sound¶

Inputs:  gamma, R_gas, T [K]
Outputs: a [m/s]
anvil.R.speed_of_sound(gamma=1.4, R_gas=287.058, T=288.15)
# a = 340.29 m/s

sutherland_viscosity¶

Sutherland's law for dynamic viscosity of gases.

Inputs:  T [K], T_ref=288.15, mu_ref=1.789e-5 [Pa*s], S=110.4 [K]
Outputs: mu [Pa*s]
anvil.R.sutherland_viscosity(T=500)
# mu = 2.670e-5 Pa*s

anvil.R.sutherland_viscosity(T=1000)
# mu = 4.153e-5 Pa*s

reynolds_number¶

Inputs:  rho, V, L_char [m], mu [Pa*s]
Outputs: Re (dimensionless)
anvil.R.reynolds_number(rho=1.225, V=100, L_char=1.0, mu=1.789e-5)
# Re = 6,847,401

Heat Transfer (heat_transfer)¶

conduction_1d¶

Fourier's law: Q = k·A·ΔT/L

Inputs:  k [W/m/K], A_cross [m²], dT [K], L_thickness [m]
Outputs: Q_cond [W]
anvil.R.conduction_1d(k=200, A_cross=0.01, dT=100, L_thickness=0.01)
# Q_cond = 20000 W

convection¶

Newton's law of cooling: Q = h·A·(T_surf - T_inf)

Inputs:  h_conv [W/m²/K], A_surf [m²], T_surf [K], T_inf [K]
Outputs: Q_conv [W]

radiation¶

Stefan-Boltzmann law: Q = ε·σ·A·(T_hot⁴ - T_cold⁴)

Inputs:  emissivity, A_surf [m²], T_hot [K], T_cold [K]
Outputs: Q_rad [W]

thermal_resistance_wall¶

Inputs:  L_thickness [m], k [W/m/K], A_cross [m²]
Outputs: R_thermal [K/W]

fin_efficiency_rect¶

Rectangular fin efficiency using the exact hyperbolic tangent formula.

Inputs:  h_conv [W/m²/K], k_fin [W/m/K], t_fin [m], L_fin [m]
Outputs: eta_fin, mL (fin parameter)
r = anvil.R.fin_efficiency_rect(h_conv=50, k_fin=200, t_fin=0.003, L_fin=0.05)
# eta_fin = 0.8806  mL = 0.4082

Structures (structures)¶

hooke_stress¶

Inputs:  E [Pa], strain
Outputs: stress [Pa]

axial_stress¶

Inputs:  F_axial [N], A_cross [m²]
Outputs: sigma_axial [Pa]

beam_deflection_cantilever¶

Point load at tip of cantilever beam.

Inputs:  F_tip [N], L_beam [m], E [Pa], I_moment [m⁴]
Outputs: deflection [m], max_moment [N·m]
r = anvil.R.beam_deflection_cantilever(F_tip=1000, L_beam=2, E=200e9, I_moment=1e-6)
# deflection = 0.013333 m    max_moment = 2000 N·m

Formula: δ = F·L³/(3·E·I)

beam_deflection_simply_supported¶

Uniformly distributed load.

Inputs:  w_load [N/m], L_beam [m], E [Pa], I_moment [m⁴]
Outputs: deflection [m], max_moment [N·m]

Formula: δ = 5·w·L⁴/(384·E·I)

buckling_euler¶

Inputs:  E [Pa], I_moment [m⁴], L_eff [m]
Outputs: P_critical [N]
anvil.R.buckling_euler(E=200e9, I_moment=1e-6, L_eff=2.0)
# P_critical = 493,480 N

Formula: P_cr = π²·E·I/(L_eff²)

thin_wall_hoop_stress¶

Inputs:  P_internal [Pa], r_inner [m], t_wall [m]
Outputs: sigma_hoop [Pa], sigma_axial [Pa]
r = anvil.R.thin_wall_hoop_stress(P_internal=1e6, r_inner=0.1, t_wall=0.005)
# sigma_hoop = 20,000,000 Pa  sigma_axial = 10,000,000 Pa

Controls (controls)¶

pid_output¶

PID control law: u = Kp·e + Ki·∫e + Kd·de/dt

Inputs:  error, integral_error, derivative_error, Kp, Ki, Kd
Outputs: u_pid
anvil.R.pid_output(error=1.0, integral_error=0.5, derivative_error=0.1,
                   Kp=2.0, Ki=0.5, Kd=0.1)
# u_pid = 2.0*1.0 + 0.5*0.5 + 0.1*0.1 = 2.26

Note: You must pre-compute integral_error and derivative_error externally. This RSQ is the control law only, not a full PID controller with state.

ziegler_nichols_pid¶

Inputs:  Ku (ultimate gain), Tu (ultimate period), method="classic"
Outputs: Kp, Ki, Kd, Ti, Td
r = anvil.R.ziegler_nichols_pid(Ku=10.0, Tu=2.0)
# Kp=6.00  Ki=6.00  Kd=1.50  Ti=1.0  Td=0.25

Methods: "classic" (Ziegler-Nichols), "no_overshoot", "some_overshoot", "pessen_integral"

first_order_step¶

Step response metrics for a first-order system G(s) = K/(τs+1).

Inputs:  K (gain), tau [s] (time constant), t_settle_criterion=0.02 (±2%)
Outputs: t_settle [s], t_rise [s], bandwidth_Hz [Hz]

second_order_metrics¶

Inputs:  omega_n [rad/s], zeta
Outputs: overshoot_pct, t_peak [s], t_settle [s], t_rise [s], omega_d [rad/s]
r = anvil.R.second_order_metrics(omega_n=10.0, zeta=0.5)
# overshoot_pct = 16.3%    t_settle = 0.800 s    t_rise = 0.181 s
# omega_d = 8.660 rad/s

r = anvil.R.second_order_metrics(omega_n=10.0, zeta=1.0)  # critically damped
# overshoot_pct = 0.0%

routh_hurwitz_2nd¶

Routh-Hurwitz stability for 2nd order characteristic polynomial: s² + a1·s + a0 = 0

Inputs:  a1, a0
Outputs: stable [bool]
anvil.R.routh_hurwitz_2nd(a1=2.0, a0=5.0)
# stable = True  (both coefficients positive)

anvil.R.routh_hurwitz_2nd(a1=-1.0, a0=5.0)
# stable = False  (negative a1)

Stability condition: Both a1 > 0 and a0 > 0.


Materials (materials)¶

safety_factor¶

Inputs:  allowable_stress [Pa], applied_stress [Pa]
Outputs: safety_factor, margin_of_safety, pass [bool]
r = anvil.R.safety_factor(allowable_stress=250e6, applied_stress=100e6)
# safety_factor = 2.50   margin_of_safety = 1.50   pass = True

margin_of_safety = safety_factor - 1

thermal_expansion_stress¶

Thermal stress in constrained member: σ = E·α·ΔT

Inputs:  E [Pa], alpha_thermal [1/K], dT [K]
Outputs: sigma_thermal [Pa]

fatigue_life_basquin¶

Basquin's power law: σₐ = σ'f · (2N)^b

Inputs:  sigma_a [Pa], sigma_f_prime [Pa], b_exponent
Outputs: N_cycles
anvil.R.fatigue_life_basquin(sigma_a=300e6, sigma_f_prime=1000e6, b_exponent=-0.1)
# N_cycles = 84,700

miners_rule¶

Inputs:  cycle_counts [list], cycle_limits [list]
Outputs: damage_index, failed [bool], remaining_life_fraction

fracture_toughness_check¶

Inputs:  sigma [Pa], a_crack [m], KIc [Pa·m^0.5], F_geometry=1.12
Outputs: KI [Pa·m^0.5], safety_factor, failed [bool]
r = anvil.R.fracture_toughness_check(sigma=200e6, a_crack=0.01, KIc=50e6)
# KI = 39,703,000  safety_factor = 1.26  failed = False

KI = F·σ·√(π·a)

composite_laminate_stiffness¶

Rule of mixtures for unidirectional composite laminate.

Inputs:  Ef [Pa], Em [Pa], Gf [Pa], Gm [Pa], nu_f, nu_m, Vf
Outputs: E1 [Pa], E2 [Pa], G12 [Pa], nu12

Orbital Mechanics (orbital)¶

vis_viva¶

Inputs:  mu [m³/s²], r [m], a [m]
Outputs: V_orbital [m/s]
# LEO circular orbit
anvil.R.vis_viva(mu=3.986e14, r=6.571e6, a=6.571e6)
# V_orbital = 7784.3 m/s

# GTO apogee
anvil.R.vis_viva(mu=3.986e14, r=42164e3, a=(6.571e6+42164e3)/2)
# V_orbital = 1596.5 m/s

hohmann_transfer¶

Inputs:  mu [m³/s²], r1 [m], r2 [m]
Outputs: dv1 [m/s], dv2 [m/s], dv_total [m/s], tof [s]
r = anvil.R.hohmann_transfer(mu=3.986e14, r1=6.571e6, r2=42164e3)
# dv1 = 2427 m/s  dv2 = 1508 m/s  dv_total = 3934.7 m/s
# tof = 19116 s = 5.31 hours

orbital_period¶

Inputs:  mu [m³/s²], a [m]
Outputs: T_orbital [s]
anvil.R.orbital_period(mu=3.986e14, a=6.571e6)
# T_orbital = 5303 s = 88.4 minutes  (LEO)

Quick Reference¶

Name Domain Type Key inputs Key outputs
g0 const Q , 9.80665 m/s²
R_universal const Q , 8.314 J/mol/K
atm_pressure const Q , 101325 Pa
sigma_sb const Q , 5.67e-8 W/m²K⁴
isa_atmosphere aero.atmosphere R h T_atm, P_atm, rho_atm, a_atm, mu_atm
isentropic_ratios aero.compressible R M, gamma T0_T, P0_P, rho0_rho
area_mach_supersonic aero.compressible R area_ratio, gamma M_exit
area_mach_subsonic aero.compressible R area_ratio, gamma M_sub
normal_shock aero.compressible R M1, gamma M2, P2_P1, T2_T1, rho2_rho1, P02_P01
prandtl_meyer aero.compressible R M, gamma nu, nu_deg
oblique_shock aero.compressible R M1, theta_deg, gamma beta_deg, M2, p2_p1, T2_T1, attached
dynamic_pressure aero R rho, V q_inf
lift_force aero R rho, V, S_ref, CL lift
drag_force aero R rho, V, S_ref, CD drag
thin_airfoil_cl aero.performance R alpha_deg, alpha_L0_deg, M CL, CL_alpha
induced_drag aero.performance R CL, AR, e CDi
drag_polar aero.performance R CL, CD0, AR, e CD, CDi, LoD
oswald_efficiency aero.performance R AR, sweep_deg, taper e_oswald
stall_speed aero.performance R W, rho, S_ref, CLmax V_stall
range_breguet aero.performance R V, TSFC, LoD, W_initial, W_final range, range_km
nozzle_area_ratio propulsion R A_exit, A_throat area_ratio
exit_conditions propulsion R T0, P0, T0_T, P0_P, gamma, R_gas T_exit, P_exit, a_exit
exit_velocity propulsion R M_exit, a_exit V_exit
choked_mass_flow propulsion R P0, A_throat, gamma, R_gas, T0 mdot
rocket_thrust propulsion R mdot, V_exit, P_exit, P_amb, A_exit thrust
specific_impulse propulsion R thrust, mdot Isp
tsiolkovsky propulsion R Isp, mass_ratio delta_v
rocket_nozzle propulsion S P0, T0, gamma, R_gas, A_throat, A_exit, P_amb M_exit, thrust, Isp, mdot, …
ideal_gas_density thermo R P, R_gas, T rho
speed_of_sound thermo R gamma, R_gas, T a
sutherland_viscosity thermo R T, T_ref, mu_ref, S mu
reynolds_number thermo R rho, V, L_char, mu Re
conduction_1d heat_transfer R k, A_cross, dT, L_thickness Q_cond
convection heat_transfer R h_conv, A_surf, T_surf, T_inf Q_conv
radiation heat_transfer R emissivity, A_surf, T_hot, T_cold Q_rad
thermal_resistance_wall heat_transfer R L_thickness, k, A_cross R_thermal
fin_efficiency_rect heat_transfer R h_conv, k_fin, t_fin, L_fin eta_fin, mL
hooke_stress structures R E, strain stress
axial_stress structures R F_axial, A_cross sigma_axial
beam_deflection_cantilever structures R F_tip, L_beam, E, I_moment deflection, max_moment
beam_deflection_simply_supported structures R w_load, L_beam, E, I_moment deflection, max_moment
buckling_euler structures R E, I_moment, L_eff P_critical
thin_wall_hoop_stress structures R P_internal, r_inner, t_wall sigma_hoop, sigma_axial
pid_output controls R error, integral_error, derivative_error, Kp, Ki, Kd u_pid
ziegler_nichols_pid controls R Ku, Tu, method Kp, Ki, Kd, Ti, Td
first_order_step controls R K, tau, t_settle_criterion t_settle, t_rise, bandwidth_Hz
second_order_metrics controls R omega_n, zeta overshoot_pct, t_peak, t_settle, t_rise, omega_d
routh_hurwitz_2nd controls R a1, a0 stable
safety_factor materials R allowable_stress, applied_stress safety_factor, margin, pass
thermal_expansion_stress materials R E, alpha_thermal, dT sigma_thermal
fatigue_life_basquin materials R sigma_a, sigma_f_prime, b_exponent N_cycles
miners_rule materials R cycle_counts, cycle_limits damage_index, failed, remaining_life_fraction
fracture_toughness_check materials R sigma, a_crack, KIc, F_geometry KI, safety_factor, failed
composite_laminate_stiffness materials R Ef, Em, Gf, Gm, nu_f, nu_m, Vf E1, E2, G12, nu12
vis_viva orbital R mu, r, a V_orbital
hohmann_transfer orbital R mu, r1, r2 dv1, dv2, dv_total, tof
orbital_period orbital R mu, a T_orbital
keplerian_to_cartesian orbital R a, e, i_deg, RAAN_deg, omega_deg, nu_deg, mu r_eci, v_eci, r_mag, v_mag
cartesian_to_keplerian orbital R r_vec, v_vec, mu a, e, i_deg, RAAN_deg, omega_deg, nu_deg, h_mag
plane_change_dv orbital R v, delta_i_deg dv_plane_change
bielliptic_transfer orbital R mu, r1, r2, rb dv1, dv2, dv3, dv_total, tof
j2_precession orbital R a, e, i_deg, [mu, R_body, J2] d_RAAN_dt, d_omega_dt, deg/day variants
eclipse_fraction orbital R a, [R_body, beta_deg] eclipse_frac, beta_max_deg, in_eclipse_season
sphere_of_influence orbital R a_body, m_body, m_parent r_SOI
propellant_mass orbital R dv, Isp, m_dry m_propellant, m_wet, mass_ratio
delta_v_budget orbital R dv1..dv6, [margin_pct] dv_total, dv_with_margin, dv_margin
euler_equations attitude R omega_x/y/z, Ix/Iy/Iz, [tau_x/y/z] alpha_x/y/z
quaternion_kinematics attitude R q_w/x/y/z, omega_x/y/z qw/x/y/z_dot, q_norm
triad_attitude attitude R b1/b2 xyz (body), r1/r2 xyz (ref) C (DCM), q_w/x/y/z
gravity_gradient_torque attitude R mu, r, Ix/Iy/Iz, [theta_pitch_deg, phi_roll_deg] T_roll, T_pitch, T_gg_max, omega_orbital
reaction_wheel_sizing attitude R I_sc, theta_slew_deg, t_slew, [margin] H_rw, tau_rw, omega_slew_max, P_peak
link_budget mission R P_tx_W, G_tx_dBi, G_rx_dBi, freq_Hz, distance_m, [losses_dB] P_rx_W, P_rx_dBW, FSPL_dB, EIRP_dBW
power_budget mission R P_load_W, T_orbit_min, eclipse_frac, [eta_solar, flux_solar, DOD, eta_battery] A_panel_m2, E_bat_Wh, m_bat_kg, P_from_panel_W
state_space_poles controls R A_flat, n_states poles_real, poles_imag, stable, min_damping
lqr_bryson controls R state_bounds, input_bounds Q_diag, R_diag
gain_phase_margin controls R num_coeffs, den_coeffs, [omega_lo, omega_hi] GM_dB, PM_deg, stable

Orbital Mechanics, Extended (orbital)¶

keplerian_to_cartesian¶

Convert classical orbital elements to ECI (Earth-Centred Inertial) Cartesian state.

Inputs:  a [m], e [-], i_deg [deg], RAAN_deg [deg], omega_deg [deg], nu_deg [deg], mu [m³/s²]
Outputs: r_eci, position vector [x, y, z] m (list)
         v_eci, velocity vector [vx,vy,vz] m/s (list)
         r_mag [m], v_mag [m/s]
eci = anvil.R.keplerian_to_cartesian(
    a=6771e3, e=0.001, i_deg=51.6, RAAN_deg=0, omega_deg=0, nu_deg=0,
    mu=3.986e14)
print(eci['r_mag'])   # 6764 km (periapsis for e=0.001)
print(eci['v_mag'])   # 7.68 km/s

cartesian_to_keplerian¶

Convert ECI Cartesian state to classical orbital elements.

Inputs:  r_vec [m] (list), v_vec [m/s] (list), mu [m³/s²]
Outputs: a [m], e [-], i_deg, RAAN_deg, omega_deg, nu_deg [all deg], h_mag [m²/s]
elems = anvil.R.cartesian_to_keplerian(
    r_vec=[6764e3, 0, 0], v_vec=[0, 5.6e3, 5.6e3], mu=3.986e14)

Round-trip: keplerian_to_cartesian → cartesian_to_keplerian recovers elements to machine precision.


plane_change_dv¶

Delta-V for a pure inclination change. Most efficient at apoapsis (lowest speed).

Inputs:  v [m/s], orbital speed at manoeuvre point
         delta_i_deg [deg], inclination change
Outputs: dv_plane_change [m/s]

Formula: dv = 2 v sin(Δi/2)

# 28.5-deg plane change from Kennedy Space Center inclination
r = anvil.R.plane_change_dv(v=7700, delta_i_deg=28.5)
# dv_plane_change ≈ 3791 m/s  (cheaper to combine with Hohmann at GTO apoapsis)

bielliptic_transfer¶

Bi-elliptic transfer via intermediate apoapsis rb. More efficient than Hohmann when r2/r1 > 11.94.

Inputs:  mu [m³/s²], r1 [m], r2 [m], rb [m], rb must be ≥ max(r1,r2)
Outputs: dv1, dv2, dv3, dv_total [m/s], tof [s]
# LEO (400 km) -> GEO via 100 000 km intermediate orbit
r = anvil.R.bielliptic_transfer(mu=3.986e14, r1=6771e3, r2=42164e3, rb=100000e3)
# dv_total ≈ 4228 m/s  (Hohmann: 3857 m/s, bielliptic is WORSE here since r2/r1=6.2 < 11.94)

j2_precession¶

Secular nodal (RAAN) and apsidal (argument-of-perigee) drift from Earth's J2 oblateness.

Inputs:  a [m], e [-], i_deg [deg]
         mu=3.986e14, R_body=6.371e6, J2=1.08263e-3  (Earth defaults)
Outputs: d_RAAN_dt [rad/s], d_omega_dt [rad/s]
r = anvil.R.j2_precession(a=6771e3, e=0.001, i_deg=97.4)
# d_RAAN_dt ≈ 1.98e-7 rad/s  (SSO: ~+0.987 deg/day)

Note: SSO condition is d_RAAN/dt ≈ +0.9856 deg/day = 1.991e-7 rad/s. Angle inputs: accept either plain float in degrees or Q(value, "deg"). All angle RSQs follow this convention. Note: SSO condition Solve for i_deg numerically with solvers.find_root.


eclipse_fraction¶

Fraction of a circular orbit spent in the planet's cylindrical shadow.

Inputs:  a [m], R_body=6.371e6 [m], beta_deg=0.0 [deg]
         beta_deg: sun-orbit-plane angle (0 = worst case, eclipse_frac is maximum)
Outputs: eclipse_frac [-], beta_max_deg [deg], in_eclipse_season [bool]
anvil.R.eclipse_fraction(a=6771e3, beta_deg=0)   # worst case: 0.39
anvil.R.eclipse_fraction(a=6771e3, beta_deg=70)  # no eclipse: 0.00
# Use with power_budget: eclipse_frac drives battery and panel sizing

sphere_of_influence¶

Laplace sphere of influence for patched-conic trajectory design.

Inputs:  a_body [m], semi-major axis of body around parent
         m_body, m_parent [kg]
Outputs: r_SOI [m]

Formula: r_SOI = a_body * (m_body/m_parent)^(2/5)

anvil.R.sphere_of_influence(a_body=384400e3, m_body=7.342e22, m_parent=5.972e24)
# r_SOI ≈ 66 200 km  (Moon SOI)

propellant_mass¶

Invert the Tsiolkovsky equation: compute propellant mass from delta-V budget.

Inputs:  dv [m/s], Isp [s], m_dry [kg]
Outputs: m_propellant [kg], m_wet [kg], mass_ratio [-]
r = anvil.R.propellant_mass(dv=3900, Isp=320, m_dry=2500)
# m_propellant ≈ 6163 kg  mass_ratio ≈ 3.47

delta_v_budget¶

Aggregate up to six mission-phase delta-Vs and apply a percentage margin.

Inputs:  dv1..dv6 [m/s], margin_pct=5.0 [%]
Outputs: dv_total [m/s], dv_with_margin [m/s], dv_margin [m/s]
r = anvil.R.delta_v_budget(dv1=3100, dv2=820, dv3=50, margin_pct=10)
# dv_total=3970 m/s  dv_with_margin=4367 m/s

Attitude Dynamics & ADCS (attitude)¶

euler_equations¶

Euler's equations of rigid-body rotation in the principal-axes body frame. Gives the instantaneous angular acceleration from current rates and applied torques.

Inputs:  omega_x/y/z [rad/s], Ix/Iy/Iz [kg*m²], tau_x/y/z=0 [N*m]
Outputs: alpha_x/y/z [rad/s²]
alpha_x = (tau_x - (Iz-Iy)*omega_y*omega_z) / Ix
r = anvil.R.euler_equations(
    omega_x=0.1, omega_y=0.05, omega_z=0.02,
    Ix=100, Iy=80, Iz=60, tau_x=0.1)
# Use with anvil ODE solvers to propagate attitude over time

Integration with ODE solvers:

from anvil import solvers

def attitude_odes(t, state):
    ox, oy, oz = state
    r = anvil.R.euler_equations(omega_x=ox,omega_y=oy,omega_z=oz, Ix=100,Iy=80,Iz=60)
    return [r['alpha_x'].si, r['alpha_y'].si, r['alpha_z'].si]

sol = solvers.ode(attitude_odes, t_span=(0,60), y0=[0.01,0.05,1.0])

quaternion_kinematics¶

Quaternion time derivative given current attitude and body angular velocity. Hamilton convention: q = [w, x, y, z], |q| = 1.

Inputs:  q_w, q_x, q_y, q_z [-], omega_x/y/z [rad/s], body frame rates
Outputs: qw_dot, qx_dot, qy_dot, qz_dot [1/s], q_norm [-]
# Identity attitude, spinning at 0.01 rad/s about pitch (y) axis
r = anvil.R.quaternion_kinematics(q_w=1, q_x=0, q_y=0, q_z=0,
                                    omega_x=0, omega_y=0.01, omega_z=0)
# qy_dot = 0.005  (q_y grows → pitch rotation)

Re-normalise after each integration step to prevent drift from q_norm.


triad_attitude¶

TRIAD two-vector attitude determination algorithm. Given two unit vectors in both body and reference frames, returns the body-to-reference DCM and quaternion.

Inputs:  b1_x/y/z, b2_x/y/z, vectors measured in body frame (e.g. sun, magnetic field)
         r1_x/y/z, r2_x/y/z, same vectors in reference frame (from ephemeris/model)
Outputs: C, 3×3 body-to-reference DCM (list of lists)
         q_w, q_x, q_y, q_z, corresponding quaternion
r = anvil.R.triad_attitude(
    b1_x=0, b1_y=1, b1_z=0,  # sun in body = +Y
    b2_x=0, b2_y=0, b2_z=1,  # mag in body = +Z
    r1_x=1, r1_y=0, r1_z=0,  # sun in ref  = +X
    r2_x=0, r2_y=0, r2_z=1)   # mag in ref  = +Z
# q_z ≈ 0.7071  (90-deg rotation about Z)

Limitation: TRIAD is exact only when measurements are noise-free. Use QUEST or EKF for real hardware.


gravity_gradient_torque¶

Gravity gradient disturbance torques on a nadir-pointing satellite (linearised, small angles).

Inputs:  mu [m³/s²], r [m], orbit radius
         Ix, Iy, Iz [kg*m²], principal moments
         theta_pitch_deg=0, phi_roll_deg=0, attitude errors [deg]
Outputs: T_roll, T_pitch [N*m], T_gg_max [N*m], worst-case (45 deg) envelope
         omega_orbital [rad/s]
r = anvil.R.gravity_gradient_torque(mu=3.986e14, r=6771e3, Ix=8, Iy=10, Iz=12,
                                      theta_pitch_deg=5)
# T_gg_max ≈ 7e-6 N*m, drives reaction-wheel momentum storage sizing

reaction_wheel_sizing¶

Size a reaction wheel for a slew manoeuvre (bang-bang torque profile).

Inputs:  I_sc [kg*m²], spacecraft MOI about slew axis
         theta_slew_deg [deg], t_slew [s], margin=1.5
Outputs: H_rw [N*m*s], required angular momentum capacity
         tau_rw [N*m] , required peak torque
         omega_slew_max [rad/s], P_peak [W]
r = anvil.R.reaction_wheel_sizing(I_sc=12, theta_slew_deg=90, t_slew=60, margin=1.5)
# H_rw ≈ 0.94 N*m*s  tau_rw ≈ 0.042 N*m  P_peak ≈ 0.039 W

Mission Budgets (mission)¶

link_budget¶

RF link budget using the Friis free-space path loss equation.

Inputs:  P_tx_W [W], G_tx_dBi [dBi], G_rx_dBi [dBi]
         freq_Hz [Hz], distance_m [m], losses_dB=3.0 [dB]
Outputs: P_rx_W [W], P_rx_dBW [dBW], FSPL_dB [dB], EIRP_dBW [dBW]
FSPL = 20 log₁₀(4π d f / c)
P_rx = P_tx + G_tx + G_rx − FSPL − losses   [all in dB]
r = anvil.R.link_budget(
    P_tx_W=5, G_tx_dBi=3, G_rx_dBi=47,
    freq_Hz=8.4e9, distance_m=800e3, losses_dB=4)
# P_rx ≈ -116 dBW   FSPL ≈ 169 dB

Note: Returns received power only. Compute SNR separately given noise temperature: SNR = P_rx / (k_B * T_sys * BW).


power_budget¶

Size solar panels and battery for a spacecraft in a given orbit.

Inputs:  P_load_W [W]    , average power load
         T_orbit_min [min], orbital period
         eclipse_frac [-], from eclipse_fraction RSQ
         eta_solar=0.28  , solar cell efficiency (0.28 = GaAs triple-junction)
         flux_solar=1361 , solar constant [W/m²]
         DOD=0.8         , battery depth of discharge
         eta_battery=0.9 , battery charge/discharge efficiency
Outputs: A_panel_m2 [m²], E_bat_Wh [Wh], m_bat_kg [kg], P_from_panel_W [W]
ecl = anvil.R.eclipse_fraction(a=6771e3, beta_deg=0)
pwr = anvil.R.power_budget(P_load_W=100, T_orbit_min=92,
                             eclipse_frac=ecl['eclipse_frac'])
# A_panel ≈ 0.41 m²   E_bat ≈ 167 Wh   m_bat ≈ 1.4 kg

Battery mass assumes 120 Wh/kg (Li-ion). Adjust for other chemistries.


Controls, Extended (controls)¶

state_space_poles¶

Compute eigenvalues of a state matrix A and assess stability.

Inputs:  A_flat, row-major flattened list of n² floats
         n_states, system order (int)
Outputs: poles_real, poles_imag, eigenvalue components (lists)
         stable [bool], min_damping [-]
# Second-order: s² + s + 1 = 0  →  poles at −0.5 ± j0.866
r = anvil.R.state_space_poles(A_flat=[0, 1, -1, -1], n_states=2)
# stable=True  min_damping=0.5  poles_real=[-0.5,-0.5]

lqr_bryson¶

Bryson's rule for LQR Q and R weighting matrices from maximum allowable state and input values.

Inputs:  state_bounds, list of max allowable state deviations (same units as states)
         input_bounds, list of max allowable control inputs
Outputs: Q_diag, R_diag, diagonal entries of Q and R matrices
Q_ii = 1/x_max_i²     R_jj = 1/u_max_j²
# Position ±10 m, velocity ±1 m/s, thrust ±100 N
r = anvil.R.lqr_bryson(state_bounds=[10, 1], input_bounds=[100])
# Q_diag=[0.01, 1.0]   R_diag=[0.0001]

gain_phase_margin¶

Gain margin (GM) and phase margin (PM) for an open-loop transfer function G(s) = num(s)/den(s).

Inputs:  num_coeffs, den_coeffs, polynomial coefficients, descending order [sⁿ ... s⁰]
         omega_lo=1e-3, omega_hi=1e4, n=2000, frequency sweep range [rad/s]
Outputs: GM_dB [dB], PM_deg [deg], stable [bool]
# G(s) = 1/(s(s+1)), integrator + first-order lag
r = anvil.R.gain_phase_margin(num_coeffs=[1], den_coeffs=[1, 1, 0])
# GM = inf dB  PM = 52 deg  stable = True

# G(s) = K/(s(s+1)(s+2))  check stability margins
r = anvil.R.gain_phase_margin(num_coeffs=[8], den_coeffs=[1, 3, 2, 0])
# GM ≈ 2.5 dB  PM ≈ 8 deg  (marginally stable)

Convention: GM = ∞ when no phase crossover exists. Stable requires GM > 0 dB and PM > 0 deg.


Fluids, Heat, Stress & Cycles (extended), Type: R¶

Compressible duct flow, pipe friction, convection and transient conduction, stress analysis, and thermodynamic cycles.

Name Domain Description
fanno_flow aero.compressible Adiabatic constant-area friction flow: sonic-reference ratios and 4fL*/D
rayleigh_flow aero.compressible Frictionless constant-area heat addition: sonic-reference ratios
mach_angle aero.compressible Mach wave angle, mu = arcsin(1/M)
colebrook_friction fluids Darcy friction factor from the implicit Colebrook equation
haaland_friction fluids Darcy friction factor from the explicit Haaland approximation
pipe_pressure_drop fluids Darcy-Weisbach pressure drop and head loss
dittus_boelter heat_transfer Turbulent internal convection Nusselt number and h
lmtd heat_transfer Log-mean temperature difference (counter/parallel flow)
biot_number heat_transfer Biot number and lumped-capacitance validity
lumped_capacitance heat_transfer Transient lumped cooling/heating temperature and time constant
torsion_circular_shaft structures Solid/hollow shaft shear stress, polar moment, angle of twist
principal_stresses_2d structures Plane-stress principal stresses and max shear (Mohr's circle)
von_mises_stress structures Von Mises equivalent stress from a 3D stress state
carnot_efficiency thermo Carnot efficiency and heat-pump/refrigerator COPs
brayton_ideal thermo Ideal Brayton cycle efficiency, stage temperatures, back-work ratio
skin_friction_flat_plate aero Flat-plate skin-friction coefficient (laminar or turbulent)
linear_regression data.fitting Least-squares line y = m x + b with R-squared
poly_fit data.fitting Polynomial least-squares fit of chosen degree with coefficients
power_fit data.fitting Power-law fit y = a x^b via log-log regression
exp_fit data.fitting Exponential fit y = a e^(b x) via semi-log regression
kinetic_energy physics.mechanics Translational kinetic energy 1/2 m v^2
newton_gravitation physics.mechanics Newton gravitational force between two masses
projectile_range physics.mechanics Projectile range over flat ground
pendulum_period physics.mechanics Small-angle simple pendulum period
coulomb_force physics.em Electrostatic force between two point charges
parallel_plate_capacitor_energy physics.em Stored energy of a vacuum parallel-plate capacitor
lorentz_force_magnitude physics.em Magnetic Lorentz force on a moving charge
snell_refraction_angle physics.optics Snell law refraction angle (flags total internal reflection)
thin_lens_image_distance physics.optics Thin lens image distance (infinite at d_o = f)
photon_energy_frequency physics.optics Photon energy E = h f
wave_speed physics.waves Wave speed v = f lambda
relativistic_doppler_shift physics.waves Relativistic Doppler observed frequency
lorentz_factor physics.relativity Lorentz factor gamma from speed
mass_energy_equivalence physics.relativity Rest energy E = m c^2
de_broglie_wavelength physics.quantum de Broglie wavelength h/p
wien_peak_wavelength physics.quantum Blackbody peak wavelength (Wien displacement)
moles_from_mass chemistry.stoichiometry Moles from mass and molar mass n = m/M
percent_yield chemistry.stoichiometry Percent yield from actual and theoretical amount
molarity chemistry.stoichiometry Molar concentration c = n/V (mol/m^3)
moles_ideal_gas chemistry.gas Moles of ideal gas n = PV/RT
combined_gas_law chemistry.gas Final volume from combined gas law
dilution chemistry.solution Dilution M1 V1 = M2 V2
beer_lambert_absorbance chemistry.solution Beer-Lambert absorbance A = eps l c
freezing_point_depression chemistry.solution.colligative Freezing point depression i Kf m
osmotic_pressure chemistry.solution.colligative Osmotic pressure Pi = i M R T
raoult_vapor_pressure chemistry.solution.colligative Raoult law solution vapor pressure
gibbs_free_energy chemistry.thermo Gibbs free energy dG = dH - T dS
gibbs_from_equilibrium_constant chemistry.thermo Gibbs energy from K, dG = -RT ln K
arrhenius_rate_constant chemistry.kinetics Arrhenius rate constant k = A exp(-Ea/RT)
first_order_half_life chemistry.kinetics First-order half-life ln2/k
equilibrium_constant_from_gibbs chemistry.equilibrium Equilibrium constant K = exp(-dG/RT)
nernst_cell_potential chemistry.electro Nernst cell potential
ph_from_concentration chemistry.acidbase pH from hydronium concentration
henderson_hasselbalch chemistry.acidbase Buffer pH from Henderson-Hasselbalch

Propulsion: Gas-Turbine Cycle, Type: R and S¶

Station-by-station engine components and full engine cycles. See the Propulsion Cycles page for the complete walkthrough.

Components (R): ram_intake, compressor, combustor, turbine, nozzle, thrust_performance (turbojet); fan, hp_compressor, hp_turbine, lp_turbine, bypass_nozzle, turbofan_thrust (turbofan); afterburner; power_turbine, turboshaft_performance (turboprop).

Engines (S): turbojet_cycle, turbofan_cycle, turbojet_ab_cycle, turboprop_cycle.

from anvil import propulsion as jet
tj = jet.build_turbojet()
tj.set(M0=0.85, pi_c=12, T04=1500, mdot=25)
res = tj.solve()
print(jet.station_table(res))

Adapters¶

An Adapter wraps an external tool, Python library, CLI executable, Fortran solver, as a native Anvil Relation. Once wrapped, it plugs into Systems, participates in solves, sweeps, and sensitivity analysis, identically to plain Python functions.

Adapter is a subclass of Relation. It appears as a normal Relation to all System machinery.


Two Backends¶

Backend Use for
"python" Python library calls (CoolProp, Cantera, OpenMDAO, Pint)
"cli" Command-line executables (SU2, OpenFOAM, custom Fortran/C codes)

Python Backend¶

from anvil import Adapter, Q

adapter = Adapter("adapter_name",
    backend="python",
    call=_wrapper_function,
    inputs={
        "param1": {"unit": "Pa", "desc": "Pressure", "default": 101325},
        "param2": {"unit": "K", "desc": "Temperature"},
    },
    outputs={
        "out1": {"unit": "kg/m^3", "desc": "Density"},
        "out2": {"desc": "Dimensionless ratio"},
    },
    desc="Short description",
    tags=["tag1", "tag2"],
)

Input spec fields¶

Field Required Description
"unit" No Tool expects this unit. Anvil converts from SI before calling wrapper.
"desc" No Human description
"default" No Default value if not in System workspace

Output spec fields¶

Field Required Description
"unit" No Tool returns in this unit. Anvil wraps with Q(v, unit).
"desc" No Human description

How the wrapper function receives inputs¶

Inputs arrive as raw SI floats. The adapter automatically converts from SI to the tool's declared unit:

# Declared: "P": {"unit": "Pa"}, same as SI, no conversion needed
# Declared: "T_C": {"unit": "K"}, if tool wants Celsius, convert INSIDE wrapper:

def _wrapper(P, T_C):
    T_celsius = T_C - 273.15   # convert K → °C inside wrapper
    result = my_tool(P_pascal=P, T_celsius=T_celsius)
    return {"rho": Q(result["density"], "kg/m^3")}

Non-numeric inputs (strings, flags, arrays)¶

String and boolean inputs bypass unit conversion entirely. Omit the "unit" field; the value passes through as-is:

# String parameter -- no unit field
inputs={
    "fuel":     {"desc": "Fuel species: H2, CH4, C3H8", "default": "H2"},
    "oxidizer": {"desc": "Oxidizer: O2, air"},
}

def _wrapper(fuel, oxidizer, T):
    # fuel and oxidizer arrive as Python strings, T arrives as SI float
    result = my_tool(fuel=fuel, ox=oxidizer, temp=T)
    return {"Tc": Q(result["T"], "K")}

Vector inputs must be flattened to named scalar components. Anvil's workspace stores named scalars, not arrays. pykep and poliastro adapters follow this pattern:

# Do this -- one input per component
inputs={
    "r_x": {"unit": "m"}, "r_y": {"unit": "m"}, "r_z": {"unit": "m"},
}

# Do NOT declare an array input -- Anvil cannot wire an array into the workspace
# inputs={"r": {"unit": "m"}}  # wrong

Inside the wrapper, reassemble the vector:

def _wrapper(r_x, r_y, r_z, v_x, v_y, v_z, dt, mu):
    r0 = [r_x, r_y, r_z]      # reassemble inside wrapper
    v0 = [v_x, v_y, v_z]
    r1, v1 = propagate(r0, v0, dt, mu)
    return {"r_x_f": Q(r1[0], "m"), ...}

Real-only availability pattern (NO mock fallbacks)¶

Policy: adapters are real-only. An adapter's result must always come from the actual tool it wraps. If the tool is missing, the adapter raises a clear error with install instructions, it never silently substitutes an approximation. If you want the closed-form physics, use the native RSQs (e.g. anvil.R.hohmann_transfer instead of poliastro_hohmann).

Every built-in adapter module follows this two-function pattern:

def _require_coolprop():
    """Import CoolProp or raise with install instructions."""
    try:
        import CoolProp  # noqa: F401
    except ImportError as exc:
        raise ImportError(
            "CoolProp is not installed. Install with: pip install CoolProp"
        ) from exc


def is_available() -> bool:
    """True when CoolProp can be imported."""
    try:
        import CoolProp  # noqa: F401
        return True
    except ImportError:
        return False


def _wrapper(P, T):
    _require_coolprop()
    import CoolProp.CoolProp as CP
    rho = CP.PropsSI('D', 'P', P, 'T', T, 'Water')
    return {"rho": Q(rho, "kg/m^3")}

Rules: - Import the external library inside the wrapper function (lazy import). The adapter file loads and the adapter object is created even when the library is missing; the error surfaces at call time. - is_available() lets callers (examples, UIs, tests) check up front and skip gracefully: if not module.is_available(): ...skip with install hint.... - CLI-backed adapters (XFOIL, SU2, NASTRAN) check for the binary on PATH the same way and raise RuntimeError with a download link when absent. - Never return placeholder values, a wrong number that looks real is worse than a clear error.

How outputs are processed¶

If the wrapper returns a Q object, it's used directly. If it returns a plain float with a declared unit, the adapter wraps it as Q(v, unit). If no unit is declared, it's passed through as a raw float.

Full example, CoolProp water properties¶

def _coolprop_water(P, T):
    try:
        import CoolProp.CoolProp as CP
    except ImportError:
        raise ImportError("CoolProp required: pip install CoolProp")

    return {
        "rho": Q(CP.PropsSI('D', 'P', P, 'T', T, 'Water'), "kg/m^3"),
        "mu":  Q(CP.PropsSI('V', 'P', P, 'T', T, 'Water'), "Pa*s"),
        "cp":  Q(CP.PropsSI('C', 'P', P, 'T', T, 'Water'), "J/kg/K"),
        "k":   Q(CP.PropsSI('L', 'P', P, 'T', T, 'Water'), "W/m/K"),
    }

water_props = Adapter("coolprop_water",
    backend="python",
    call=_coolprop_water,
    inputs={
        "P": {"unit": "Pa", "desc": "Pressure",   "default": 101325},
        "T": {"unit": "K", "desc": "Temperature", "default": 300},
    },
    outputs={
        "rho": {"unit": "kg/m^3", "desc": "Density"},
        "mu":  {"unit": "Pa*s",  "desc": "Dynamic viscosity"},
        "cp":  {"unit": "J/kg/K", "desc": "Specific heat"},
        "k":   {"unit": "W/m/K", "desc": "Thermal conductivity"},
    },
    desc="Water thermophysical properties via CoolProp",
    tags=["fluid", "water", "coolprop"],
)

Key rule: Import the external library inside the wrapper function (lazy import). This way the adapter file loads even if CoolProp isn't installed.


CLI Backend¶

adapter = Adapter("su2_airfoil",
    backend="cli",
    command="SU2_CFD flow.cfg",    # runs in workdir; no format placeholders needed
    inputs={"mach": {}, "alpha_deg": {}, "Re": {}},
    outputs={"CL": {}, "CD": {}, "CM": {}},
    setup=write_config_fn,         # function(inputs_dict, workdir), write config
    parse=read_results_fn,         # function(workdir) → dict, read outputs
    timeout=300,                   # seconds before kill
    cwd="./su2_runs",              # persistent workdir; None = temp dir (auto-deleted)
)

Execution sequence¶

  1. setup(inputs_dict, workdir), write all config files, meshes, etc.
  2. subprocess.run(command, cwd=workdir, timeout=timeout), run the executable
  3. parse(workdir) → dict, read all output files
  4. Outputs wrapped with declared units
  5. If cwd=None: temp dir created, cleaned up after each call

Return code check: If exit code ≠ 0, raises RuntimeError with stdout/stderr excerpt (200 chars).

Full example, SU2 Euler solver¶

import os

def write_su2_config(inputs, workdir):
    config = f"""
SOLVER= EULER
MACH_NUMBER= {inputs['mach']}
AOA= {inputs['alpha_deg']}
REYNOLDS_NUMBER= {inputs['Re']}
MESH_FILENAME= mesh.su2
OUTPUT_FILES= (RESTART, CSV)
CONV_FIELD= RMS_DENSITY
"""
    with open(os.path.join(workdir, "flow.cfg"), "w") as f:
        f.write(config)

def parse_su2_output(workdir):
    history_file = os.path.join(workdir, "history.csv")
    with open(history_file) as f:
        lines = [l.strip() for l in f if l.strip()]
    headers = [h.strip() for h in lines[0].split(",")]
    last    = [v.strip() for v in lines[-1].split(",")]
    row = dict(zip(headers, last))
    return {"CL": float(row["CL"]), "CD": float(row["CD"]), "CM": float(row["CMz"])}

su2 = Adapter("su2_airfoil",
    backend="cli",
    command="SU2_CFD flow.cfg",
    inputs={
        "mach":      {"desc": "Freestream Mach number"},
        "alpha_deg": {"desc": "Angle of attack (degrees)"},
        "Re":        {"desc": "Reynolds number"},
    },
    outputs={"CL": {}, "CD": {}, "CM": {}},
    setup=write_su2_config,
    parse=parse_su2_output,
    timeout=600,
    cwd="./su2_runs",
)

Using Adapters in Systems¶

Once created, adapters work exactly like any other Relation:

pipe = anvil.system("pipe_flow")
pipe.add("P",     500e3, "Pa")
pipe.add("T",     350,  "K")
pipe.add("D_pipe", 0.05, "m")
pipe.add("V_flow", 2.0,  "m/s")

pipe.use(water_props)                          # rho, mu, cp, k from CoolProp
pipe.use("reynolds_number", map={             # uses rho, mu from adapter
    "L_char": "D_pipe",
    "V":      "V_flow",
})

result = pipe.solve_forward()
result.summary()

Direct call:

result = water_props(P=101325, T=300)
print(result["rho"])   # Q(998.2, "kg/m^3")

Sweep with adapter:

import numpy as np
sweep = pipe.sweep("T", np.linspace(280, 370, 20), parallel=4)
sweep.summary(outputs=["rho", "mu", "Re"])

Quick Checklist for Writing a New Adapter¶

Use this as a recipe when asking an LLM or writing an adapter from scratch.

1. Write the wrapper function
   - Name it _call or _<adapter_name>_call (private, one per adapter)
   - Import the external library INSIDE the wrapper (lazy import)
   - Add a _require_<lib>() helper raising ImportError with the install
     command, plus a module-level is_available() -> bool (NO mock fallbacks)
   - Inputs arrive as SI floats for numeric params; strings/booleans pass through unchanged
   - Convert to tool's native units inside the wrapper (e.g., m -> km for poliastro)
   - Return a dict of {key: Q(value, "unit")} for all outputs

2. Declare the Adapter object
   - name: lowercase_with_underscores, globally unique
   - backend: "python" (library call) or "cli" (subprocess)
   - inputs spec: numeric inputs get "unit" (SI unit); strings/flags omit "unit"; add "default" if optional
   - outputs spec: always include "unit" so downstream Systems know the dimension
   - desc: one sentence
   - tags: 3-5 keywords for registry search

3. Add a register() function
   - anvil.push(adapter, domain="domain.subdomain", tags=[...])

4. Add if __name__ == "__main__": with a 3-5 line smoke test
   - Call the adapter directly with concrete values
   - Print results so output is verifiable

5. Save to adapters/<library_name>.py

Vector inputs checklist: If the tool takes a position/velocity vector: - Flatten to r_x, r_y, r_z scalar inputs - Reassemble r = [r_x, r_y, r_z] inside the wrapper - Return components as r_x_f, r_y_f, r_z_f


Built-in Cantera Adapters¶

Located in anvil/adapters/cantera_thermo.py. Require conda install -c cantera cantera.

from cantera_thermo import cea_rocket, equilibrium_flame, register

cea_rocket, Rocket equilibrium¶

Inputs: fuel, oxidizer, OF, Pc, T_fuel, T_ox Outputs: Tc [K], gamma_c, R_gas_c [J/kg/K], MW_c [kg/mol], rho_c [kg/m³], cstar [m/s]

r = cea_rocket(fuel="H2", oxidizer="O2", OF=6.0, Pc=10e6)
# Tc ≈ 3400 K  gamma_c ≈ 1.2  cstar ≈ 2380 m/s

equilibrium_flame, Adiabatic flame temperature¶

Inputs: fuel, oxidizer, phi, T_init, P Outputs: T_ad [K], gamma, MW [kg/mol], rho [kg/m³]

register(), push both to global registry¶

register()   # pushes cea_rocket and equilibrium_flame under domain "propulsion.combustion"

Real only: requires Cantera; calls raise ImportError with install instructions when it is missing. Check cantera_thermo.is_available() first. See examples/ex09_cantera_cea.py.


Registering Adapters¶

# Global registry
anvil.push(water_props, domain="fluid.water", tags=["coolprop", "water"])
anvil.R.coolprop_water(P=101325, T=300)

# Project registry
proj = anvil.project("hx_study", path="./work")
proj.push(water_props, domain="fluid")
proj.R.coolprop_water(P=101325, T=300)

Standard adapter file layout¶

"""
Anvil adapter for CoolProp, water thermophysical properties.

Requirements:
    pip install CoolProp
"""
from anvil import Adapter, Q


def _call(P, T):
    try:
        import CoolProp.CoolProp as CP
    except ImportError:
        raise ImportError("CoolProp required.\n  Install: pip install CoolProp")
    rho = CP.PropsSI('D', 'P', P, 'T', T, 'Water')
    return {"rho": Q(rho, "kg/m^3")}


adapter = Adapter("coolprop_water",
    backend="python", call=_call,
    inputs={"P": {"unit": "Pa", "default": 101325},
            "T": {"unit": "K", "default": 300}},
    outputs={"rho": {"unit": "kg/m^3", "desc": "Water density"}},
    desc="Water density via CoolProp",
    tags=["fluid", "water", "coolprop"],
)


def register():
    import anvil
    anvil.push(adapter, domain="fluid.water")


if __name__ == "__main__":
    r = adapter(P=101325, T=300)
    print(f"Water density at STP: {r['rho']}")

Adapter Inspection¶

print(adapter.info())
# Adapter: coolprop_water  (backend: python)
#   Water density via CoolProp
#   Inputs:
#     P [Pa] = 101325  -- Pressure
#     T [K]            -- Temperature
#   Outputs:
#     rho [kg/m^3]     -- Water density
#   Tags: fluid, water, coolprop

anvil.check("coolprop_water")   # after registering

Unit Handling in Adapters¶

Anvil always passes SI floats to the wrapper. The "unit" field in inputs spec tells Anvil to divide the SI value by the unit scale first:

# If "P": {"unit": "bar"}
# Workspace has P = 100000 Pa (SI)
# scale("bar") = 1e5
# Wrapper receives P = 100000 / 1e5 = 1.0 bar

For the outputs spec: - If wrapper returns Q(val, "unit") → used directly, unit info from Q - If wrapper returns plain float and "unit" is declared → wrapped as Q(float, "unit") - If wrapper returns plain float and no "unit" → raw float in workspace

# Celsius example, degC / °C are supported since v1.3
inputs={"T": {"unit": "degC"}}   # wrapper receives value in °C
# Or declare in K and convert inside:
inputs={"T": {"unit": "K"}}
def _wrapper(T):   # T arrives in Kelvin (SI)
    T_celsius = T - 273.15
    result = tool(T_c=T_celsius)
    return {"rho": Q(result["rho"], "kg/m^3")}

Supported units for "unit" field: Any unit string in the Anvil unit database (see Unit Engine for full list). Compound units like "g/s", "W/m^2" also work.


poliastro Adapter (Orbital Mechanics)¶

Located in anvil/adapters/poliastro_orbits.py. Requires pip install poliastro astropy, real only, no mock fallback (is_available() to check). The equivalent closed-form two-body math lives in the native orbital RSQs (hohmann_transfer, vis_viva, orbital_period, ...).

from anvil.adapters.poliastro_orbits import poliastro_orbit, poliastro_hohmann, poliastro_propagate, register

poliastro_orbit, Keplerian elements to ECI state¶

Inputs: a [m], ecc, inc [rad], raan [rad], argp [rad], nu [rad], mu (default: Earth) Outputs: r_x, r_y, r_z [m], v_x, v_y, v_z [m/s], r_mag [m], v_mag [m/s], period [s], r_apoapsis [m], r_periapsis [m]

import math
r = poliastro_orbit(a=6778e3, ecc=0.0, inc=math.radians(51.6),
                     raan=0.0, argp=0.0, nu=0.0)
# period ≈ 5556 s   v_mag ≈ 7669 m/s

poliastro_hohmann, Hohmann transfer delta-v¶

Inputs: a_i [m], a_f [m], mu (default: Earth) Outputs: dv_1 [m/s], dv_2 [m/s], dv_total [m/s], t_transfer [s], a_transfer [m]

r = poliastro_hohmann(a_i=6578e3, a_f=42164e3)
# dv_total ≈ 3930 m/s   t_transfer ≈ 18928 s (5.26 h)

poliastro_propagate, Orbit propagation by time¶

Inputs: Keplerian elements + dt [s], mu (default: Earth) Outputs: r_x, r_y, r_z [m], v_x, v_y, v_z [m/s], nu_f [rad]

r = poliastro_propagate(a=6778e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0,
                         nu=0.0, dt=1380.0)  # quarter orbit
# nu_f ≈ π/2  (90 deg)

register(), push to global registry¶

register()   # domain "orbital.poliastro"
anvil.R.poliastro_hohmann(a_i=6578e3, a_f=42164e3)

See examples/ex21_poliastro_adapter.py for a full demonstration including System chaining, sweeps, and sensitivity analysis.


pykep Adapter (Trajectory Design)¶

Located in anvil/adapters/pykep_trajectories.py. Requires pip install pykep, real only, no mock fallback; every adapter raises ImportError when pykep is missing (is_available() to check).

from anvil.adapters.pykep_trajectories import pykep_lambert, pykep_propagate, pykep_planet_state, register

All inputs and outputs are in SI (m, m/s, s, m³/s²). pykep uses SI natively.

pykep_planet_state, Planet heliocentric state¶

Inputs: planet (string: "earth", "mars", "venus", ...), epoch_mjd2000 (days since 2000-01-01.5) Outputs: r_x, r_y, r_z [m], v_x, v_y, v_z [m/s], r_mag [m], v_mag [m/s]

r = pykep_planet_state(planet="earth", epoch_mjd2000=0.0)
# r_mag ≈ 1.496e11 m (1 AU)   v_mag ≈ 29780 m/s

pykep_propagate, Cartesian state propagation¶

Inputs: r_x, r_y, r_z [m], v_x, v_y, v_z [m/s], dt [s], mu (default: Earth) Outputs: r_x_f, r_y_f, r_z_f [m], v_x_f, v_y_f, v_z_f [m/s], r_mag_f [m], v_mag_f [m/s]

r = pykep_propagate(r_x=r0[0], r_y=r0[1], r_z=r0[2],
                     v_x=v0[0], v_y=v0[1], v_z=v0[2], dt=3600.0)

pykep_lambert, Lambert arc (pykep required)¶

Inputs: r0_x, r0_y, r0_z [m], r1_x, r1_y, r1_z [m], tof [s], mu (default: Sun), cw, multi_revs Outputs: v_dep_x, v_dep_y, v_dep_z [m/s], v_arr_x, v_arr_y, v_arr_z [m/s], dv_dep [m/s], dv_arr [m/s], dv_total [m/s]

sol = pykep_lambert(
    r0_x=r_earth["r_x"].si, r0_y=r_earth["r_y"].si, r0_z=r_earth["r_z"].si,
    r1_x=r_mars["r_x"].si, r1_y=r_mars["r_y"].si, r1_z=r_mars["r_z"].si,
    tof=200 * 86400,
)
# dv_dep ≈ 30.1 km/s  (heliocentric departure speed)

register(), push to global registry¶

register()   # domain "trajectory.pykep"

See examples/ex22_pykep_adapter.py for a full demonstration including Lambert arcs, porkchop sweeps, and a combined LEO-departure + interplanetary-arc mission budget.


XFOIL Adapter (2D Airfoil)¶

Located in anvil/adapters/xfoil_airfoil.py. Requires the XFOIL binary on PATH (xfoil or xfoil.exe), real only, no mock fallback; a missing binary raises RuntimeError with the download link (is_available() to check). Get XFOIL: https://web.mit.edu/drela/Public/web/xfoil/

from anvil.adapters.xfoil_airfoil import xfoil_polar, xfoil_alpha_sweep, register

xfoil_polar, Single operating point¶

Inputs: airfoil (NACA designation like "NACA2412", or .dat path), Re, alpha_deg, Mach, Ncrit Outputs: CL, CD, CM, xtr_top, xtr_bot

r = xfoil_polar(airfoil="NACA2412", Re=1e6, alpha_deg=4.0, Mach=0.1)
print(r["CL"], r["CD"], r["CM"])

xfoil_alpha_sweep, Full polar¶

Inputs: airfoil, Re, alpha_min, alpha_max, alpha_step, Mach, Ncrit Outputs: alpha_array, CL_array, CD_array, CM_array, CL_max, LD_max, n_converged

Non-converged alphas are skipped (alpha_array holds only converged points); if nothing converges the adapter raises RuntimeError.

r = xfoil_alpha_sweep(airfoil="NACA2412", Re=1.5e6,
                      alpha_min=-4, alpha_max=14, alpha_step=2)
print(f"L/D max = {r['LD_max']:.1f}   CL_max = {r['CL_max']:.3f}")

register(), domain aero.xfoil¶


OpenFOAM CFD Adapter¶

Located in anvil/adapters/openfoam_cfd.py. Requires OpenFOAM installed with the solver on PATH, real only, no mock fallback; a missing solver or case directory raises a clear error (is_available() to check).

from anvil.adapters.openfoam_cfd import openfoam_incompressible, openfoam_compressible, register

openfoam_incompressible, simpleFoam (low-speed)¶

Inputs: case_path, U_inf [m/s], alpha_deg, rho [kg/m³], nu [m²/s], L_ref [m], A_ref [m²], n_cores, solver Outputs: CL, CD, CM, F_lift [N], F_drag [N], Re, source

r = openfoam_incompressible(case_path="./my_case", U_inf=50.0, alpha_deg=5.0)
print(r["CL"], r["CD"], r["F_lift"])

Case requirements: prepared OpenFOAM case with mesh (blockMesh or snappyHexMesh already run), 0/U, 0/p, system/controlDict with forceCoeffs function object.

openfoam_compressible, rhoSimpleFoam (transonic/supersonic)¶

Inputs: case_path, U_inf [m/s], alpha_deg, p_inf [Pa], T_inf [K], gamma, L_ref, A_ref, n_cores, solver Outputs: CL, CD, CM, Mach, F_lift [N], F_drag [N], source

r = openfoam_compressible(case_path="./transonic_case", U_inf=272.0, alpha_deg=3.0)
print(r["CL"], r["Mach"])
# Mach ≈ 0.80  CL ≈ 0.196

register(), domain cfd.openfoam¶


SU2 CFD Adapter¶

Located in anvil/adapters/su2_aero.py. Requires SU2_CFD on PATH plus a .cfg template and .su2 mesh, real only, no mock fallback; missing binary or files raise clear errors (is_available() to check).

from anvil.adapters.su2_aero import su2_euler, su2_rans, register

su2_euler, Inviscid Euler¶

Inputs: cfg_template (.cfg file path), mesh (.su2 mesh path), Mach, AoA_deg, sideslip_deg, alpha0_deg Outputs: CL, CD, CM, Mach, source

r = su2_euler(cfg_template="naca0012.cfg", mesh="naca0012.su2",
              Mach=0.3, AoA_deg=4.0)
print(r["CL"], r["CD"])
# CL ≈ 0.439   CD ≈ 0.00129 (induced only, no friction)

su2_rans, Turbulent RANS (Spalart-Allmaras)¶

Inputs: same as su2_euler + Reynolds Outputs: CL, CD, CM, Mach, Re, source

r = su2_rans(cfg_template="naca0012_sa.cfg", mesh="naca0012.su2",
             Mach=0.3, AoA_deg=4.0, Reynolds=3e6)
print(r["CL"], r["CD"])
# CL ≈ 0.439   CD ≈ 0.00589 (pressure + friction)

Config patching: The adapter patches MACH_NUMBER, AOA, SIDESLIP_ANGLE, and optionally REYNOLDS_NUMBER in the template. All other settings (numerics, mesh filename path handling, BCs) come from the template.

register(), domain cfd.su2¶


OpenMDAO MDO Adapter¶

Located in anvil/adapters/openmdo_wrap.py. Requires pip install openmdao, real only, no mock fallback (is_available() to check).

from anvil.adapters.openmdo_wrap import make_openmdo_adapter, openmdo_sellar, openmdo_beam, register

make_openmdo_adapter, Factory for any OpenMDAO Problem¶

def build_prob():
    import openmdao.api as om
    # ... set up components ...
    p = om.Problem()
    p.setup()
    return p

adapter = make_openmdo_adapter(
    prob_factory=build_prob,
    input_vars={"x": {"unit": "1", "desc": "Input", "default": 0.0}},
    output_vars={"f": {"unit": "1", "desc": "Output"}},
    name="my_mdo",
    run_driver=False,    # True = optimize, False = single analysis
)
r = adapter(x=3.0)

openmdo_sellar, Sellar coupled MDO benchmark¶

Inputs: x1, z1, z2 (dimensionless) Outputs: f (objective), g1, g2 (constraints ≤ 0), y1, y2 (coupling), source

r = openmdo_sellar(x1=1.0, z1=5.0, z2=2.0)
print(r["f"], r["g1"], r["g2"])
# f ≈ 28.6  g1 ≈ -22.4 (feasible)  g2 ≈ -12.1 (feasible)

openmdo_beam, Cantilever beam ExplicitComponent¶

Inputs: F_tip [N], L_beam [m], E [Pa], b [m], h [m] Outputs: deflection [m], max_stress [Pa], I_moment [m⁴], source

r = openmdo_beam(F_tip=5000, L_beam=2.0, E=70e9, b=0.05, h=0.10)
print(r["deflection"], r["max_stress"])
# deflection ≈ 0.0366 m   max_stress ≈ 480 MPa

register(), domain mdo.openmdao¶


FEniCSx FEM Adapter¶

Located in anvil/adapters/fenics_fem.py. Requires conda install -c conda-forge fenics-dolfinx mpich, real only, no mock fallback; missing dolfinx raises ImportError (is_available() to check).

from anvil.adapters.fenics_fem import fenics_linear_elasticity, fenics_heat_conduction, register

fenics_linear_elasticity, 3D box linear elasticity¶

Inputs: E [Pa], nu, Lx [m], Ly [m], Lz [m], F_distributed [Pa], nx, ny, nz Outputs: max_displacement [m], max_von_mises [Pa], source

r = fenics_linear_elasticity(E=200e9, nu=0.3,
    Lx=1.0, Ly=0.05, Lz=0.05,
    F_distributed=1e4)
print(r["max_displacement"], r["max_von_mises"])
# max_displacement ≈ 1.15e-4 m   max_von_mises ≈ 2.4 MPa

Setup: Cantilever fixed at x=0, distributed traction on top face (z=Lz).

fenics_heat_conduction, 3D steady heat conduction¶

Inputs: k [W/m/K], Lx, Ly, Lz [m], T_left [K], T_right [K], Q_vol [W/m³], nx, ny, nz Outputs: T_max [K], heat_flux [W], source

r = fenics_heat_conduction(k=205.0, Lx=0.5, Ly=0.02, Lz=0.02,
                            T_left=600.0, T_right=300.0)
print(r["T_max"], r["heat_flux"])
# T_max = 600 K   heat_flux ≈ 9.84 W

register(), domain fem.fenics¶


pyNASTRAN / NASTRAN Adapter¶

Located in anvil/adapters/pynastran_fem.py. Requires pip install pyNastran AND a NASTRAN-compatible solver binary on PATH (MYSTRAN free, MSC NASTRAN, NX NASTRAN, Optistruct auto-detected), real only, no mock fallback; missing either raises a clear error (is_available() to check).

from anvil.adapters.pynastran_fem import nastran_linear_static, nastran_normal_modes, register

nastran_linear_static, SOL 101¶

Inputs: bdf_path, load_case_id, nastran_bin (None = auto-detect) Outputs: max_displacement [m], max_stress [Pa], source

r = nastran_linear_static(bdf_path="my_model.bdf", load_case_id=1)
print(r["max_displacement"], r["max_stress"])

nastran_normal_modes, SOL 103¶

Inputs: bdf_path, n_modes, nastran_bin Outputs: frequencies [list of Q], f1 [Hz], f2 [Hz], n_modes, source

r = nastran_normal_modes(bdf_path="my_model.bdf", n_modes=6)
print(r["f1"], r["frequencies"])

MYSTRAN (free solver): Download from https://github.com/dr-bill-c/MYSTRAN. Add mystran.exe to PATH. The adapter auto-detects it.

register(), domain fem.nastran¶


Surrogate / Metamodel Adapters¶

Located in anvil/adapters/surrogate_models.py. GP surrogates require pip install scikit-learn, real only, no spline fallback (is_available() to check). Polynomial (numpy.polyfit) and RBF (scipy.interpolate.RBFInterpolator) are real methods built on Anvil's core dependencies and always work.

from anvil.adapters.surrogate_models import (
    make_gp_adapter, make_poly_adapter, make_rbf_adapter, gp_demo, register
)

make_gp_adapter, Gaussian Process from training data¶

import numpy as np
X_train = np.linspace(0, 10, 15).reshape(-1, 1)
y_train = np.sin(X_train.ravel()) + 0.02 * np.random.randn(15)

gp = make_gp_adapter(
    X_train, y_train,
    x_name="x", y_name="y_pred",
    x_unit="m", y_unit="1",
    name="sin_gp",
)
r = gp(x=3.14)
print(r["y_pred"], r["y_pred_std"])   # mean prediction + uncertainty

Requires scikit-learn; raises ImportError with the install command when missing.

make_poly_adapter, Polynomial chaos / polyfit¶

poly = make_poly_adapter(X_train, y_train,
    x_name="x", y_name="y_pred",
    degree=4, name="sin_poly")
r = poly(x=3.14)
# Works with numpy only, always available

make_rbf_adapter, RBF interpolation (multi-input)¶

X_2d = np.column_stack([aoa_vals, mach_vals])   # shape (n, 2)
rbf  = make_rbf_adapter(X_2d, cl_vals,
    input_names=["AoA_deg", "Mach"],
    y_name="CL_pred",
    function="multiquadric",
    name="cl_rbf",
)
r = rbf(AoA_deg=5.0, Mach=0.3)
# Requires scipy (always available as Anvil dependency)

gp_demo, Pre-built demo GP (noisy sine)¶

r = gp_demo(x=1.57)
print(r["y_pred"], r["y_exact"])   # GP prediction vs exact sin(x)

Surrogates in Systems¶

sys_ = anvil.system("drag_polar")
sys_.add("AoA_deg", 0.0)
sys_.use(gp_cd)   # gp_cd = make_gp_adapter(...)

sweep = sys_.sweep("AoA_deg", np.linspace(-4, 14, 10))

register(), domain surrogate.demo¶


Other Bundled Adapters¶

Brief entries, see each module's docstring for full I/O specs. All are real-only with is_available().

coolprop_props.py, Fluid properties (fluid.coolprop)¶

coolprop_props(fluid, T, P) → rho, cp, mu, k, ... via CoolProp. pip install CoolProp.

meshing_geom.py, Mesh generation (geometry.meshing)¶

mesh_box(Lx, Ly, Lz, elem_size) and mesh_cylinder(radius, height, elem_size) → element/node counts + mesh file, via gmsh. pip install gmsh.

rocket_cea.py, Rocket performance (propulsion.rocketcea)¶

rocket_cea(oxidizer, fuel, OF, Pc, ...) → Isp, cstar, Tc, ... via RocketCEA (pip install rocketcea); rocketpy_flight(thrust, burn_time, dry_mass, ...) → apogee/velocity via RocketPy (pip install rocketpy).

uq_surrogate.py, Monte Carlo UQ (uq.montecarlo)¶

uq_montecarlo(model, a_mean, a_std, ...) → mean/std/quantiles + surrogate R². The Monte Carlo path is native numpy and always runs (this is NOT a mock, numpy is a core dependency); only surrogate="sklearn" needs scikit-learn.


Adapter Comparison¶

All adapters are real-only, the Requires column must be satisfied or calls raise with install instructions.

Adapter file Requires Domain Best for
cantera_thermo.py Cantera propulsion.combustion Combustion, flame temperature
nasa_cea_detonation.py NASA CEA (pip install cea) propulsion.detonation Detonation products
poliastro_orbits.py poliastro + astropy orbital.poliastro Orbit design, Hohmann transfers
pykep_trajectories.py pykep trajectory.pykep Lambert arcs, interplanetary trajectory
xfoil_airfoil.py XFOIL binary on PATH aero.xfoil 2D airfoil polars, viscous drag
openfoam_cfd.py OpenFOAM solvers on PATH cfd.openfoam 3D CFD, incompressible/compressible
su2_aero.py SU2_CFD on PATH cfd.su2 Euler/RANS, adjoint-ready
openmdo_wrap.py OpenMDAO mdo.openmdao MDO problems, coupled systems
fenics_fem.py FEniCSx (dolfinx) fem.fenics FEM elasticity, heat conduction
pynastran_fem.py pyNastran + NASTRAN binary fem.nastran NASTRAN SOL 101/103, OP2 post-proc
surrogate_models.py scikit-learn (GP only) surrogate.demo Data-driven surrogates, metamodels
coolprop_props.py CoolProp fluid.coolprop Thermophysical fluid properties
meshing_geom.py gmsh geometry.meshing Parametric box/cylinder meshes
rocket_cea.py RocketCEA / RocketPy propulsion.rocketcea Engine performance, flight sim
uq_surrogate.py , (numpy core) uq.montecarlo Monte Carlo uncertainty propagation

Limitations¶

Limitation Notes
"http" backend Not yet implemented
"shared_lib" backend Not yet implemented
Async CLI tools Not supported, subprocess.run is synchronous
Stateful wrappers Each call is independent; no session state between calls
Nested subprocess environments CLI adapters inherit the parent process environment; path issues may arise
Thread safety Python adapters are called from ThreadPoolExecutor in parallel sweeps; ensure wrapper function is thread-safe
Array inputs Anvil workspace is scalar-keyed; flatten vectors to named components
String inputs in sweeps sys.sweep() sweeps over numeric ranges; string parameters must be fixed at System level
Registry round-trip anvil.push(adapter) stores the inner wrapper closure, losing the inputs spec. Do not use anvil.R.adapter_name in sys.use(), pass the adapter object directly: sys.use(my_adapter). Register for discoverability only.

Sweep & Sensitivity¶


Parametric Sweep¶

sys.sweep() evaluates the system at multiple values of one parameter and collects all results.

sweep = sys.sweep(
    param_name,          # str, must be in sys._quantities
    values,              # array-like of parameter values (in original declared unit)
    skip_errors=False,   # True: failed points → None instead of raising
    parallel=1,          # int > 1: concurrent evaluation via ThreadPoolExecutor
    warm_start=False,    # True: carry computed outputs forward as initial guess
    **solve_kwargs,      # passed to sys.solve(): method, relaxation, max_iter, …
)

Returns: SweepResult object.

Parameter requirement¶

The parameter must be declared with .add() before sweeping. The values are interpreted in the original declared unit:

sys.add("P0", 6.9e6, "Pa")
# sweep in Pa (SI)
sweep = sys.sweep("P0", np.linspace(5e6, 30e6, 20))

sys.add("T", 300, "K")
# values in K, not Rankine, not Celsius
sweep = sys.sweep("T", np.linspace(200, 600, 20))

If you try to sweep a name not in the system:

sys.sweep("nonexistent", values)
# KeyError: "Cannot sweep 'nonexistent' -- not in system.
#   Available inputs: P0, T0, gamma, ...
#   Hint: use system.add('nonexistent', value) first."

Warm-start¶

For iterative systems (Gauss-Seidel/Newton) sweeping over a slowly varying parameter, warm_start=True carries the previous point's computed outputs forward as initial guesses. This reduces iteration count significantly when adjacent points are close together.

# Without warm_start: every point starts from declared initial guesses
# With warm_start:    each point starts from previous solution

sweep = sys.sweep("UA", np.linspace(500, 5000, 30),
                  method="gauss_seidel", relaxation=0.7,
                  warm_start=True)

Restrictions: - Incompatible with parallel > 1, raises ValueError (parallel order is undefined) - Only updates quantities declared with .add(), not purely computed intermediates - If a warm-started point fails and skip_errors=True, that point's guess is not carried forward

Solver options in sweep¶

# Coupled system, use GS with relaxation
sweep = sys.sweep("UA", np.linspace(500, 5000, 20),
                  method="gauss_seidel", relaxation=0.7, max_iter=100)

# Newton for strongly nonlinear
sweep = sys.sweep("P0", values, method="newton", rtol=1e-10)

Parallel sweep¶

# Uses ThreadPoolExecutor, best for NumPy/SciPy heavy relations
sweep = sys.sweep("M", np.linspace(0.5, 3.0, 50), parallel=4)

Each parallel point gets its own deep copy of the system (sys.copy()), so state doesn't collide. Result order is preserved (indexed by original value order, not completion order).

Practical gains: - Pure Python arithmetic in relations: minimal speedup (GIL) - NumPy/SciPy operations (find_root, matrix math, integrations): near-linear speedup up to ~4 workers

Thread safety requirement: All Relation functions called in parallel sweep must be thread-safe. Pure functions with no global state are safe. Adapters that write to a shared directory are not.

Error handling¶

sweep = sys.sweep("q_heat", np.linspace(0, 1e6, 50), skip_errors=True)
# Points where flow chokes (ValueError) → None in results, UserWarning printed
sweep.summary()  # failed points shown as "(failed)"

Without skip_errors=True (default), the first failure raises RuntimeError with the failed point's value.


SweepResult¶

Accessing results¶

sweep["thrust"]    # numpy array of SI floats, length n_points
sweep["P0"]        # numpy array of parameter values
sweep["M2"]        # numpy array of output values

# To convert output values: use Q
from anvil import Q
thrust_kn = sweep["thrust"] / 1000   # manual conversion

.summary()¶

sweep.summary(outputs=["thrust", "Isp", "mdot"])

Output example:

----------------------------------------------------------------------
  rocket_nozzle -- sweep over P0
----------------------------------------------------------------------
              P0         thrust            Isp           mdot
            [Pa]            [N]            [s]         [kg/s]
  ----------------------------------------------------------------------
         5000000     2.363e+04          268.3          8.986
     6.579e+06     3.116e+04          269.8          11.84
         8.158e+06     3.869e+04          271.3          14.69
         9.737e+06     4.622e+04          272.8          17.54
     1.132e+07     5.375e+04          274.3          20.39
----------------------------------------------------------------------

.to_dict(si=True)¶

d = sweep.to_dict(si=True)   # dict of numpy arrays, SI values
d["thrust"]   # np.array([23630., 31160., ...])

d = sweep.to_dict(si=False)  # dict of numpy arrays, display-unit values

.to_csv() and .to_json()¶

sweep.to_csv("sweep.csv")
sweep.to_csv("sweep.csv", si=True, outputs=["thrust", "Isp"])

sweep.to_json("sweep.json")

CSV format: one column per variable, one row per sweep point.

Jupyter display¶

SweepResult renders as an HTML table in Jupyter. First 8 outputs shown. Units row below headers.


Sensitivity Analysis¶

sys.sensitivity() computes how each output changes relative to each input.

sens = sys.sensitivity(
    outputs=None,    # list of output names; None = all computed outputs
    step=0.01,       # fractional perturbation (1% default)
)

Algorithm: Central finite difference, normalized:

sensitivity[out][inp] = (∂out/∂inp) × (inp/out)

A value of 1.0 means a 1% change in inp → 1% change in out. A value of 3.0 means a 1% change in inp → 3% change in out. Negative values: inverse relationship.

Computation: For each input with non-zero value: 1. Perturb inp by +step*|inp| → solve → get out_plus 2. Perturb inp by -step*|inp| → solve → get out_minus 3. sens = (out_plus - out_minus) / (2*delta) * inp / out_base

Example¶

sys = anvil.system("isentropic")
sys.add("M", 2.0)
sys.add("gamma", 1.4)
sys.use("isentropic_ratios")

sens = sys.sensitivity(outputs=["P0_P", "T0_T"])
sens.summary()

Output:

============================================================
  isentropic -- Sensitivity Analysis
============================================================
  (normalized: 1.0 = 1% input change -> 1% output change)

  P0_P:
    M                     +3.1116  ################
    gamma                 +0.3013  ##

  T0_T:
    M                     +1.4000  #######
    gamma                 +0.5000  ##
============================================================

Interpretation for P0_P: - M: Mach has 3.1× leverage on P0/P, a 1% increase in M → ~3.1% increase in P0/P - gamma: γ has ~0.3× leverage, relatively insensitive

sens.top(output, n=5)¶

top5 = sens.top("P0_P", n=5)
# [("M", 3.1116), ("gamma", 0.3013)]

Returns a sorted list of (input_name, sensitivity_value), highest absolute value first.

sens.to_dict()¶

d = sens.to_dict()
# {"P0_P": {"M": 3.1116, "gamma": 0.3013},
#  "T0_T": {"M": 1.4000, "gamma": 0.5000}}

SensitivityResult Jupyter display¶

Renders as an HTML table with horizontal bar charts for each sensitivity value. Color: blue for positive, red for negative.


Combining Sweep and Sensitivity¶

Typical workflow: use sweep to understand parameter ranges, then sensitivity to know which inputs matter most.

import numpy as np

# 1. Sweep to find interesting operating points
nozzle = anvil.S.rocket_nozzle.copy()
sweep = nozzle.sweep("P0", np.linspace(5e6, 20e6, 20))
sweep.summary(outputs=["thrust", "Isp"])

# 2. Set to a specific point
nozzle.set(P0=12e6)
result = nozzle.solve_forward()

# 3. Sensitivity at that point
sens = nozzle.sensitivity(outputs=["thrust", "Isp"])
sens.summary()

# 4. Top drivers
print("Top drivers for Isp:")
for name, val in sens.top("Isp", n=3):
    print(f"  {name}: {val:+.3f}")

SweepResult Internals¶

SweepResult stores: - _param: parameter name - _values: list of parameter values - _results: list of Result objects (or None for failed points) - _output_keys: list of output names from first valid result

sweep["key"] iterates _results, extracting r[key]._si_value (or float) per point. For failed points it inserts np.nan.

Memory: Each result stores the full workspace. For large systems and many points, SweepResult can use significant memory. Use to_dict() to extract only the arrays you need, then let the result be garbage-collected.


Performance¶

Scenario Time estimate
sweep(n=50), forward-pass system, single thread ~50 × single_solve_time
sweep(n=50, parallel=4), scipy-heavy relations ~15 × single_solve_time
sensitivity(5 inputs, 1 output) ~10 × single_solve_time (2 solves per input)
sensitivity(5 inputs, 5 outputs) ~10 × single_solve_time (outputs evaluated in same solves)

sensitivity() always runs single-threaded. The number of outputs doesn't increase the number of solves, they all come from the same +/- perturbation runs.

Visualization¶

All visualization functions are in anvil.viz. They require matplotlib. Import is deferred, Anvil loads without matplotlib; only viz.* calls trigger the import.

from anvil import viz
# or
import anvil
anvil.viz.convergence(system)

Install matplotlib: pip install matplotlib


viz.convergence(system, ax=None, show=True)¶

Plot convergence residual vs iteration from a monitored solve.

result = system.solve_gauss_seidel(monitor=True)
viz.convergence(system)

Requirements: System must have been solved with monitor=True. Data comes from system.history().

Plot: Semi-log y-axis. Blue o- line. Green dashed line at rtol=1e-6 (default convergence threshold).

Parameters:

Parameter Default Description
system required System with solve history
ax None Existing matplotlib Axes to draw on
show True Call plt.show() if True

Returns: The Axes object (for further customization).

import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(12, 5))
viz.convergence(system1, ax=axes[0], show=False)
viz.convergence(system2, ax=axes[1], show=False)
plt.tight_layout()
plt.show()

If system.history() returns an empty list (no monitor=True solve), prints:

  No monitoring data. Use system.solve(monitor=True).

Convergence example output (from ex02_heat_exchanger.py):

The HX system converges in 33 iterations with residual dropping from 2.0 to 6.9×10⁻¹¹. The convergence curve is exponentially decaying, typical for linear Gauss-Seidel on this system.


viz.variable_trace(system, variables, ax=None, show=True)¶

Plot how specific variable values evolved across iterations.

result = system.solve_gauss_seidel(monitor=True)
viz.variable_trace(system, ["T_hot_out", "T_cold_out"])

Parameters:

Parameter Default Description
system required System with solve history
variables required List of variable name strings
ax None Existing Axes
show True Call plt.show()

Plot: Line plot of each variable's SI value vs iteration. Multiple lines on same axes with legend. Useful for seeing if a variable oscillates, converges monotonically, or diverges before convergence.

Typical use: Debugging Gauss-Seidel convergence. If a variable oscillates, try lower relaxation.


viz.sweep_plot(sweep_result, y=None, x_label=None, ax=None, show=True)¶

Plot sweep results as line plots.

sweep = sys.sweep("P0", np.linspace(5e6, 20e6, 30))
viz.sweep_plot(sweep, y=["thrust", "Isp", "mdot"])

Parameters:

Parameter Default Description
sweep_result required SweepResult from sys.sweep()
y None Output names to plot; None = first 4 outputs
x_label None X-axis label; None = parameter name
ax None Axes (ignored if multiple outputs, creates figure internally)
show True Call plt.show()

Layout: - 1 output → single axes - 2 outputs → 1×2 grid - 3-4 outputs → 2×2 grid (last panel hidden if 3) - N outputs → ceil(N/2) × 2 grid

Each subplot has: - Title: output name - X-label: parameter name - Y-label: "output_name [unit]" (unit from first result) - Figure title: "system_name -- Sweep over param"

# Save instead of showing
fig = viz.sweep_plot(sweep, y=["thrust", "Isp"], show=False)
fig.savefig("sweep.png", dpi=150, bbox_inches="tight")

viz.dependency_graph(system, show=True, save=None)¶

Plot the system's dependency graph: inputs → relations → outputs.

sys.validate()   # must be validated first for edge detection
viz.dependency_graph(sys)
# or
viz.dependency_graph(sys, show=False, save="graph.png")

Layout: - Left column: input quantities (green boxes) - Middle column: relations (orange rounded boxes) - Right column: computed outputs (blue boxes) - Grey arrows: inputs → relations - Blue arrows: relations → outputs

Parameters:

Parameter Default Description
system required System (will auto-validate if not done)
show True Call plt.show()
save None Filepath to save PNG (300 DPI)

Returns: The Figure object.

Known limitations: - Layout is purely positional (3 columns), not a proper graph layout algorithm. For systems with many relations, nodes may overlap. - For large systems (>20 relations), output node positions compress and labels become unreadable at default figure size. Scale figsize manually using matplotlib after the call. - Cycles are not visually distinguishable from acyclic connections.

Example, rocket nozzle dependency graph:

Input column (7 nodes): P0, T0, gamma, R_gas, A_throat, A_exit, P_amb

Relations column (8 nodes): nozzle_area_ratio → area_mach_supersonic → isentropic_ratios → exit_conditions → exit_velocity + choked_mass_flow → rocket_thrust → specific_impulse

Output column (12 nodes): area_ratio, M_exit, T0_T, P0_P, rho0_rho, T_exit, P_exit, a_exit, V_exit, mdot, thrust, Isp


viz.pod_energy(pod_result, ax=None, show=True, threshold=0.99)¶

Two-panel figure showing POD singular value spectrum and cumulative energy. Used to select the truncation rank for reconstruction or reduced-order modeling.

from anvil import viz, decomp

pod = decomp.pod(X, r=20)
viz.pod_energy(pod)
viz.pod_energy(pod, threshold=0.999)   # mark 99.9% instead of 99%

Left panel: Bar chart of singular values on a log scale. A steep drop indicates low-rank structure in the data.

Right panel: Cumulative energy (%) as a function of mode count. A green dashed line marks threshold. A vertical dotted line and annotation show how many modes achieve that threshold.

Parameters:

Parameter Default Description
pod_result required Dict from anvil.decomp.pod()
ax None Array of 2 Axes [ax_left, ax_right]; creates figure if None
show True Call plt.show()
threshold 0.99 Energy threshold for the guide line

Returns: The Figure object.

# Save to file
fig = viz.pod_energy(pod, show=False)
fig.savefig("pod_energy.png", dpi=150, bbox_inches="tight")

viz.dmd_spectrum(dmd_result, ax=None, show=True, unit_circle=True)¶

Scatter plot of DMD eigenvalues in the complex plane. Marker size and color encode normalized mode amplitude, making the dominant modes immediately visible.

from anvil import viz, decomp

dmd_r = decomp.dmd(H, dt=0.005, r=12)
viz.dmd_spectrum(dmd_r)

Stability interpretation: - Eigenvalues inside the unit circle (|λ|<1): stable, decaying modes - Eigenvalues on the unit circle (|λ|≈1): neutrally stable, purely oscillatory - Eigenvalues outside the unit circle (|λ|>1): unstable, growing modes

Frequency: The angle arg(λ) encodes frequency. Eigenvalues at ±iy on the unit circle are at frequency y/(2π dt) Hz.

Parameters:

Parameter Default Description
dmd_result required Dict from anvil.decomp.dmd()
ax None Existing Axes; creates figure if None
show True Call plt.show()
unit_circle True Draw |λ|=1 circle as dashed line

Returns: The Figure object.

# Two spectra side by side
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
viz.dmd_spectrum(dmd_before, ax=axes[0], show=False)
viz.dmd_spectrum(dmd_after, ax=axes[1], show=False)
axes[0].set_title("Before treatment")
axes[1].set_title("After treatment")
plt.tight_layout()
plt.show()

viz.abel_compare(image, abel_result, ax=None, show=True, cmap="hot", log_scale=False)¶

Side-by-side comparison of a raw camera projection and its Abel-inverted radial distribution. Used for flame, plasma, and symmetric emission imaging.

from anvil import viz, decomp

# Run Abel inversion
abel_r = decomp.abel_image(image, method="three_point")

# Compare projection vs radial distribution
viz.abel_compare(image, abel_r)

# Log scale for high dynamic-range data (flames, plasma)
viz.abel_compare(image, abel_r, log_scale=True, cmap="inferno")

# Save without displaying
fig = viz.abel_compare(image, abel_r, show=False)
fig.savefig("abel_compare.png", dpi=150, bbox_inches="tight")

Layout: 1×2 figure. Left panel shows the original projection; right panel shows the recovered radial distribution. Both panels share the colormap. The symmetry axis (column center) is overlaid as a cyan dashed line.

Auto-scaling: Each panel clips at the 99th percentile of finite values to suppress hot pixels. Pass ax=[ax0, ax1] to embed in an existing figure layout.

Parameters:

Parameter Default Description
image required 2D ndarray, original camera image (projection)
abel_result required Dict from anvil.decomp.abel_image()
ax None Array of 2 Axes [ax_raw, ax_radial]; creates figure if None
show True Call plt.show()
cmap "hot" Colormap, "hot" for flames, "gray" for absorption, "viridis" for plasma density
log_scale False Apply log1p to both images before display

Returns: The Figure object.

When to use log_scale=True: Flame cores and plasma jets can be 3-4 orders of magnitude brighter than the edges. Without log scale, the outer structure is invisible. With log scale, both core and edge features are visible in the same frame.


Saving Figures¶

All functions return an object (Axes or Figure) that can be used for further customization:

import matplotlib.pyplot as plt

# Convergence
ax = viz.convergence(sys, show=False)
ax.set_title("Custom Title")
ax.set_ylim([1e-12, 1e1])
plt.savefig("convergence.png", dpi=150)

# Sweep
fig = viz.sweep_plot(sweep, y=["thrust", "Isp"], show=False)
plt.suptitle("Parametric Study, P0 variation", y=1.02)
fig.savefig("sweep_study.png", dpi=200, bbox_inches="tight")

# Dependency graph
fig = viz.dependency_graph(sys, show=False, save="system_graph.png")

Jupyter Integration¶

In Jupyter notebooks, show=True (default) calls plt.show() inline. If you're working in Jupyter and want to embed plots in the notebook:

import matplotlib
matplotlib.use("inline")   # or use %matplotlib inline magic

Then call viz.* with show=True (default), figures display inline automatically.

For notebooks: the SweepResult._repr_html_(), SensitivityResult._repr_html_(), Result._repr_html_(), and Q._repr_html_() methods provide rich table/chart displays without matplotlib.

Built-in Databases¶

Anvil ships three read-only databases:

Database Access Contents
anvil.db.const anvil.db.const.c, .R, etc. Physical constants
anvil.db.fluids anvil.fluids.get("air", T=300) Fluid property tables
anvil.db.materials anvil.materials.get("Al-6061-T6") Structural material properties
from anvil.db import fluids, materials, const
# or
import anvil
anvil.fluids.get("water", T=350)
anvil.materials.get("steel-4340")

Physical Constants (anvil.db.const)¶

from anvil.db import const

const.c       # Q(299792458.0, "m/s"), speed of light
const.h       # Q(6.626e-34, "J")  , Planck constant
const.k_B     # Q(1.381e-23, "J/kg/K"), Boltzmann
const.R       # Q(8.314, "J/mol/K"), gas constant
const.N_A     # Q(6.022e23, "")    , Avogadro
const.g0      # Q(9.80665, "m/s^2"), standard gravity
const.sigma   # Q(5.67e-8, "W")    , Stefan-Boltzmann
const.atm     # Q(101325.0, "Pa")  , standard atmosphere
const.T_sl    # Q(288.15, "K")     , sea-level temperature
const.rho_sl  # Q(1.225, "kg/m^3"), sea-level density
const.a_sl    # Q(340.294, "m/s") , sea-level speed of sound
const.gamma_air # Q(1.4, "")       , air heat ratio
const.R_air   # Q(287.058, "J/kg/K"), air gas constant
const.M_air   # Q(0.0289647, "kg/mol"), air molar mass
const.cp_air  # Q(1005.0, "J/kg/K"), air specific heat
const.pi      # Q(3.14159, "")     , π

# List all constants
const.list()
# ['N_A', 'R', 'R_air', 'T_sl', 'a_sl', 'a_sl', 'atm', 'c', 'cp_air', 'g0',
#  'gamma_air', 'h', 'k_B', 'M_air', 'pi', 'rho_sl', 'sigma', 'T_sl']

# Search
const.search("gravity")
# [('g0', 9.8067 m/s^2)]

const.search("gas")
# [('R', 8.3145 J/mol/K), ('R_air', 287.06 J/kg/K)]

Fluid Database (anvil.fluids)¶

Property tables for common engineering fluids. Uses curve-fit or tabulated data (no external packages needed).

fluids.get(name, T, P=101325)¶

from anvil.db import fluids

# Air at 500 K, 1 atm
air = fluids.get("air", T=500)
print(air["rho"])    # Q(0.7054, "kg/m^3")
print(air["mu"])     # Q(2.670e-5, "Pa*s")
print(air["cp"])     # Q(1005.0, "J/kg/K")
print(air["k"])      # Q(0.0404, "W/m/K")
print(air["gamma"])  # 1.4 (dimensionless float)
print(air["R_gas"])  # Q(287.058, "J/kg/K")
print(air["Pr"])     # 0.71

# Water at 350 K
water = fluids.get("water", T=350)
# rho, mu, cp, k properties

# Nitrogen at high temperature
n2 = fluids.get("nitrogen", T=1000)

# Helium
he = fluids.get("helium", T=300)

# Hydrogen
h2 = fluids.get("hydrogen", T=300)

# CO2
co2 = fluids.get("co2", T=300)

Available fluids¶

Name Description T range
"air" Dry air at 1 atm 200-2000 K
"nitrogen" N₂ 200-2000 K
"helium" He 50-2000 K
"hydrogen" H₂ 200-3000 K
"co2" CO₂ 250-1500 K
"water" Liquid/vapor 280-600 K
"methane" CH₄ 200-2000 K
"oxygen" O₂ 200-2000 K
"argon" Ar 100-2000 K

Property models¶

Most gaseous fluids use: - Density: Ideal gas law, ρ = P/(R·T) - Viscosity: Sutherland's law, μ = μ_ref·(T/T_ref)^1.5·(T_ref+S)/(T+S) - Conductivity: Power law, k = k_ref·(T/T_ref)^n - cp, γ: Constant (approximate)

These are suitable for temperature ranges away from critical point and liquid-vapor transitions. For high-accuracy near-critical or cryogenic properties, use the CoolProp adapter.

fluids.search(keyword)¶

fluids.search("nitrogen")
# Returns list of matching fluid entries

Usage in System¶

fluid = fluids.get("air", T=350, P=200e3)
sys.add("rho", float(fluid["rho"].si), "kg/m^3")
sys.add("mu", float(fluid["mu"].si), "Pa*s")

Material Database (anvil.materials)¶

Structural material properties. Data from standard references (ASM, MIL-HDBK-5).

materials.get(name)¶

from anvil.db import materials

al = materials.get("Al-6061-T6")
print(al["E"])         # Q(68.9e9, "Pa"), Young's modulus
print(al["sigma_y"])   # Q(276e6, "Pa") , Yield strength
print(al["sigma_u"])   # Q(310e6, "Pa") , Ultimate tensile strength
print(al["rho"])       # Q(2700, "kg/m^3"), Density
print(al["nu"])        # 0.33           , Poisson's ratio
print(al["alpha"])     # Q(23.6e-6, "1/K"), Thermal expansion
print(al["k"])         # Q(167, "W/m/K"), Thermal conductivity
print(al["cp"])        # Q(896, "J/kg/K"), Specific heat

Available materials¶

Name Category
"Al-6061-T6" Aluminum alloy
"Al-7075-T6" Aluminum alloy (high strength)
"steel-1020" Mild steel
"steel-4340" Alloy steel (high strength)
"Ti-6Al-4V" Titanium alloy
"Inconel-718" Nickel superalloy
"CFRP-unidirectional" Carbon fiber composite
"Kevlar-49" Aramid fiber

materials.search(keyword)¶

materials.search("steel")
materials.search("titanium")

Usage with structures RSQs¶

mat = materials.get("Al-6061-T6")
E = float(mat["E"].si)       # 68.9e9 Pa
I = 1e-6                      # m^4

r = anvil.R.beam_deflection_cantilever(F_tip=5000, L_beam=1.5, E=E, I_moment=I)
print(r["deflection"])       # m

r = anvil.R.buckling_euler(E=E, I_moment=I, L_eff=1.5)
print(r["P_critical"])       # N

anvil.lookup(), In-REPL Help¶

A quick-reference search across the full registry plus constants and fluid/material databases:

anvil.lookup("pressure")
# Returns and prints all RSQs tagged or named with "pressure",
# plus relevant constants and fluid properties

This is the same as anvil.search() but with a more complete output format.


Database Accuracy Notes¶

Fluid properties: All models are polynomial/power-law fits valid over the stated temperature range. Accuracy: - Density (ideal gas): exact for ideal gases; ±2% for real gases away from saturation - Viscosity (Sutherland): ±2% for most gases in stated range - Conductivity (power law): ±5% for moderate temperatures

For high-accuracy fluid properties (near critical point, saturation, mixtures): use the CoolProp adapter.

Material properties: Single-temperature values from standard references. Temperature-dependent properties are not available, for thermal analysis requiring T-dependent E or σ_y, use external data.

Limits, Gotchas, and Known Issues¶

Comprehensive list of what Anvil cannot do, where it behaves unexpectedly, and how to work around issues.


1. System & Solver Limitations¶

DOF analysis, partial ✓ fixed¶

validate() now emits warnings for two common DOF mistakes:

  1. Declared variable also produced by relation, silent overwrite (intentional for iterative initial guesses, but likely a naming bug in forward systems):
sys.add("T0_T", 999.0)       # declared
sys.use("isentropic_ratios")  # also produces T0_T
sys.validate()
# WARNING: variable(s) declared via .add() are also produced by a relation
#   declared value will be overwritten after solve: ['T0_T']
  1. Declared variable used by no relation, orphan / typo:
sys.add("unused_var", 42.0)
sys.validate()
# WARNING: variable(s) declared via .add() are not used by any relation: ['unused_var']

Warnings fire once per system instance; suppressed on sweeps/re-solves.

Still NOT checked: Whether n_equations == n_unknowns in the general case. An underdetermined system where nothing needs the missing variable still produces no error.

Gauss-Seidel divergence¶

Problem: Systems with strong coupling (|spectral radius| > 1) diverge regardless of relaxation.

# This ALWAYS diverges, spectral radius >> 1
def r1(x): return {"y": 10*x + 1}
def r2(y): return {"x": 10*y + 1}
sys.use(r1); sys.use(r2)
sys.solve_gauss_seidel(max_iter=20)
# RuntimeError: Not converged after 20 iters (residual: 9.90e+01)

Workaround: Use solve_newton() for strongly coupled systems. Newton converges even when Gauss-Seidel diverges.

Gauss-Seidel convergence rate¶

For linear systems, Gauss-Seidel convergence rate is linear (geometric). With relaxation=ω < 1, each iteration reduces error by factor ≈ ω × spectral_radius. Heat exchanger example needs 33 iterations; aerodynamic systems may need 100+.

Workaround for slow convergence: 1. Try relaxation=0.5, more conservative but stable 2. Try solve_newton(), quadratic convergence 3. Improve initial guess with sys.set() before solving

solve_newton with poor initial guess¶

Newton's method can diverge if the initial guess is far from the solution:

# Newton on a system with multiple solutions, may find wrong one
sys.set(x=0.0)   # near a saddle point
sys.solve_newton()  # may fail or find wrong root

Workaround: Use solve_gauss_seidel() first to get close, then refine with solve_newton().

Warm-starting between sweep points, ✓ fixed¶

sweep() now accepts warm_start=True. Each successful solve's outputs are carried forward as initial guesses for the next point:

sweep = sys.sweep("UA", np.linspace(500, 5000, 30),
                  method="gauss_seidel", relaxation=0.7,
                  warm_start=True, skip_errors=True)

Reduces iteration count significantly for slowly varying parameters. Incompatible with parallel > 1 (raises ValueError).


2. Quantity Gotchas¶

Celsius and Fahrenheit, offset arithmetic ✓ supported (v1.3+)¶

degC, °C, degF, °F are now registered as offset units with full conversion:

Q(25, "degC").si          # 298.15 K, correct
Q(25, "degC").to("K")     # 298.15 K
Q(25, "degC").to("degF")  # 77.00 degF
Q(32, "degF").to("K")     # 273.15 K

Gotcha, arithmetic between offset temperatures:

Q(100, "degC") + Q(100, "degC")
# SI values: 373.15 + 373.15 = 746.30 K
# → displayed as 746.30 K (not 200°C)

Addition/subtraction operates on SI (Kelvin) values. The result has no _unit_hint, so it displays in K. This is the correct behaviour for absolute temperature arithmetic, adding two absolute temperatures is physically meaningful only as K. For temperature differences, the result is correct. If you need to display the result in °C:

result = Q(100, "degC") + Q(0, "degC")  # purely as K arithmetic
print(result.to("degC"))  # 100.00 degC, force display unit

Gotcha, "C" or "F" still create custom dimensions:

Q(25, "C")   # custom dim [C], NOT Celsius
Q(77, "F")   # custom dim [F], NOT Fahrenheit

Use "degC" / "°C" / "degF" / "°F" explicitly.

Adding scalar to dimensional Q¶

Q(10, "N") + 5    # ValueError, correct behavior
Q(1.4) + 0.1      # OK, dimensionless

The error is correct physics. But it can surprise users who expect implicit dimensionless addition.

Q ** non-dimensionless Q¶

Q(3, "m") ** Q(2, "N")
# ValueError: Exponent must be dimensionless.

Even though the numerical result of 3**2=9 is fine, Anvil refuses because the dimension of N makes no physical sense as an exponent.

Cross-dimension comparison, ✓ fixed¶

<, <=, >, >= between incompatible-dimension Q objects now raise TypeError:

Q(10, "N") < Q(5, "K")
# TypeError: Cannot compare [L][M][T-2] < [Θ]: incompatible dimensions.
#            Convert to the same unit first.

Previously this returned NotImplemented, which Python treated as True in boolean contexts, a silent logic bug. __le__ and __ge__ operators also added. == still returns False (not an error) for dimension mismatches.

Q(None) in arithmetic¶

Q(None, "Pa") + Q(100, "Pa")
# TypeError: unsupported operand type(s) for +: 'NoneType' and 'float'

Undefined quantities should never participate in arithmetic. They're only valid as System workspace placeholders.

Display encoding on Windows¶

On Windows consoles (cp1252 encoding), printing Dim objects containing Θ (temperature symbol) raises UnicodeEncodeError:

print(Q(100, "N") / Q(5, "K"))  # contains [L][M][T-2][Θ-1]
# UnicodeEncodeError: 'charmap' codec can't encode character 'Θ'

Workaround: Run Python with -X utf8 flag, or set PYTHONIOENCODING=utf-8 in environment, or use repr() only in contexts you control.


3. Relation Gotchas¶

Functions that don't return a dict, ✓ fixed¶

def bad(x):
    return x * 2   # not a dict

sys.use(bad)
sys.solve_forward()
# RuntimeError: Relation 'bad' returned 'Quantity' instead of a dict.
#   Relations must return a dict mapping output names to values.
#   Example: return {"result": Q(F, "N")}

Previously this silently produced no outputs. Now raises RuntimeError with a clear message. Single-scalar return with exactly one known output still works as a narrow convenience.

Dynamic dict construction¶

def fn(x, mode):
    d = {}
    if mode == 1:
        d["y"] = x * 2
    else:
        d["z"] = x * 3
    return d   # keys depend on runtime value

sys.use(fn)

AST pass can't detect the keys (not in a literal return {"key": ...}). Runtime probe with dummy values (mode=1.0) would succeed but might miss the alternate keys. Anvil discovers y but not z (or vice versa).

Workaround: Use explicit return or always return all keys with None:

def fn(x, mode):
    return {"y": x*2 if mode == 1 else None,
            "z": x*3 if mode != 1 else None}

Quantity as dict key¶

def bad_rel(x):
    return {Q(1, "Pa"): x * 2}   # Q is not hashable
# TypeError: cannot use Quantity as dict key

Always use string keys.

NumPy ufuncs break unit propagation¶

import numpy as np

def fn(T):
    return {"a": np.sqrt(T)}   # np.sqrt(Q) fails → fallback to float → no unit

sys.add("T", 300, "K")
sys.use(fn)
r = sys.solve_forward()
r["a"]   # dimensionless float, no K^0.5 unit

Workaround: Use Python math or wrap explicitly:

def fn(T):
    T_val = float(getattr(T, "si", T))
    return {"a": Q(T_val ** 0.5, "K^0.5")}

4. Registry Gotchas¶

Duplicate push warning¶

anvil.push(my_func, name="my_rsq")
anvil.push(my_func, name="my_rsq")   # UserWarning: RSQ 'my_rsq' already exists

Use anvil.update() for intentional overwrites.

Seed only runs once (or when builtins missing)¶

After initial seeding, new built-in RSQs added to seed.py only appear after: 1. from anvil.seed import seed; seed(force=True), force re-seed 2. Or: new RSQ name added to _SEED_ENTRIES, then next import checks if all builtins present

If you add an RSQ to seed.py and it doesn't appear in anvil.R.*, run seed(force=True).

Registry DB at ~/.anvil/registry.db¶

The global registry is per-user, not per-project. All Anvil sessions on the same machine share the same ~/.anvil/registry.db. Local RSQs pushed in one script are visible in all other scripts.

Implication: anvil.push() in a script permanently modifies the shared DB. Use project registries (anvil.project()) for development RSQs.

anvil.R.<name> vs string lookup, ✓ fixed¶

anvil.push() and anvil.update() now rebuild anvil.R.* / anvil.S.* / anvil.QDB.* automatically. The new RSQ is accessible immediately:

import anvil
anvil.push(my_func, name="new_rsq")
anvil.R.new_rsq(x=1.0)   # works, no restart needed

Manual rebuild still available if needed (e.g. after direct DB manipulation):

anvil.registry._rebuild_namespaces()

5. Solver-Specific Limits¶

find_root, no real root in bracket¶

solvers.find_root(lambda x: x**2 + 1, bracket=(0, 10))
# ValueError: f(a) and f(b) must have different signs

Brent's method requires a sign change. If your function is always positive (or always negative) in the bracket, it cannot find a root.

solve_ode, success flag is unreliable for stiff problems¶

ODE with singularity may report success=True:

r = solvers.solve_ode(lambda t, y: [1.0/(y[0]-0.5)], (0, 0.4), [0.0])
r["success"]   # True, but the solution is garbage

Workaround: Check r["message"] and sanity-check the solution values. For stiff problems, use solve_ode_stiff().

solve_pde_heat_1d, 1D uniform Cartesian only¶

Limitations: - 1D only (no 2D/3D) - Uniform grid only (constant dx) - Constant diffusivity only (no α(x) or α(T)) - Dirichlet or zero-flux Neumann BCs only (no Robin, mixed, time-derivative BCs) - Scalar PDE only (no vector equations, no coupled PDEs)

For any of these, use a CLI adapter wrapping OpenFOAM/FEniCS/FiPy.

minimize, local minima only¶

All methods in solvers.minimize() are gradient-based (or simplex-based) local optimizers. They find the nearest local minimum to x0.

# Rosenbrock has one global minimum at [1, 1]
r = solvers.minimize(rosen, [0.0, 0.0])   # converges to [1, 1]

# Multi-modal function, result depends on x0
def f(x): return np.sin(x[0]) + 0.1*x[0]**2
r = solvers.minimize(f, [0.0])   # local min near x=0
r = solvers.minimize(f, [-3.0])  # different local min

Workaround: Use multi-start (run minimize from multiple x0 values), or use scipy.optimize.differential_evolution directly for global optimization.


6. Adapter Limitations¶

No "http" or "shared_lib" backend¶

Only "python" and "cli" backends are implemented. HTTP endpoints and shared libraries require custom wrapping.

CLI adapter timeout kills the whole process tree¶

subprocess.run(..., timeout=N) raises TimeoutExpired and terminates the process, but may not clean up child processes. On Windows this can leave orphaned processes.

Workaround: Implement cleanup in the setup function's try/finally block.

Thread safety in parallel sweeps¶

Python backend adapters are called from ThreadPoolExecutor threads in parallel sweeps. If the adapter writes to a shared file or modifies global state, race conditions can occur.

Workaround: Use cwd pointing to separate per-call directories, or use parallel=1.

No state between adapter calls¶

Each adapter call is stateless. If your external tool requires initialization (connecting to a service, loading large data) per call, it's called every time.

Workaround: Implement caching inside the wrapper function using functools.lru_cache or module-level state.


7. Accuracy and Numerical Precision¶

Internal precision: float64¶

All workspace values are np.float64. Loss of precision occurs for: - Very large values combined with very small (cancellation): Q(1e20, "Pa") - Q(1e20 - 1, "Pa") → loss of significant digits - Deeply chained exponentiation: Q(x, "m") ** 0.5 ** 0.5, compounding roundoff

Unit scaling and precision¶

Q(1, "psi").to("Pa").value
# 6894.757293168  (exact database value)

Q(1, "psi").si
# 6894.757293168  (preserved, no intermediate rounding)

Conversion chains can accumulate floating-point errors:

Q(1, "ft").to("cm").to("mm").to("m").si
# ~0.3048000000000001 (small roundoff vs 0.3048 exact)

Solver tolerances vs physical accuracy¶

Solver Default tolerance Practical accuracy
find_root (brent) 1e-12 ~12 significant digits
solve_nonlinear 1e-10 ~10 significant digits
solve_ode rtol=1e-8, atol=1e-10 6-8 significant digits
solve_ode_stiff rtol=1e-6, atol=1e-10 4-6 significant digits
solve_bvp tol=1e-3 2-3 significant digits (coarse mesh)
solve_pde_heat_1d dx²/12 spatial, dt²/12 temporal 0.05% with default settings
minimize 1e-8 8 significant digits

PDE accuracy¶

Crank-Nicolson is 2nd order in both space and time. With nx=50, nx=100, the spatial error is O(dx²):

nx=50:   dx=0.02   spatial error ~ 4e-4
nx=100:  dx=0.01   spatial error ~ 1e-4  (4× improvement = 2× in dx)
nx=200:  dx=0.005  spatial error ~ 2.5e-5

8. Performance Limits¶

Operation Typical time Hard limit
Single forward pass, 5 relations <1 ms ,
gauss_seidel, 50 iterations ~5 ms ,
find_root (brent) ~10 µs ,
sweep(50 points), simple system ~50 ms Memory scales with n_points
sweep(50 points, parallel=4) ~15 ms 4 concurrent solve copies
sensitivity(5 inputs) ~10× single solve ,
Registry SQLite lookup ~1 ms ,
Project promote_all(1000 RSQs) ~1 s ,

Memory¶

Each sweep point stores a full Result object with all workspace variables. For large systems (100+ variables) × 500 sweep points, this can reach 50-100 MB. Use sweep.to_dict() to extract arrays and release.


9. Known Issues¶

Issue Status Workaround
Cross-dim </<=/>/>= silent boolean bug ✓ Fixed Now raises TypeError
Non-dict relation return silent no-op ✓ Fixed Now raises RuntimeError
anvil.R.* stale after push() ✓ Fixed Namespace rebuilt automatically
DOF: declared var overwritten with no warning ✓ Fixed validate() warns once per system
No warm-start between sweep points ✓ Fixed sweep(warm_start=True)
Celsius/Fahrenheit not supported ✓ Fixed (v1.3) degC, °C, degF, °F with full offset arithmetic
Seed not updating changed built-in RSQs ✓ Fixed (v1.3) Source strings compared; changed RSQs re-seed automatically
Beam RSQ max_moment wrong unit ("N" instead of "N*m") ✓ Fixed (v1.3) Corrected in seed.py
deg unit not working as angle input to RSQs ✓ Fixed (v1.3) _rad() helper in loader handles both Q(deg) and plain float
viz.dependency_graph() overlaps nodes for large systems Open Scale figure: fig.set_size_inches(20, 15)
block relation returns all workspace keys, not just declared outputs Open Access only the keys you care about
_qty_compatible cache resets on copy() Open Negligible, first call recaches
ResourceWarning: unclosed database on CPython 3.14+ Open Harmless for scripts; store.close() in servers
Windows cp1252 encoding breaks Dim repr with Θ symbol Open Use -X utf8 flag or PYTHONIOENCODING=utf-8
anvil.project() prints "Project opened" in silent scripts Open Redirect stdout if needed
as_relation() closure re-validates on every call Open Avoid composition inside tight loops
solve_ode success=True for singular/ill-conditioned problems Open Check r["message"]; validate results physically
degC/degF arithmetic result displays in K, not °C By design Use .to("degC") on result if display unit matters
"C" or "F" (without prefix) create custom dimensions, not Celsius/Fahrenheit By design Always use "degC" / "°C" / "degF" / "°F"

Advanced Topics¶


System Composition¶

Systems can be nested into larger systems via .as_relation(). This is Anvil's primary abstraction mechanism for building complex multi-physics tools.

Pattern: subsystem as relation¶

# 1. Build and test subsystem independently
nozzle = anvil.system("rocket_nozzle")
nozzle.add("P0", 6.9e6, "Pa")
nozzle.add("T0", 3500, "K")
# ... add relations ...
nozzle.solve_forward()   # verify it works

# 2. Wrap as relation
nozzle_rel = nozzle.as_relation(
    inputs=["P0", "T0", "gamma", "R_gas", "A_throat", "A_exit", "P_amb"],
    outputs=["thrust", "Isp", "mdot", "V_exit"],
)

# 3. Use in larger system
rocket = anvil.system("two_stage")
rocket.add("P0_1", 10e6, "Pa")
rocket.add("T0_1", 3500, "K")
# ... stage 1 nozzle inputs ...
rocket.use(nozzle_rel, map={"P0": "P0_1", "T0": "T0_1"})

# ... add Tsiolkovsky, staging relations, etc. ...
rocket.solve_forward()

How .as_relation() works¶

Creates a closure _fn(**kwargs) that: 1. Deep-copies the system 2. For each kwarg, overwrites the corresponding quantity's _si_value 3. Calls sys.copy().solve() 4. Extracts the declared outputs from the result 5. Returns them as a dict

This means every call to the composed relation runs a full solve of the subsystem. For iterative parent systems, the subsystem is solved at every Gauss-Seidel iteration.

Performance impact: For a parent system with 50 Gauss-Seidel iterations and a subsystem that takes 5 ms to solve, composition adds 250 ms per parent solve.

Pre-built system composition¶

anvil.S.rocket_nozzle is already a System. Use it in composition directly:

nozzle = anvil.S.rocket_nozzle
rel = nozzle.as_relation(outputs=["thrust", "Isp"])

parent = anvil.system("mission")
parent.use(rel)

Cyclic Systems, When to Use Each Solver¶

A system has cycles when relation A's output is relation B's input AND relation B's output is relation A's input (directly or transitively).

sys.validate()
sys._has_cycles      # True or False
sys._exec_order      # list of relation indices in topo order

# Coupled variables (appear in both all_inputs and all_outputs)
all_out = set().union(*[r._outputs for r in sys._relations])
all_in  = set().union(*[r._inputs  for r in sys._relations])
coupled = all_out & all_in
Solver Use when
solve_forward() No cycles, validated by _has_cycles=False
solve_gauss_seidel() Cycles, weakly coupled (convergence factor < 1)
solve_newton() Cycles, strongly coupled or slow GS convergence

Building cyclic systems manually¶

# Counter-flow heat exchanger, Q_dot depends on T_hot_out, which depends on Q_dot
sys = anvil.system("hx")
sys.add("T_hot_in", 600, "K")
sys.add("T_cold_in", 290, "K")
sys.add("T_hot_out", 400, "K")   # initial guess for iteration
sys.add("T_cold_out",350, "K")   # initial guess
sys.add("Q_dot", 100000, "W")   # initial guess
sys.add("UA", 2000, "W")

def energy_hot(T_hot_in, T_hot_out, Cp_hot, mdot_hot):
    return {"Q_dot": Cp_hot * mdot_hot * (T_hot_in - T_hot_out)}

def energy_cold(T_cold_in, Q_dot, Cp_cold, mdot_cold):
    return {"T_cold_out": T_cold_in + Q_dot / (Cp_cold * mdot_cold)}

def lmtd(T_hot_in, T_hot_out, T_cold_in, T_cold_out, UA, Cp_hot, mdot_hot):
    lmtd_val = ((T_hot_in - T_cold_out) - (T_hot_out - T_cold_in)) / \
                np.log((T_hot_in - T_cold_out) / (T_hot_out - T_cold_in))
    Q = UA * lmtd_val
    return {"T_hot_out": T_hot_in - Q / (Cp_hot * mdot_hot)}

sys.use(energy_hot)
sys.use(energy_cold)
sys.use(lmtd)
# Cycles: Q_dot → T_cold_out → energy_hot → Q_dot
#         T_hot_out → lmtd → T_hot_out (self-referential!)

result = sys.solve_gauss_seidel(relaxation=0.5, monitor=True)

Initial guesses matter: GS starts with declared values. Good guesses near the solution converge faster.


Block Relations¶

Relation.block() chains multiple functions into a single relation that shares an intermediate workspace.

Use cases¶

  1. Breaking up complex physics, separate concerns into testable steps:
def compute_isentropic(M, gamma=1.4):
    T_ratio = 1 + (gamma-1)/2 * M**2
    P_ratio = T_ratio ** (gamma/(gamma-1))
    return {"T0_T": T_ratio, "P0_P": P_ratio}

def compute_exit(T0, P0, T0_T, P0_P, gamma, R_gas):
    T_e = T0 / T0_T; P_e = P0 / P0_P
    a_e = (gamma * R_gas * T_e) ** 0.5
    return {"T_exit": Q(T_e, "K"), "P_exit": Q(P_e, "Pa"), "a_exit": Q(a_e, "m/s")}

def compute_thrust(mdot, V_exit, P_exit, P_amb, A_exit):
    F = mdot * V_exit + (P_exit - P_amb) * A_exit
    return {"thrust": Q(F, "N")}

nozzle_block = Relation.block("nozzle_physics",
    steps=[compute_isentropic, compute_exit, compute_thrust])
  1. Avoiding intermediate workspace pollution, block outputs are scoped:

If step 1 outputs T0_T and step 2 also outputs T0_T (corrected), only step 2's value appears in the parent workspace.

Block relation input detection¶

Relation.block() computes: - all_inputs: parameters of all steps NOT produced by any earlier step - all_outputs: keys returned by any step

nozzle_block._inputs
# ['A_exit', 'A_throat', 'P0', 'T0', 'gamma', 'R_gas', 'P_amb', 'mdot']
# (initial inputs to the chain, not produced by any step)

nozzle_block._outputs
# ['T0_T', 'P0_P', 'T_exit', 'P_exit', 'a_exit', 'thrust']
# (all keys produced by any step)

Gotcha: The block always returns ALL workspace keys (including inputs passed through). Filter on the caller side:

r = nozzle_block(M=3.0, T0=3500, P0=10e6, ...)
r["thrust"]   # the output you want
r["M"]        # also present, the input passed through

anvil.relation Decorator Details¶

The @anvil.relation decorator wraps the function AND (by default) registers it in the global registry.

@anvil.relation(domain="aero", tags=["compressible"], register=True)
def my_rsq(M, gamma=1.4):
    return {"T_ratio": 1 + (gamma-1)/2*M**2}

# my_rsq is now a Relation object (not the original function)
my_rsq(M=2.0)           # direct call, returns dict
my_rsq._inputs          # ['M', 'gamma']
my_rsq.name             # "my_rsq"
anvil.R.my_rsq(M=2.0)  # also works via registry

Without parentheses:

@anvil.relation
def simple_fn(x, y):
    return {"z": x + y}
# Registered with name "simple_fn", no domain, no tags

register=False, wrap without pushing to registry:

@anvil.relation(domain="draft", register=False)
def draft(x): return {"y": x*2}
# draft is a Relation but NOT in anvil.R.*

Important: If registration fails (e.g., DB error), the relation still works as a callable, push() failure is non-fatal by design.


Watchdog¶

anvil.Watchdog is an internal convergence monitoring utility. It tracks residuals per iteration and can trigger callbacks.

from anvil.watchdog import Watchdog

wd = Watchdog(rtol=1e-6, max_iter=200)
for i in range(max_iter):
    prev = current.copy()
    # ... update ...
    converged, residual = wd.check(prev, current)
    if converged:
        break

This is used internally by the monitor mode of solve_gauss_seidel. Direct use is uncommon unless you're building custom iterative algorithms.


CFD Module¶

anvil.cfd is a 2D finite-volume Euler solver for structured body-fitted meshes. It handles subsonic through supersonic flows with Roe/HLLC flux schemes, MUSCL reconstruction, and multiple BC types.

See CFD Solver for the full reference: mesh factories, boundary conditions, solver parameters, post-processing, VTK/Tecplot export, and System integration via solver.as_relation().

from anvil.cfd import CFDSolver, Mesh
from anvil.cfd.bc import SupersonicInlet, SlipWall, SupersonicOutlet, Farfield

mesh   = Mesh.wedge(half_angle_deg=10, chord=1.0, height=0.8, nx=80, ny=40)
bcs    = {"west": SupersonicInlet(M=2.0, p=101325, T=300),
          "east": SupersonicOutlet(), "south": SlipWall(),
          "north": Farfield(M=2.0, p=101325, T=300)}
solver = CFDSolver(mesh, bcs, flux_scheme="roe", order=2, cfl=0.5)
solver.initialize(M=2.0, p=101325, T=300)
result = solver.run(max_iter=5000, tol=1e-6, monitor=True)
result.to_vtk("wedge.vtk")

# Integrate into an Anvil System
rel = solver.as_relation(inputs=["M_inf","p_inf","T_inf"], outputs=["CL","CD"])

anvil.help_.lookup(), In-REPL Reference¶

anvil.lookup("pressure")
# Searches registry + constants + fluids + materials for "pressure"
# Prints matching RSQs, constants, fluid properties

A convenience function for interactive use, equivalent to anvil.search() with more complete output.


Custom Unit Registration¶

The UnitDB is extensible. Register new units at module level before any Q creation:

from anvil.units import db, Dim

# Register a new unit
db.register("furlong", 201.168, Dim(L=1))     # 1 furlong = 201.168 m
db.register("fortnight", 1209600.0, Dim(T=1)) # 1 fortnight = 2 weeks in seconds

Q(3, "furlong")                    # Q(3, "furlong"), si = 603.504 m
Q(1, "furlong/fortnight")          # Q(1, "furlong/fortnight"), velocity
Q(1, "furlong/fortnight").to("m/s")  # conversion

Set as preferred display unit:

from anvil.units import Dim
db.set_preferred(Dim(L=1), "furlong", "furlong")  # set as preferred SI AND Imperial

Caution: Custom units added at runtime are not persisted, re-register each session.


Building a Multi-Stage Analysis Pipeline¶

Pattern for complex workflows:

import anvil
from anvil import Q
import numpy as np

# Stage 1: Combustion chamber equilibrium (Cantera adapter or simple model)
@anvil.relation(domain="propulsion.combustion")
def combustion(OF, Pc_bar, fuel="H2", oxidizer="O2"):
    Pc = Pc_bar * 1e5
    Tc = 3400.0 * (1 - 0.02*abs(OF-6)) - 0.001*(Pc-10e6)
    gamma = 1.2 + 0.01*(OF-6)
    R_gas = 8.314 / 0.007   # H2/O2 approx
    return {"Tc": Q(Tc, "K"), "gamma_c": gamma, "R_gas_c": Q(R_gas, "J/kg/K")}

# Stage 2: Nozzle (pre-built system)
nozzle = anvil.S.rocket_nozzle.copy()
nozzle_rel = nozzle.as_relation(
    inputs=["P0", "T0", "gamma", "R_gas", "A_throat", "A_exit", "P_amb"],
    outputs=["thrust", "Isp", "mdot"],
)

# Stage 3: Vehicle performance
@anvil.relation(domain="propulsion.trajectory")
def delta_v_calc(Isp, mass_wet, mass_dry):
    g0 = 9.80665
    return {"delta_v": Q(g0 * Isp * np.log(mass_wet / mass_dry), "m/s")}

# Wire together
pipeline = anvil.system("engine_sizing")
pipeline.add("OF",      6.0)
pipeline.add("Pc_bar",  100.0)
pipeline.add("A_throat", 0.01, "m^2")
pipeline.add("A_exit",  0.1,  "m^2")
pipeline.add("P_amb",   0.0,  "Pa")  # vacuum
pipeline.add("mass_wet", 100000, "kg")
pipeline.add("mass_dry", 20000, "kg")

pipeline.use(combustion)
pipeline.use(nozzle_rel, map={
    "P0": "Pc",      # combustion outputs Pc
    "T0": "Tc",
    "gamma": "gamma_c",
    "R_gas": "R_gas_c",
})
pipeline.use(delta_v_calc)

result = pipeline.solve_forward()
print(f"Thrust: {result['thrust'].to('kN')}")
print(f"Isp: {result['Isp']}")
print(f"ΔV: {result['delta_v'].to('km/s')}")

# Sweep: find optimal OF ratio
sweep = pipeline.sweep("OF", np.linspace(4, 8, 20))
sweep.summary(outputs=["thrust", "Isp", "delta_v"])

Registry Loader, How RSQs Are Reconstituted¶

anvil.registry.loader.load_rsq(record, store) takes a registry record (dict from SQLite) and returns a live Python object.

For Relations (type "R"): 1. Source code string retrieved from record["source"] 2. exec() in a namespace with anvil, Q, solvers, np pre-imported 3. Looks for export variable in the namespace, this is the callable 4. Wraps in Relation(export)

# seed.py stores source as:
'def isentropic_ratios(M, gamma=1.4):\n    ...\nexport = isentropic_ratios'

# loader does:
namespace = {"anvil": anvil, "Q": Q, "np": np, "solvers": solvers}
exec(source, namespace)
func = namespace["export"]
rel = Relation(func)

For Systems (type "S"): Source is a serialized anvil.system(...) construction script. Executed similarly.

For Quantities (type "Q"): Source is export = Q(value, "unit"). Executed to get the Q object.

Security note: Registry sources are executed with exec(). The global registry (~/.anvil/registry.db) can contain arbitrary code. Do not open unknown registry files or accept untrusted registry records.

Decomposition: POD and DMD¶

anvil.decomp provides data-driven decomposition methods for engineering signals and snapshot matrices. No additional dependencies beyond NumPy.

import anvil.decomp as decomp
# or
from anvil import decomp

Overview¶

Method Full name What it gives you
POD Proper Orthogonal Decomposition Orthogonal spatial modes ranked by energy (variance)
DMD Dynamic Mode Decomposition Spatial modes + complex eigenvalues (frequency + growth rate)

Both operate on snapshot matrices X of shape (n_space, n_time). For 1D signals, use hankel() to create the matrix first.


Quick Start¶

import numpy as np
import anvil.decomp as decomp

# 1D signal with two frequencies
dt = 0.005
t  = np.arange(0, 8, dt)
x  = np.sin(2*np.pi*3*t) + 0.4*np.sin(2*np.pi*11*t)

# 1. Embed into Hankel matrix
H = decomp.hankel(x, window=len(t)//4)    # (400, 1201)

# 2. POD, energy decomposition
pod = decomp.pod(H, r=10)
print(decomp.pod_rank(pod, 0.99))          # modes needed for 99% energy

# 3. DMD, frequency identification
dmd = decomp.dmd(H, dt=dt, r=10)
idx = decomp.dmd_dominant(dmd, n=4)
print(dmd["frequencies"][idx])             # → ±3 Hz, ±11 Hz

# 4. Reconstruct
X_hat = decomp.dmd_reconstruct(dmd, n_steps=H.shape[1])

hankel, 1D Signal Embedding¶

H = decomp.hankel(x, window)

Embeds a scalar time series into a Hankel (trajectory) matrix by sliding a window along the signal. Each column of H is one window-length snapshot.

Parameter Type Description
x array, shape (N,) 1D signal
window int Embedding dimension (number of rows)

Returns: ndarray, shape (window, N - window + 1)

Window size rule of thumb: N // 4 to N // 3. Larger window → more spatial resolution in POD/DMD modes, fewer time columns. Too large → too few columns for meaningful SVD.

t = np.linspace(0, 10, 2000)
x = np.sin(2*np.pi*5*t)

H = decomp.hankel(x, window=400)   # shape (400, 1601)
# H[:, k] = x[k : k+400]        , window starting at sample k

Why Hankel? POD and DMD are defined for multi-dimensional snapshot data. The Hankel embedding converts a 1D time series into a pseudo-spatial problem, each row corresponds to a "lag", enabling the methods to identify frequency content and reconstruct the signal.


pod, Proper Orthogonal Decomposition¶

result = decomp.pod(X, r=None, subtract_mean=True)

Decomposes X = U S Vt (economy SVD) where columns of U are orthonormal spatial modes ranked by their singular value (energy content).

Parameter Default Description
X required Snapshot matrix, shape (n_space, n_time)
r None Modes to retain; None = all non-zero
subtract_mean True Remove temporal mean before decomposition

Returns dict:

Key Shape Description
"modes" (n_space, r) Orthonormal spatial modes
"singular_values" (r,) Ranked descending
"temporal_coefficients" (r, n_time) Time evolution of each mode
"energy_fractions" (r,) Fraction of total variance per mode
"cumulative_energy" (r,) cumsum(energy_fractions)
"mean" (n_space,) Subtracted mean (zeros if subtract_mean=False)
"rank" int Actual number of modes retained

SciPy backend: numpy.linalg.svd (economy, full_matrices=False)

POD energy interpretation¶

pod = decomp.pod(X, r=20)

# How many modes for 99% energy?
r99 = decomp.pod_rank(pod, 0.99)

# Fraction captured by first 5 modes
print(pod["cumulative_energy"][4])     # index 4 = mode 5

# Is the data low-rank? Steep drop in singular values → yes
print(pod["singular_values"])

Energy fraction measures what fraction of the total variance (Frobenius norm squared) is captured by each mode. A fast decay indicates a low-dimensional structure in the data.

Example¶

# 64-sensor vibration measurement, 500 snapshots
X = load_sensor_data()    # shape (64, 500)

pod = decomp.pod(X, r=8)

print(f"Top mode: {pod['energy_fractions'][0]*100:.1f}% of variance")
print(f"Modes for 99%: {decomp.pod_rank(pod, 0.99)}")

# Mode shapes (spatial patterns)
mode1_shape = pod["modes"][:, 0]   # first bending mode

# Temporal coefficients (how each mode evolves in time)
mode1_time = pod["temporal_coefficients"][0, :]

pod_reconstruct, Reconstruct from POD Modes¶

X_hat = decomp.pod_reconstruct(pod_result, r=None)

Rebuilds the snapshot matrix from r POD modes. Mean is added back automatically.

Parameter Description
pod_result Dict from pod()
r Use first r modes; None = all retained

Returns: ndarray (n_space, n_time), U[:,:r] @ TC[:r,:] + mean

pod = decomp.pod(X)

# Reconstruction with increasing rank
for r in [2, 5, 10]:
    X_hat = decomp.pod_reconstruct(pod, r=r)
    err = np.linalg.norm(X - X_hat) / np.linalg.norm(X)
    print(f"r={r}: error = {err:.3f}  (cumE = {pod['cumulative_energy'][r-1]:.3f})")

pod_project, Project onto Existing Basis¶

coefficients = decomp.pod_project(pod_result, X_new, subtract_mean=True)

Projects new snapshot data onto a pre-computed POD basis. Useful for reduced-order evaluation of new data without recomputing the SVD.

Parameter Description
pod_result From pod()
X_new New snapshots, shape (n_space, n_time_new)
subtract_mean Subtract training mean (default True)

Returns: ndarray (r, n_time_new), modal amplitudes of X_new in the POD basis.

# Train POD on first half of data
pod_train = decomp.pod(X[:, :250], r=10)

# Project second half (new data) onto training modes
coeff_test = decomp.pod_project(pod_train, X[:, 250:])
# coeff_test.shape = (10, 250)

pod_rank, Minimum Modes for Target Energy¶

r = decomp.pod_rank(pod_result, target_energy=0.99)

Returns the minimum number of modes needed to capture target_energy fraction of total variance.

pod = decomp.pod(X)
print(decomp.pod_rank(pod, 0.90))   # 90%
print(decomp.pod_rank(pod, 0.99))   # 99%
print(decomp.pod_rank(pod, 0.999))  # 99.9%

dmd, Dynamic Mode Decomposition¶

result = decomp.dmd(X, dt=1.0, r=None, threshold=None)

Exact DMD (Tu et al. 2014). Fits a linear operator A such that x(t+dt) ≈ A x(t) and returns its eigendecomposition.

Parameter Default Description
X required Snapshot matrix, shape (n_space, n_time). Consecutive columns must be dt apart
dt 1.0 Time step between snapshots
r None SVD truncation rank for noise filtering
threshold None Keep singular values above threshold × s_max. Default 1e-10 when r is None

Returns dict:

Key Shape Description
"eigenvalues" (r,) complex Discrete-time: |λ|>1 growing, <1 decaying, =1 neutral
"omega" (r,) complex Continuous-time: log(λ)/dt
"modes" (n_space, r) complex Spatial DMD modes
"amplitudes" (r,) complex Initial amplitude of each mode
"frequencies" (r,) float Im(ω) / (2π), oscillation frequency in 1/dt_unit
"growth_rates" (r,) float Re(ω), positive = growing, negative = decaying
"singular_values" (r,) float SVD singular values used

Rank selection¶

  • r specified: use exactly r modes (set r small to filter noise)
  • threshold specified: keep modes where s_i > threshold × s_max
  • Neither: use threshold=1e-10 (keeps nearly all non-zero modes)

Rule of thumb for noisy data: Start with r=2×(expected modes), DMD modes come in conjugate pairs for real-valued signals.

Reading eigenvalues¶

dmd_r = decomp.dmd(H, dt=0.005, r=12)

# Discrete-time eigenvalues
evals = dmd_r["eigenvalues"]
# |λ| < 1 → decaying;  |λ| > 1 → growing;  |λ| ≈ 1 → neutral/oscillatory

# Continuous-time frequency (Hz if dt is in seconds)
freqs = dmd_r["frequencies"]       # Im(log(λ)/dt) / (2π)
grows = dmd_r["growth_rates"]      # Re(log(λ)/dt)

# Example output for a 3 Hz + 11 Hz signal:
# freqs ≈ [+3.0, -3.0, +11.0, -11.0, ...]
# grows ≈ [~0,  ~0,  ~0,   ~0,  ...]  (stable modes)

Conjugate pairs: Real-valued signals always produce complex-conjugate eigenvalue pairs. The positive-frequency mode is the physical one; the negative-frequency mode is its mirror image. Both have the same amplitude and growth rate.

Example: frequency identification¶

import numpy as np
import anvil.decomp as decomp

dt = 0.005
t  = np.arange(0, 8, dt)
x  = np.sin(2*np.pi*3*t) + 0.4*np.sin(2*np.pi*11*t)

H = decomp.hankel(x, window=400)
dmd_r = decomp.dmd(H, dt=dt, r=8)

# Get dominant modes
idx = decomp.dmd_dominant(dmd_r, n=4, by="amplitude")
for i in idx:
    print(f"  f = {dmd_r['frequencies'][i]:+.2f} Hz  "
          f"  growth = {dmd_r['growth_rates'][i]:+.3f}  "
          f"  |amp| = {abs(dmd_r['amplitudes'][i]):.3f}")
# Output:
#   f =  +3.00 Hz   growth =  +0.001   |amp| = ...
#   f =  -3.00 Hz   growth =  +0.001   |amp| = ...
#   f = +11.00 Hz   growth =  +0.001   |amp| = ...
#   f = -11.00 Hz   growth =  +0.001   |amp| = ...

dmd_reconstruct, Reconstruct from DMD Modes¶

X_hat = decomp.dmd_reconstruct(dmd_result, n_steps=None, t=None)

Reconstructs the snapshot matrix (or extrapolates into the future) using X_hat[:, k] = Re(Φ diag(b) λ^k).

Parameter Description
dmd_result Dict from dmd()
n_steps Number of time steps to generate
t Time array; n_steps = len(t) (overrides n_steps)

Returns: ndarray (n_space, n_steps), real part only.

dmd_r = decomp.dmd(H, dt=0.005, r=8)

# Reconstruct training window
X_train = decomp.dmd_reconstruct(dmd_r, n_steps=H.shape[1])

# Predict future (extrapolate)
n_future = H.shape[1] + 200
X_future = decomp.dmd_reconstruct(dmd_r, n_steps=n_future)

# Error on training data
err = np.linalg.norm(H - X_train) / np.linalg.norm(H)

Extrapolation validity: DMD extrapolates by raising eigenvalues to higher powers (λ^k). Stable modes (|λ|≤1) extrapolate reliably; growing modes (|λ|>1) will eventually diverge. Check growth_rates before extrapolating.


dmd_dominant, Rank Modes by Importance¶

indices = decomp.dmd_dominant(dmd_result, n=5, by="amplitude")

Returns indices of the n most dominant DMD modes.

by Ranking criterion
"amplitude" |b_i|, initial amplitude (default)
"energy" |b_i|², initial energy
"growth" Re(ω_i), most rapidly growing first
# Top 4 by amplitude
idx = decomp.dmd_dominant(dmd_r, n=4, by="amplitude")

# Top 3 growing modes (stability analysis)
growing = decomp.dmd_dominant(dmd_r, n=3, by="growth")
print(dmd_r["growth_rates"][growing])   # all positive

Visualization¶

from anvil import viz

# POD: singular value spectrum + cumulative energy
viz.pod_energy(pod_result, threshold=0.99)

# DMD: eigenvalue spectrum in complex plane
viz.dmd_spectrum(dmd_result, unit_circle=True)

viz.pod_energy(pod_result, ax=None, show=True, threshold=0.99)¶

Two-panel figure: - Left: Bar chart of singular values (log scale) - Right: Cumulative energy % with guide line at threshold and annotation of how many modes are needed

viz.dmd_spectrum(dmd_result, ax=None, show=True, unit_circle=True)¶

Complex eigenvalue plane scatter plot: - Marker size and color encode normalized amplitude - Unit circle drawn at |λ|=1, modes inside are stable, outside are growing - Colorbar shows normalized amplitude

# Save without displaying
fig = viz.pod_energy(pod, show=False)
fig.savefig("pod_energy.png", dpi=150, bbox_inches="tight")

fig2 = viz.dmd_spectrum(dmd_r, show=False)
fig2.savefig("dmd_spectrum.png", dpi=150, bbox_inches="tight")

Multi-Dimensional Data (CFD / Sensor Arrays)¶

For data already in snapshot-matrix form, skip hankel():

# CFD: pressure field at 128×128 grid, 300 snapshots
# Shape: (128*128, 300) = (16384, 300)
X_cfd = load_snapshots()   # (n_space, n_time)

pod_cfd = decomp.pod(X_cfd, r=20)
dmd_cfd = decomp.dmd(X_cfd, dt=time_step, r=20)

# Most energetic mode shape (reshape to spatial grid)
mode1 = pod_cfd["modes"][:, 0].reshape(128, 128)

# Dominant DMD frequency
idx = decomp.dmd_dominant(dmd_cfd, n=2)
print(dmd_cfd["frequencies"][idx])

Algorithm Details¶

POD¶

  1. Subtract temporal mean: Xc = X - mean(X, axis=1)
  2. Economy SVD: U, s, Vt = svd(Xc)
  3. Modes U[:,i] ranked by s_i² (energy)
  4. Temporal coefficients: diag(s) @ Vt

DMD (exact, Tu 2014)¶

  1. Split: X1 = X[:,:-1], X2 = X[:,1:]
  2. SVD of X1: U_r, s_r, Vt_r (truncated to rank r)
  3. Reduced operator: Ã = U_r† X2 Vt_r† diag(1/s_r), shape (r,r)
  4. Eigendecompose Ã: Ã W = W diag(λ)
  5. Exact modes: Φ = X2 Vt_r† diag(1/s_r) W diag(1/λ)
  6. Amplitudes b: least-squares fit Φ b ≈ x(0)
  7. Continuous eigenvalues: ω = log(λ)/dt

Limits and Gotchas¶

Issue Detail
Conjugate pairs Real signals always produce ±freq pairs. Both represent the same physical mode.
Rank choice Too high → noise modes appear. Too low → real modes merged. Start with 2× expected physical modes.
Window size For hankel(), window too small → poor frequency resolution. Too large → fewer columns → noisy SVD.
Growing DMD modes |λ|>1 extrapolation diverges. Only extrapolate stable or neutral modes.
POD vs DMD POD sorts by energy, DMD sorts by dynamics. A low-energy mode can be physically important (e.g., an unstable growing mode).
subtract_mean Default True for POD. Set False when passing Hankel matrix to DMD (DMD already handles the dynamics without mean subtraction).
Complex modes DMD modes are complex. np.real(mode) gives the oscillatory part; np.abs(mode) gives the amplitude envelope.

Abel Transform¶

anvil.decomp includes forward and inverse Abel transforms for cylindrically symmetric data: plasma spectroscopy, combustion emission, ion velocity-map imaging, and any system where a camera measures a line-of-sight integral of a 3D radially symmetric source.

import anvil.decomp as decomp
from anvil import viz

Background¶

A cylindrically symmetric 3D distribution f(r) projects onto a 2D camera image as:

F(y) = 2 * integral_y^inf  f(r) * r / sqrt(r^2 - y^2)  dr   (forward Abel)

The inverse (Abel inversion) recovers f(r) from the measured projection F(y):

f(r) = -(1/pi) * integral_r^inf  (dF/dy) / sqrt(y^2 - r^2)  dy

Inputs and outputs:

Symbol Meaning In Anvil
f(r) True radial distribution (3D) fr array, index 0 = center
F(y) Measured camera projection (2D) Fy array, index 0 = center
dr Pixel size (any length unit) dr parameter

Quick Start¶

import numpy as np
import anvil.decomp as decomp
from anvil import viz

# --- 1D: single row ---
N = 300
dr = 0.05
r = np.arange(N) * dr

fr_true = np.exp(-(r / 3.0)**2)              # Gaussian radial source
Fy = decomp.abel_forward(fr_true, dr=dr)      # simulate camera projection
fr_recovered = decomp.abel_three_point(Fy, dr=dr)   # invert

# --- 2D: full image ---
result = decomp.abel_image(camera_frame, method="three_point")
viz.abel_compare(camera_frame, result)

abel_forward -- Forward Transform¶

Fy = decomp.abel_forward(fr, dr=1.0)

Computes the Abel projection using exact analytical pixel-strip integration.

Returns: ndarray (N,), projection values F(y[k]) at y[k] = k * dr.

Analytic check: for f(r) = exp(-r^2/s^2):

F(y) = s * sqrt(pi) * exp(-y^2 / s^2)
sigma = 3.0
fr = np.exp(-(r / sigma)**2)
Fy = decomp.abel_forward(fr, dr=dr)
Fy_analytic = sigma * np.sqrt(np.pi) * np.exp(-(r / sigma)**2)
# relative error ~ 1e-4 for N=300, dr=0.05

abel_three_point -- Inversion (recommended)¶

fr = decomp.abel_three_point(Fy, dr=1.0)

Inverse Abel transform using the Dasch (1992) three-point operator. Evaluates the derivative formulation analytically with a log-kernel, producing less noise amplification than onion peeling.

When to use: Default choice for most data. Handles both smooth and moderately noisy projections. Noise increases toward the center (r=0) but less severely than onion peeling.

Center point: The r=0 pixel is computed from the kernel formula using the even-symmetry boundary condition F(-y) = F(y) (so dF/dy = 0 at y=0). No extrapolation is applied by default.

fr = decomp.abel_three_point(Fy, dr=dr)

# For noisy data: smooth F before inverting
from scipy.ndimage import gaussian_filter1d
Fy_smooth = gaussian_filter1d(Fy, sigma=1.5)
fr_smooth = decomp.abel_three_point(Fy_smooth, dr=dr)

abel_onion -- Inversion (onion peeling)¶

fr = decomp.abel_onion(Fy, dr=1.0)

Backward substitution on the forward Abel matrix, working from the outermost shell inward. Each shell's error propagates to all inner shells, noise amplifies toward the center.

When to use: High SNR data, or when an exact matrix-inversion approach is needed. For noisy data, prefer abel_three_point.

fr_op = decomp.abel_onion(Fy, dr=dr)

abel_center -- Find Axis of Symmetry¶

center_row, center_col = decomp.abel_center(image, search_range=None)

Finds the vertical axis of symmetry by minimizing L2 asymmetry between the left and right halves of the column-summed projection. Searches within search_range pixels of n_cols // 2.

Parameter Default Description
image required (n_rows, n_cols) array
search_range n_cols // 8 Search half-width in pixels

Returns: (center_row, center_col) both integers. center_row = n_rows // 2 (axis assumed vertical).

cr, cc = decomp.abel_center(frame)
print(f"Symmetry axis at column {cc}")

# Narrow the search if you know the approximate center
cr, cc = decomp.abel_center(frame, search_range=10)

Limitations: Assumes a single vertical axis of symmetry. Works best when the source is bright and the background is low. For off-center or tilted sources, crop the image first.


abel_image -- 2D Image Inversion¶

result = decomp.abel_image(image, method="three_point", center=None,
                            dr=1.0, half="average")

Applies 1D Abel inversion to each row of a 2D image independently. The axis of symmetry is assumed to be vertical (constant column index across all rows).

Parameter Default Description
image required Camera frame, shape (n_rows, n_cols)
method "three_point" "three_point" or "onion"
center None (row, col); auto-detected with abel_center() if None
dr 1.0 Physical pixel size
half "average" "right", "left", or "average" (average both halves)

Returns dict:

Key Description
"radial" (n_rows, n_cols) Abel-inverted image
"center" (row, col) of symmetry axis used
"method" method string
"n_radial" half-width in pixels
# Basic usage
result = decomp.abel_image(frame)
radial = result["radial"]

# With known center
result = decomp.abel_image(frame, center=(60, 256), method="three_point")

# Left half only (if right half is obscured)
result = decomp.abel_image(frame, half="left")

half parameter: - "average" (default): averages both halves before inversion, best SNR, assumes good symmetry - "right" / "left": uses only one side, useful when the other is blocked or noisy - Both sides are always written to the output image (mirrored)


abel_forward_image -- Forward Project a Radial Image¶

Fy_image = decomp.abel_forward_image(fr_image, center=None, dr=1.0)

Applies the forward Abel transform to each row of a 2D radial distribution, producing the simulated camera projection. Primary use: round-trip validation.

result = decomp.abel_image(frame)
reprojected = decomp.abel_forward_image(result["radial"],
                                         center=result["center"])
err = np.linalg.norm(frame - reprojected) / np.linalg.norm(frame)
print(f"Round-trip error: {err:.4f}")   # should be small (< 0.05)

Visualization¶

viz.abel_compare(image, abel_result, ...)¶

Side-by-side figure: raw projection (left) vs Abel-inverted radial distribution (right). A cyan dashed line marks the detected symmetry axis.

from anvil import viz

result = decomp.abel_image(frame)
viz.abel_compare(frame, result)

# Options
viz.abel_compare(frame, result, cmap="inferno", log_scale=True)

# Save without display
fig = viz.abel_compare(frame, result, show=False)
fig.savefig("abel_inversion.png", dpi=150, bbox_inches="tight")
Parameter Default Description
image required Original projection
abel_result required Dict from abel_image()
ax None Array of 2 Axes
show True Call plt.show()
cmap "hot" Matplotlib colormap
log_scale False Apply log1p (useful for high dynamic range)

Full Workflow Example¶

import numpy as np
import anvil.decomp as decomp
from anvil import viz

# Load image (e.g. from a camera or simulation)
frame = np.load("plasma_emission.npy")   # shape (480, 640)

# Step 1: find axis of symmetry
cr, cc = decomp.abel_center(frame, search_range=20)
print(f"Axis at column {cc}")

# Step 2: invert
result = decomp.abel_image(
    frame,
    method="three_point",
    center=(cr, cc),
    dr=0.05,         # 50 microns per pixel
    half="average",
)

# Step 3: visualize
viz.abel_compare(frame, result, cmap="hot", log_scale=True)

# Step 4: extract radial profile at a specific row
radial = result["radial"]
mid_row = result["center"][0]
r_profile = radial[mid_row, cc:]      # half-profile from center outward
r_axis = np.arange(len(r_profile)) * 0.05   # physical r in mm

# Step 5: validate with round-trip
reprojected = decomp.abel_forward_image(radial, center=(cr, cc), dr=0.05)
err = np.linalg.norm(frame - reprojected) / np.linalg.norm(frame)
print(f"Round-trip error: {err:.4f}")

Method Comparison¶

Method Noise sensitivity Speed Center accuracy Best for
three_point Low-moderate O(N^2) Good General purpose (default)
onion High at center O(N^2) Moderate Clean/simulated data

Both methods are O(N^2) per row (matrix operations). For a 500-column image with 500 rows: ~0.2 s per method on a modern CPU.


Limits and Gotchas¶

Issue Detail
Noise at center Both methods amplify noise toward r=0. Pre-smooth Fy with gaussian_filter1d(Fy, sigma=1-2) before inverting noisy data.
Non-symmetric image half="average" improves SNR but amplifies errors if the image is not truly symmetric. Check with half="right" vs half="left" separately.
Center accuracy abel_center() works to the nearest pixel. Sub-pixel centering reduces artifacts; for high-precision work, find the center externally and pass it explicitly.
Edge effects The outermost 2-3 pixels of the inverted image are unreliable (boundary treatment). Trim them before analysis.
Background Non-zero background in F(y) introduces a spike at r=0 in the inversion. Subtract background from Fy before inverting.
Memory Each abel_image() call builds an N x N matrix per row. For N=1000: 8 MB per row. Use half="right" or half="left" (smaller N) for very wide images.
Layer 2 (PyAbel) BASEX, rbasex, DAUN (regularized), Hansen-Law, higher accuracy for difficult data, will be added as a PyAbel adapter.

2D Euler CFD Solver¶

anvil.cfd is a native finite-volume CFD solver for the 2D inviscid (Euler) equations on structured body-fitted meshes. It handles subsonic, transonic, and supersonic flows including normal and oblique shocks.

The key integration point: solver.as_relation() wraps the CFD result into an Anvil Relation, making it fully composable with Systems, sweepable over Mach number or geometry, and registerable like any other RSQ.


Quick Start¶

from anvil.cfd import CFDSolver, Mesh
from anvil.cfd.bc import SupersonicInlet, SupersonicOutlet, SlipWall, Farfield

# 1. Build mesh
mesh = Mesh.wedge(half_angle_deg=10, chord=1.0, height=0.8, nx=80, ny=40)

# 2. Boundary conditions
M_inf, p_inf, T_inf = 2.0, 101325.0, 300.0
bcs = {
    "west":  SupersonicInlet(M=M_inf, p=p_inf, T=T_inf),
    "east":  SupersonicOutlet(),
    "south": SlipWall(),
    "north": Farfield(M=M_inf, p=p_inf, T=T_inf),
}

# 3. Create and initialize solver
solver = CFDSolver(mesh, bcs, gamma=1.4, flux_scheme="roe", order=2, cfl=0.5)
solver.initialize(M=M_inf, p=p_inf, T=T_inf, alpha_deg=0.0)

# 4. Run
result = solver.run(max_iter=5000, tol=1e-6, monitor=True, print_every=200)
result.summary()

# 5. Export
result.to_vtk("wedge.vtk")         # open in ParaView
result.to_tecplot("wedge.dat")     # open in Tecplot

Mesh¶

All mesh types are structured (i, j indexing). Factory methods return a StructuredMesh2D object.

Factory Methods¶

from anvil.cfd import Mesh

# Flat-plate / generic rectangular domain
mesh = Mesh.cartesian(x_span=(0, 2), y_span=(0, 1), nx=100, ny=50)

# Wedge (sharp leading edge, supersonic flows)
mesh = Mesh.wedge(half_angle_deg=10, chord=1.0, height=0.8, nx=80, ny=40)

# Gaussian bump (transonic channel flow, shock-induced separation)
mesh = Mesh.bump(length=3.0, height=1.0, nx=120, ny=60,
                 bump_height=0.1, bump_center=1.0, bump_width=0.3)

# Compression ramp
mesh = Mesh.compression_ramp(length=2.0, height=1.0,
                              ramp_angle_deg=15, nx=100, ny=50)

# From custom coordinate arrays (body-fitted grid generation)
mesh = Mesh.from_arrays(X, Y)   # X, Y: (nx+1, ny+1) node coordinates

# Load from file (previously saved mesh)
mesh = Mesh.from_file("mesh.msh")

Mesh Info and Plotting¶

mesh.info()       # prints: nx, ny, patch names, bounding box
mesh.plot()       # matplotlib visualization of grid lines
mesh.to_file("mesh.msh")

Patch Names¶

Each mesh factory assigns patch names to the four boundaries:

Factory west east south north
cartesian inlet outlet wall top
wedge west east south north
bump west east south north
compression_ramp west east south north

Override with patches={"west": MeshPatch(...), ...} argument.


Boundary Conditions¶

from anvil.cfd.bc import (
    SupersonicInlet, SupersonicOutlet,
    SubsonicInlet, SubsonicOutlet,
    SlipWall, Farfield, BackPressure, MassFlowInlet,
)
BC Class Use Case Required args
SupersonicInlet M > 1 inflow, all characteristics enter M, p, T
SupersonicOutlet M > 1 outflow, all characteristics leave ,
SubsonicInlet M < 1 inflow (stagnation BCs) M, p0, T0
SubsonicOutlet M < 1 outflow, back-pressure specified p_back
SlipWall Inviscid wall (tangential flow enforced) ,
Farfield Far-field / freestream condition M, p, T
BackPressure Pressure outlet (subsonic) p_back
MassFlowInlet Specified mass flow rate inlet mdot, T, area

Example, subsonic channel with back-pressure:

bcs = {
    "west":  SubsonicInlet(M=0.3, p0=110000, T0=310),
    "east":  BackPressure(p_back=101325),
    "south": SlipWall(),
    "north": SlipWall(),
}

CFDSolver¶

solver = CFDSolver(
    mesh,
    bcs,
    gamma      = 1.4,      # ratio of specific heats
    R_gas      = 287.058,  # specific gas constant [J/kg/K]
    flux_scheme= "roe",    # "roe" or "hllc"
    order      = 2,        # 1 = first-order upwind, 2 = MUSCL (2nd order)
    cfl        = 0.5,      # CFL number (reduce to 0.3 for stability near shocks)
    time_scheme= "rk4",    # "rk4" or "euler" (RK4 more stable)
)

solver.initialize(M=2.0, p=101325, T=300, alpha_deg=0.0)

Running¶

result = solver.run(
    max_iter    = 5000,   # iteration limit
    tol         = 1e-6,  # L2 residual convergence threshold
    monitor     = True,  # print residual every print_every iterations
    print_every = 200,   # print interval
    restart     = None,  # path to .npz restart file (or None)
)

Convergence output:

iter     0   res = 2.4812e+00
iter   200   res = 4.3271e-01
iter   400   res = 6.1843e-02
...
iter  3200   res = 8.4312e-07
Converged in 3246 iterations (res = 9.98e-07)

Restart¶

# Save state mid-run
result.to_restart("checkpoint.npz")

# Resume from checkpoint
result2 = solver.run(max_iter=5000, restart="checkpoint.npz")

CFDResult¶

Post-processing is done on the CFDResult object returned by solver.run().

result.summary()
# Prints: converged flag, iterations, final residual,
#         freestream conditions, min/max field values

# Wall pressure distribution (south boundary by default)
p_wall = result.wall_pressure(side="south")
# returns ndarray (nx,) of static pressure along wall

# Aerodynamic force coefficients
CL, CD = result.force_coefficients(
    p_ref=p_inf, rho_ref=rho_inf, V_ref=V_inf,
    S_ref=1.0,        # reference area [m²]
    side="south",     # which boundary to integrate
    alpha_deg=0.0,    # angle of attack (for CL/CD decomposition)
)

# Field arrays
fields = result._field_dict()
# {"rho": (nx,ny), "u": (nx,ny), "v": (nx,ny), "p": (nx,ny),
#  "T": (nx,ny), "M": (nx,ny), "p0": (nx,ny)}

Export¶

result.to_vtk("solution.vtk")         # ParaView, rho, u, v, p, T, M
result.to_tecplot("solution.dat")     # Tecplot ASCII
result.to_restart("solution.npz")     # numpy restart file

Integration with Anvil System¶

solver.as_relation() converts the CFD solver into a standard Anvil Relation. Scalar Q inputs and scalar Q outputs are wired as normal workspace quantities.

rel = solver.as_relation(
    inputs  = ["M_inf", "p_inf", "T_inf"],
    outputs = ["CL", "CD", "M_max", "p_wall_max"],
    name    = "wedge_cfd",
)

# Use in a System
sys = anvil.System("aero_study")
sys.add("M_inf", 2.0)
sys.add("p_inf", 101325.0, "Pa")
sys.add("T_inf", 300.0,   "K")
sys.use(rel)

result = sys.solve_forward()
print(result["CL"], result["CD"])

# Sweep Mach number, runs CFD at each point
import numpy as np
sweep = sys.sweep("M_inf", np.linspace(1.5, 3.5, 8), parallel=4)
sweep.summary(outputs=["M_inf", "CL", "CD"])

Note on parallel: Each parallel worker gets an independent solver copy. Stateless, safe for parallel sweeps.


Supported Mesh Configurations & Flows¶

Configuration Mesh factory Typical use
2D wedge Mesh.wedge() Oblique shock angle, wave drag
Channel bump Mesh.bump() Transonic shock / shock-boundary layer
Compression ramp Mesh.compression_ramp() Ramp-induced separation
Flat plate Mesh.cartesian() Subsonic flow, generic geometry
Custom body-fitted Mesh.from_arrays() Airfoils, nozzles, any structured grid

Supported flow regimes:

Regime M range Notes
Subsonic M < 0.8 Smooth convergence
Transonic 0.8 < M < 1.2 May need CFL ≤ 0.3, order=1 near shock
Supersonic M > 1.2 Fastest convergence, clean shocks
Hypersonic M > 5 Needs reduced CFL (0.2-0.3), gamma correction

Visualization, anvil.cfd.viz¶

anvil.cfd.viz provides filled contour plots and multi-field panels for CFDResult objects. All plots use monospace fonts. Fixed colorbar limits (vmin/vmax) keep successive frames directly comparable, essential for animations or batch sweeps.

from anvil.cfd import viz as cfd_viz

# Single-field contour (opens matplotlib window)
cfd_viz.contour(result, "M")                           # Mach number, auto scale
cfd_viz.contour(result, "p", vmin=90000, vmax=180000)  # fixed colorbar

# Save to PNG without opening a window
cfd_viz.save_png(result, "M", "mach.png", vmin=0, vmax=2.5)

# 2×2 multi-field panel with per-field colorbar limits
fig, axes = cfd_viz.multi_field(
    result, ["M", "p", "T", "rho"],
    vmin_map={"p": (90e3, 200e3), "M": (0, 3)},
    save_path="overview.png",
)

# Mesh + named boundary patch labels
cfd_viz.mesh_plot(mesh)

# Normalised residual history PNG (res / res0)
cfd_viz.convergence_png(result.history, "convergence.png")

Available fields: p (pressure), T (temperature), M (Mach), rho (density), u, v (velocity components), cp (pressure coefficient), pt (total pressure).

Function reference:

Function Description
contour(result, field, vmin, vmax, show_patches, save_path) Filled contour of one field; overlays boundary patch labels
save_png(result, field, path, vmin, vmax) Non-interactive contour save, no window
multi_field(result, fields, vmin_map, save_path) 2×2 (or 1×N) panel, optional per-field limits
mesh_plot(mesh) Grid lines + named boundary patch labels
convergence_png(history, path) Normalised residual (res/res0) saved to PNG

Tip, animations: Run save_png inside a loop over save_every restart frames. Pass the same vmin/vmax each call so all frames share a colorbar scale.

for i, r in enumerate(solver.snapshots):
    cfd_viz.save_png(r, "M", f"frame_{i:04d}.png", vmin=0, vmax=2.5)

Limitations¶

  • 2D only, no 3D support
  • Structured meshes only, no unstructured, no AMR
  • Euler only, no viscous (Navier-Stokes), no turbulence
  • Ideal gas only, no real gas EOS, no equilibrium chemistry
  • Dirichlet / ghost-cell BCs only, no adjoint-compatible, no PML far-field
  • For viscous flow, combustion, or production-quality results: use OpenFOAM via a CLI adapter

Extensibility notes (from module docstring): - Viscous: add viscous_flux_2d() in flux.py; call after inviscid in solver._residual() - 3D: add k-index in mesh, w-velocity, and third face sweep in solver - Real gas: replace ideal-gas EOS calls with custom EOS object

Signal Processing RSQs¶

Seven built-in RSQs for time-domain and frequency-domain signal analysis. All live in domain misc, accept NumPy arrays, and return arrays + scalar summaries.


Quick reference¶

RSQ Purpose Key outputs
fft_spectrum One-sided power spectrum freqs, power, dominant_freq, rms, thd
welch_psd Averaged PSD (Welch) freqs, psd, total_power, dominant_freq
stft_spectrogram Time-frequency map t, freqs, S (n_freq × n_time), f_peak
bandpass_filter Zero-phase Butterworth signal_filtered, rms_in, rms_out, attenuation_dB
envelope_detection Hilbert envelope + inst. freq envelope, inst_freq, inst_phase, peak_envelope
cross_correlation Normalized xcorr + lag lags, xcorr, lag_peak, corr_peak
signal_statistics Descriptive statistics rms, peak, crest_factor, kurtosis, skewness

All available via anvil.R.<name>(...) after import anvil.


fft_spectrum¶

r = anvil.R.fft_spectrum(signal, dt=1.0, window="hann")

Inputs

Parameter Type Description
signal array 1-D time-domain signal
dt float Sampling interval in seconds (default 1.0)
window str "hann" (default), "hamming", "blackman", "none"

Outputs: freqs [Hz], power, amplitude, dominant_freq [Hz], dominant_power, rms, thd, n_samples, f_resolution [Hz]

Notes - THD (Total Harmonic Distortion) = √(Σ harmonic power, 2nd-5th) / fundamental amplitude - Use "none" window only for exactly periodic signals (integer number of cycles in window) - f_resolution = 1/(n·dt): more samples → finer resolution

import numpy as np, anvil
t  = np.linspace(0, 1, 1024, endpoint=False)
sig = np.sin(2*np.pi*50*t) + 0.3*np.sin(2*np.pi*150*t)
r  = anvil.R.fft_spectrum(signal=sig, dt=t[1]-t[0])
# dominant_freq = 50.0 Hz   thd ≈ 0.300   rms ≈ 0.740

welch_psd¶

r = anvil.R.welch_psd(signal, dt=1.0, nperseg=256, noverlap=None, window="hann")

Inputs

Parameter Type Description
signal array 1-D signal
dt float Sampling interval (s)
nperseg int Segment length; controls frequency resolution (default 256)
noverlap int or None Overlap samples (default: nperseg//2)
window str Same options as fft_spectrum

Outputs: freqs, psd (power/Hz), total_power, dominant_freq, dominant_psd, f_resolution

When to use over fft_spectrum: noisy or stochastic signals where variance reduction matters more than exact amplitude. psd is in power per Hz, integrate to get total power. Good for NVH, acoustic measurements, sensor noise floors.


stft_spectrogram¶

r = anvil.R.stft_spectrogram(signal, dt=1.0, nperseg=256, noverlap=None, window="hann")

Outputs: t [s], freqs [Hz], S (power array, shape n_freq × n_time), t_peak, f_peak, n_frames

Plotting:

import matplotlib.pyplot as plt
plt.pcolormesh(r["t"], r["freqs"], 10*np.log10(r["S"] + 1e-12))
plt.xlabel("time (s)"); plt.ylabel("frequency (Hz)"); plt.colorbar(label="dB")

Time-frequency resolution tradeoff: large nperseg → fine frequency resolution, coarse time resolution. Small nperseg → good time resolution, coarse frequency resolution.


bandpass_filter¶

r = anvil.R.bandpass_filter(signal, dt=1.0, f_low=None, f_high=None, order=4)

Inputs: f_low, f_high in Hz. Omit one for lowpass/highpass.

Outputs: signal_filtered (array), rms_in, rms_out, attenuation_dB

Implementation: scipy.signal.sosfiltfilt (zero-phase forward+backward), no phase distortion. Effective filter order = 2×order.

# Lowpass at 100 Hz
r = anvil.R.bandpass_filter(signal=noisy, dt=dt, f_high=100.0, order=5)

# Bandpass 30-80 Hz
r = anvil.R.bandpass_filter(signal=noisy, dt=dt, f_low=30, f_high=80, order=4)

# Highpass at 10 Hz (remove DC drift)
r = anvil.R.bandpass_filter(signal=drift, dt=dt, f_low=10.0)

envelope_detection¶

r = anvil.R.envelope_detection(signal, dt=1.0)

Outputs: envelope (array, same length), inst_freq [Hz] (array), inst_phase [rad] (array), peak_envelope, mean_envelope, n_samples

Applications: bearing fault detection (envelope spectrum of high-frequency band), flutter amplitude tracking, AM signal demodulation, AE hit detection.

Tip: apply bandpass_filter first to isolate the carrier band, then envelope_detection to extract the modulation, then fft_spectrum on the envelope to find fault frequencies.


cross_correlation¶

r = anvil.R.cross_correlation(signal_a, signal_b, dt=1.0, mode="full")

Outputs: lags [s], xcorr (normalized, array), lag_peak [s], corr_peak, n_samples

Applications: - Time delay: lag_peak = propagation delay between two sensor signals - Flow velocity from cross-correlation of upstream/downstream probes: v = d / lag_peak - Signal alignment: shift signal_b by lag_peak to align with signal_a - Coherence check: corr_peak close to 1.0 means signals are linearly related

Normalization: both signals zero-meaned and unit-variance before correlation → corr_peak ∈ [−1, 1].


signal_statistics¶

r = anvil.R.signal_statistics(signal, dt=1.0)

Outputs

Key Description
mean DC offset
std Standard deviation (AC RMS)
rms True RMS (includes DC)
peak Max absolute value
peak_to_peak Max − min
crest_factor peak / rms
kurtosis 4th statistical moment (3.0 for Gaussian)
skewness 3rd statistical moment (0.0 for symmetric)
n_samples Sample count
duration n_samples × dt (seconds)
sample_rate 1/dt (Hz)

Fault indicators (vibration health monitoring): - Kurtosis > 4: impulsive content, possible bearing spalling or structural crack - High crest factor (> 4-5): isolated impacts superimposed on background vibration - Sine wave: kurtosis ≈ 1.5, crest factor ≈ 1.414


Chaining RSQs, vibration fault diagnosis example¶

import numpy as np, anvil

# Simulated bearing outer-race fault: carrier at 2 kHz, fault at 120 Hz
fs = 10e3; dt = 1/fs; n = 8192
t = np.arange(n) * dt
fault_sig = (1 + 0.6*np.sin(2*np.pi*120*t)) * np.sin(2*np.pi*2000*t)
noise = 0.2 * np.random.default_rng(0).standard_normal(n)
raw = fault_sig + noise

# Step 1: statistics to characterize raw signal
s = anvil.R.signal_statistics(signal=raw, dt=dt)
print(f"kurtosis={s['kurtosis']:.2f}  crest={s['crest_factor']:.2f}")
# kurtosis≈2.1 (masked by noise)

# Step 2: bandpass around 2 kHz carrier band (1500-2500 Hz)
f = anvil.R.bandpass_filter(signal=raw, dt=dt, f_low=1500, f_high=2500, order=5)

# Step 3: envelope (Hilbert)
e = anvil.R.envelope_detection(signal=f["signal_filtered"], dt=dt)

# Step 4: FFT of envelope → fault frequency appears at 120 Hz
r = anvil.R.fft_spectrum(signal=e["envelope"], dt=dt, window="hann")
print(f"envelope dominant_freq = {r['dominant_freq']:.1f} Hz")
# envelope dominant_freq = 120.0 Hz  ← bearing fault frequency detected

Usage in Systems and sweeps¶

# Sweep: how does SNR affect dominant frequency detection?
sys_ = anvil.system("snr_study")
sys_.add("signal", signal)
sys_.add("dt", dt)
sys_.add("window", "hann")
sys_.use("fft_spectrum")

# Can't sweep over array-valued inputs in sys.sweep(), sweep scalars only.
# For array signals, build a loop and call the RSQ directly:
for noise_level in [0.01, 0.1, 0.5, 1.0]:
    noisy = signal + noise_level * np.random.default_rng(0).standard_normal(len(signal))
    r = anvil.R.fft_spectrum(signal=noisy, dt=dt)
    print(f"noise={noise_level:.2f}  f_dom={r['dominant_freq']:.1f} Hz")

Note: sys.sweep() sweeps over scalar parameters. Array inputs (signal) must be fixed at System level; sweep over scalar parameters like dt, nperseg, f_low, etc.


Dependencies¶

RSQ scipy required notes
fft_spectrum No numpy.fft only
welch_psd Yes (scipy.signal.welch) always available (Anvil core dep)
stft_spectrogram No numpy.fft only
bandpass_filter Yes (scipy.signal.butter, sosfiltfilt) always available
envelope_detection Yes (scipy.signal.hilbert) always available
cross_correlation No numpy.correlate
signal_statistics No numpy only

scipy is a core Anvil dependency, all signal RSQs are always available.

Web Workbench¶

The Web Workbench is Anvil's browser UI: a Calculator for calling any registered RSQ with units, and a Canvas for building solvable systems visually. One local server serves the UI, the JSON/WebSocket API, and this wiki from a single origin.

The workbench is the third (optional) run tier, the core package and the project database work without it. See Overview.


Quick Start¶

# 1. Server dependencies (FastAPI + uvicorn)
pip install -r anvil_server/requirements.txt

# 2. Build the frontend once (Node 18+)
cd anvil_web && npm install && npm run build && cd ..

# 3. Run
python -m anvil_server.run
# → UI    http://127.0.0.1:8000/
# → wiki  http://127.0.0.1:8000/wiki
# → API   http://127.0.0.1:8000/api/...

Mount a project database so its RSQs appear alongside the 101 built-ins:

python -m anvil_server.run --project ./my_study
Env var Default Purpose
ANVIL_HOST 127.0.0.1 Bind address
ANVIL_PORT 8000 Port
ANVIL_PROJECT , Same as --project
NATIVE_ONLY off Tier B: hide adapter RSQs, sandbox execution
ANVIL_CORS_ORIGINS , Extra CORS origins (comma-separated)

For frontend development, run npm run dev in anvil_web/ (Vite dev server on port 5173, proxying to the API at 8000).


Calculator Page¶

  • Catalog, browse every RSQ from the global registry (and the mounted project registry), grouped by domain. Categories are collapsible. Each entry links to its wiki section.
  • Calc pad, pick an RSQ, fill inputs (units accepted, e.g. 500 kPa), get outputs with units and LaTeX-rendered relations (KaTeX).
  • Sweep panel, sweep any input over a range and plot the response inline.

Canvas Page¶

A node-graph editor (React Flow) where quantity blocks wire into relation blocks to form a System:

  • Palette, add quantity nodes and relation/adapter nodes; grouped by domain, collapsible.
  • Solve, runs the graph server-side; iterative solves stream residuals live over WebSocket.
  • Auto-align, one click re-lays the graph left-to-right by dependency depth (quantities → relations → outputs) so nothing overlaps.
  • Script bridge, every canvas serializes to a plain Python script and back. GET /api/example-scripts lists the repo's examples/*.py that parse into non-empty canvases; loading one populates the graph.
  • Save/load, canvases persist server-side as scripts (PUT /api/canvases/{name}).

API Reference (summary)¶

Endpoint Method Purpose
/healthz GET Liveness + version
/api/registry GET All RSQs (global + project) with metadata
/api/registry/refresh POST Re-read registry databases
/api/rsq/{name} GET One RSQ: signature, units, source, docs link
/api/solve POST Call one RSQ with inputs
/api/sweep POST Sweep an input, return table
/api/system/solve POST Solve a canvas graph as a System
/ws/system/solve WS System solve with live residual streaming
/api/canvases GET/PUT/DELETE Saved canvases (as Python scripts)
/api/canvases/parse POST Script text → canvas graph
/api/example-scripts GET Example scripts that produce valid canvases
/api/viz/sweep, /api/viz/convergence POST Server-rendered plots
/wiki GET This wiki (static)
/ GET The built SPA (anvil_web/dist)

Interactive OpenAPI docs at http://127.0.0.1:8000/docs while the server runs.


Execution Tiers¶

Tier Flag Behaviour
A default RSQs run in-process; fastest; trusted local use
B NATIVE_ONLY=1 Adapter RSQs hidden; solves run in a sandboxed subprocess with a timeout

Architecture¶

anvil_server/            FastAPI backend
├── run.py               entry point (python -m anvil_server.run)
└── app/
    ├── main.py          app factory, registry/solve/sweep/viz routes, static mounts
    ├── builder_routes.py    /api/system/solve + /ws/system/solve
    ├── canvas_routes.py     canvas CRUD + script parse/serialize (AST-based)
    ├── ws_routes.py         /ws/solve residual streaming
    ├── executor.py          Tier A/B execution
    ├── sandbox.py           subprocess sandbox for Tier B
    └── schemas.py           Pydantic request/response models

anvil_web/               React 18 + TypeScript + Vite frontend
└── src/
    ├── components/      Calculator, Catalog, Builder (canvas), SweepPanel, ...
    ├── components/nodes/    canvas node types (quantity, relation, result)
    └── lib/             API client, docs links, graph layout

The canvas ↔ script bridge is the design centre: the canvas is never a separate format, it is a Python script, so anything built visually runs headless with plain python, and any example script opens as a canvas.


Calculator features¶

Beyond running a single RSQ with unit-aware inputs, the Calculator page has:

  • Formula rendering. Every RSQ shows its formula typeset with KaTeX (from the RSQ latex metadata), falling back to the Python signature when none is set.
  • Relation packs. Chips above the catalog filter the RSQ list to a curated pack (Jet Engine Cycle, Compressible Flow, Heat Transfer, Structures, Curve Fitting, and more). Packs match by domain and tags, so newly seeded RSQs join the right pack automatically.
  • Curve fitting and data tables. The fitting RSQs (linear_regression, poly_fit, power_fit, exp_fit) take x_data and y_data arrays. Paste a data table into the CSV box and map columns straight onto the array inputs, then read back the coefficients, R-squared and fitted curve.
  • Result comparison. Click + Compare on any result to pin it into the comparison tray, which lines up every pinned run as a column with one row per variable. Export the whole comparison as CSV.
  • Report export. Export a solved RSQ as a self-contained HTML report (formula as MathML, inputs and results tables, ready to print) or as Markdown, alongside the raw CSV and JSON exports.

Keypad, memory and navigation¶

The right-hand pane of the Calculator page and a few global keys round out the workbench:

  • Scientific keypad. A calculator pad evaluates freeform expressions (trig, logs, powers, constants) with a rad/deg angle-mode toggle, independent of any RSQ.
  • Memory plane. Named slots plus an auto-log of recent results sit below the keypad. Press M+ on any RSQ result to drop it into memory instantly; slots and history persist in localStorage and sync live with the calculator.
  • Spotlight. Press Ctrl/Cmd-K for a fuzzy command palette that searches every RSQ by name and description. Enter opens the match in the Calculator, or drops it onto the Canvas when that page is active.
  • Keyboard shortcuts. ? opens the shortcuts help, t toggles the theme, and g then c / v jumps between the Calculator and Canvas pages.
  • Pinning. The star toggle on any catalog entry pins it to a quick-access group at the top of the catalog (persisted in localStorage).

Contributing & Extending Anvil¶

Anything in Anvil, a unit, an RSQ, an adapter, a wiki page, is added by editing one predictable place. This page is the map.

Priorities (in order): native functions, sweep, sensitivity, the unit engine, the project database, re-usability, robustness. Adapters to external tools come after all of those, they are conveniences, never load-bearing, and they are real-only (no mock fallbacks; see Adapters).


Repo Layout (what to touch for what)¶

I want to... Edit
Add/fix a unit src/anvil/units.py
Add a built-in RSQ src/anvil/seed.py
Add an adapter src/anvil/adapters/<lib>.py
Change solving/sweep/sensitivity src/anvil/system.py, src/anvil/solvers/
Change the web API anvil_server/app/
Change the web UI anvil_web/src/
Update docs docs/wiki/*.md, then rebuild (below)
Add an example examples/
Add a test tests/

Adding a Unit¶

Units live in src/anvil/units.py in the UnitDB definitions: each unit maps a string to (scale_to_SI, Dim). Find the category block (pressure, energy, ...) and add a line following its neighbours. Offset units (like degC, degF) additionally carry an offset, see how those two are defined before adding another.

Checklist:

  1. Add the definition next to its category.
  2. python -c "import anvil; print(anvil.Q(1,'<new_unit>').to('<si_unit>'))" round-trips correctly.
  3. Add a conversion case to tests/ (see test_v03.py unit-conversion checks).
  4. Update the unit count in docs/wiki/index.md and README.md if you changed the total.

Adding a Built-in RSQ¶

Built-ins are seeded from src/anvil/seed.py. Each RSQ is a plain function, keyword inputs, dict of outputs (values as Q where dimensional), pushed with a domain and tags. Copy the closest existing entry in the same domain.

Rules that keep RSQs reusable:

  • Inputs are SI floats or dimensionless; accept Q transparently where the neighbours do.
  • Return {"name": Q(value, "unit"), ...}, declared units let Systems propagate dimensions.
  • One equation set per RSQ; compose bigger models in a System, not inside one function.
  • Docstring: one-line summary + the equation + input/output meaning. The wiki and the workbench catalog both surface it.

Then: add it to docs/wiki/09_builtin_rsqs.md under its domain, bump the counts, add a test.

For your own project's RSQs you don't edit the repo at all, use the project database:

proj = anvil.project("my_study", path="./work")
proj.push(my_func, domain="aero", tags=["compressible"])
proj.promote("my_func")   # optional: copy into the global registry

Adding an Adapter¶

Follow the real-only pattern in Adapters exactly:

  1. src/anvil/adapters/<lib>.py with _require_<lib>() (raises ImportError/RuntimeError with the install command) and is_available() -> bool.
  2. Lazy-import the library inside the wrapper; the module must import cleanly without the dependency.
  3. No mock fallbacks. A missing tool is an error, not an approximation. If closed-form physics exists, it belongs in seed.py as a native RSQ instead.
  4. register() pushing to a sensible domain.subdomain.
  5. An examples/ex_<lib>_adapter.py that starts with the availability guard:
from anvil.adapters import mylib_adapter
if not mylib_adapter.is_available():
    print("mylib not installed -- skipping example.")
    print("Install: pip install mylib")
    raise SystemExit(0)
  1. A row in the Adapter Comparison table in docs/wiki/10_adapters.md.

Adding a Wiki Page¶

  1. Write docs/wiki/NN_topic.md (plain Markdown; fenced code blocks and tables supported).
  2. Register it in docs/build_wiki.py: add to PAGES and to a SECTION_GROUPS group.
  3. Rebuild:
cd docs
python build_wiki.py        # → ANVIL_WIKI.html + index.html

The build produces a single self-contained HTML file (all CSS/JS inline) written to both ANVIL_WIKI.html (served at /wiki by the workbench server) and index.html (so the docs/ folder deploys directly to any static host, e.g. Cloudflare Pages with docs as the build output directory and no build command).

Web UI Development¶

cd anvil_web
npm install
npm run dev        # Vite on http://localhost:5173, API proxied to :8000
npm run build      # production bundle → anvil_web/dist (served by the backend)

Backend dev: python -m anvil_server.run reloads are manual; the API surface is defined in anvil_server/app/ with Pydantic schemas in schemas.py.


Tests¶

python -m pytest tests/ -q        # pytest-compatible suite
python tests/test_v03.py          # standalone check scripts also run directly

Conventions: test files double as runnable scripts (if __name__ == "__main__": guards); helper assertion decorators are named check, not test, so pytest doesn't collect them as fixtures.

Before submitting changes:

  1. Full test suite passes.
  2. Any touched example still runs (python examples/ex_*.py, adapter examples must exit 0 with an install hint when the tool is absent).
  3. Wiki rebuilt if you touched docs/wiki/.
  4. Counts in index.md / README.md still true if you added units or RSQs.

Examples¶

Every runnable example from the examples/ folder, with its code and output. Examples that wrap an external tool show what to install; the CFD cases note that they run a full solve locally.

Example 1: Rocket Nozzle Design Trade Study¶

examples/ex01_rocket_nozzle.py

import sys, os
import numpy as np

import anvil
from anvil import Q

print("=" * 60)
print("  Example 1: Rocket Nozzle Design")
print("=" * 60)

# --- Step 1: Inspect what's available ---
print("\n[1] Inspecting the rocket_nozzle system...")
anvil.check("rocket_nozzle")

# --- Step 2: Load and customize ---
print("\n[2] Loading nozzle with custom propellant properties...")
nozzle = anvil.S.rocket_nozzle.copy()

# LOX/LH2 propellant properties
nozzle.set(
    P0=20e6,          # 20 MPa chamber pressure (high-performance engine)
    T0=3500,           # 3500 K combustion temperature
    gamma=1.20,        # typical for LOX/LH2
    R_gas=520,         # J/kg/K for LOX/LH2 products
    A_throat=0.005,    # 50 cm^2 throat
    A_exit=0.08,       # 800 cm^2 exit
    P_amb=0,           # vacuum (space engine)
)

result = nozzle.solve_forward()
result.summary()

# --- Step 3: Unit conversions ---
print("\n[3] Key results in different units:")
print(f"  Thrust:  {result['thrust'].to('kN')}")
print(f"           {result['thrust'].to('lbf')}")
print(f"  Isp:     {result['Isp']}")
print(f"  Exit V:  {result['V_exit'].to('km/s')}")
print(f"  mdot:    {result['mdot']}")

# --- Step 4: Trade study -- chamber pressure ---
print("\n[4] Sweep: Thrust and Isp vs chamber pressure...")
sweep = nozzle.sweep("P0", np.linspace(5e6, 30e6, 6))
sweep.summary(outputs=["thrust", "Isp", "mdot", "M_exit"])

# --- Step 5: Trade study -- area ratio ---
print("\n[5] Sweep: Performance vs exit area...")
nozzle.set(P0=20e6)  # reset
sweep2 = nozzle.sweep("A_exit", np.linspace(0.02, 0.15, 6))
sweep2.summary(outputs=["thrust", "Isp", "M_exit", "P_exit"])

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 1: Rocket Nozzle Design
============================================================

[1] Inspecting the rocket_nozzle system...

============================================================
  anvil.check('rocket_nozzle')  [PASS]
============================================================
  Type:        System
  Domain:      propulsion
  Description: Quasi-1D isentropic rocket nozzle with thrust and Isp
  Version:     0.1.0
  Inputs:      P0, T0, gamma, R_gas, A_throat, A_exit, P_amb
  Outputs:     Isp, M_exit, P0_P, P_exit, T0_T, T_exit, V_exit, a_exit, area_ratio, mdot
  Depends on:  nozzle_area_ratio, area_mach_supersonic, isentropic_ratios, exit_conditions, exit_velocity, choked_mass_flow, rocket_thrust, specific_impulse

  --- Dependency Tree ---
  rocket_nozzle (System)
    Inputs (7):
      P0: 6900000.0 Pa  -- Chamber pressure
      T0: 3500.0 K  -- Chamber temperature
      gamma: 1.25   -- Ratio of specific heats
      R_gas: 320.0 J/kg/K  -- Specific gas constant
      A_throat: 0.01 m^2  -- Throat area
      A_exit: 0.08 m^2  -- Exit area
      P_amb: 101325.0 Pa  -- Ambient pressure
    Relations (8):
      [1] nozzle_area_ratio
          in:  A_exit, A_throat
          out: area_ratio
      [2] area_mach_supersonic
          in:  area_ratio, gamma
          out: M_exit
... (125 more lines)

Example 2: Heat Exchanger Design (Coupled System)¶

examples/ex02_heat_exchanger.py

import numpy as np

from anvil import System
from anvil.monitor import diagnose

print("=" * 60)
print("  Example 2: Counter-Flow Heat Exchanger")
print("=" * 60)

# --- Build the system (registry relations only) ---

hx = System("counter_flow_hx")

# Operating conditions
hx.add("T_hot_in",  600,    "K",      desc="Hot inlet (exhaust gas)")
hx.add("T_cold_in", 290,    "K",      desc="Cold inlet (water)")
hx.add("UA",        2000,   "W",      desc="Overall heat transfer coefficient * area")
hx.add("Cp_hot",    1050,   "J/kg/K", desc="Hot fluid specific heat (exhaust)")
hx.add("Cp_cold",   4186,   "J/kg/K", desc="Cold fluid specific heat (water)")
hx.add("mdot_hot",  0.8,    "kg/s",   desc="Hot mass flow rate")
hx.add("mdot_cold", 0.5,    "kg/s",   desc="Cold mass flow rate")

# Initial guesses for the coupled variables
hx.add("T_cold_out", 350,   "K",      desc="Cold outlet (initial guess)")
hx.add("Q_dot",      50000, "W",      desc="Heat transfer rate (initial guess)")

# Q_dot needs T_cold_out; T_cold_out needs Q_dot -> a cycle Anvil
# detects and solves iteratively (gauss_seidel auto-selected).
hx.use("hx_heat_rate")
hx.use("hx_cold_out")
hx.use("hx_hot_out")
hx.use("hx_effectiveness")

# --- Pre-solve diagnostics ---
print("\n[1] Pre-solve diagnostics:")
for msg in diagnose(hx):
    print(f"  {msg}")

# --- Solve ---
print("\n[2] Solving (Gauss-Seidel with monitoring)...")
result = hx.solve_gauss_seidel(
    max_iter=200,
    rtol=1e-10,
    relaxation=0.5,
    monitor=True,
    verbose=True,
)
result.summary()

# --- Verify energy balance ---
print("\n[3] Energy balance check:")
Q_hot  = result["mdot_hot"].si * result["Cp_hot"].si * (result["T_hot_in"].si - result["T_hot_out"].si)
Q_cold = result["mdot_cold"].si * result["Cp_cold"].si * (result["T_cold_out"].si - result["T_cold_in"].si)
print(f"  Q_hot  = {Q_hot:.2f} W")
print(f"  Q_cold = {Q_cold:.2f} W")
print(f"  Error  = {abs(Q_hot - Q_cold):.4f} W")
print(f"  Effectiveness = {result['effectiveness']}")

# --- Convergence info ---
hist = hx.history()
print(f"\n[4] Convergence: {len(hist)} iterations")
print(f"  Initial residual: {hist[0]['residual']:.2e}")
print(f"  Final residual:   {hist[-1]['residual']:.2e}")

# --- Sweep: vary UA ---
print("\n[5] Sweep: effectiveness vs UA...")
sweep = hx.sweep("UA", np.linspace(500, 5000, 6),
                 method="gauss_seidel", relaxation=0.5, max_iter=200)
sweep.summary(outputs=["effectiveness", "T_hot_out", "T_cold_out", "Q_dot"])

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 2: Counter-Flow Heat Exchanger
============================================================

[1] Pre-solve diagnostics:
  INFO: Coupled variables detected: Q_dot, T_cold_out, T_hot_out. Will use iterative solver.

[2] Solving (Gauss-Seidel with monitoring)...
  WARNING: variable(s) declared via .add() are also produced by a relation, so the declared value will be overwritten after solve: ['T_cold_out', 'Q_dot']
    (This is intentional for iterative initial guesses; for forward systems it may indicate a naming mismatch.)
  iter    0  |  residual = 2.0000e+00  |  t = 0.000s
  iter    1  |  residual = 2.3426e-01  |  t = 0.000s
  iter    2  |  residual = 7.3938e-02  |  t = 0.000s
  iter    3  |  residual = 2.9794e-02  |  t = 0.000s
  iter    4  |  residual = 1.6573e-02  |  t = 0.000s
  iter    5  |  residual = 8.7496e-03  |  t = 0.000s
  iter    6  |  residual = 4.4986e-03  |  t = 0.000s
  iter    7  |  residual = 2.2818e-03  |  t = 0.000s
  iter    8  |  residual = 1.1494e-03  |  t = 0.000s
  iter    9  |  residual = 5.7687e-04  |  t = 0.000s
  iter   10  |  residual = 2.8900e-04  |  t = 0.000s
  iter   11  |  residual = 1.4465e-04  |  t = 0.000s
  iter   12  |  residual = 7.2362e-05  |  t = 0.000s
  iter   13  |  residual = 3.6190e-05  |  t = 0.000s
  iter   14  |  residual = 1.8098e-05  |  t = 0.000s
  iter   15  |  residual = 9.0495e-06  |  t = 0.000s
  iter   16  |  residual = 4.5249e-06  |  t = 0.000s
  iter   17  |  residual = 2.2625e-06  |  t = 0.000s
  iter   18  |  residual = 1.1313e-06  |  t = 0.000s
  iter   19  |  residual = 5.6564e-07  |  t = 0.000s
  iter   20  |  residual = 2.8282e-07  |  t = 0.000s
  iter   21  |  residual = 1.4141e-07  |  t = 0.000s
  iter   22  |  residual = 7.0705e-08  |  t = 0.000s
  iter   23  |  residual = 3.5352e-08  |  t = 0.000s
... (57 more lines)

Example 3: LEO to GEO Orbital Transfer Mission¶

examples/ex03_orbital_transfer.py

import os

import numpy as np

import anvil
from anvil import Q, System

print("=" * 60)
print("  Example 3: LEO to GEO Orbital Transfer")
print("=" * 60)

# --- Constants ---
mu_earth = 3.986004418e14  # m^3/s^2
R_earth = 6371e3  # m

# --- Step 1: Define the orbits ---
h_LEO = 400e3  # 400 km altitude
h_GEO = 35786e3  # geostationary altitude

r_LEO = R_earth + h_LEO
r_GEO = R_earth + h_GEO

print(f"\n[1] Orbit definitions:")
print(f"  LEO: {Q(h_LEO,'m').to('km')} altitude, r = {Q(r_LEO,'m').to('km')}")
print(f"  GEO: {Q(h_GEO,'m').to('km')} altitude, r = {Q(r_GEO,'m').to('km')}")

# --- Step 2: Orbital velocities ---
print(f"\n[2] Orbital velocities:")
leo_v = anvil.R.vis_viva(mu=mu_earth, r=r_LEO, a=r_LEO)
geo_v = anvil.R.vis_viva(mu=mu_earth, r=r_GEO, a=r_GEO)
print(f"  V_LEO = {leo_v['V_orbital'].to('km/s')}")
print(f"  V_GEO = {geo_v['V_orbital'].to('km/s')}")

# --- Step 3: Hohmann transfer ---
print(f"\n[3] Hohmann transfer:")
transfer = anvil.R.hohmann_transfer(mu=mu_earth, r1=r_LEO, r2=r_GEO)
print(f"  dV1 (LEO departure):  {transfer['dv1'].to('km/s')}")
print(f"  dV2 (GEO insertion):  {transfer['dv2'].to('km/s')}")
print(f"  Total delta-V:        {transfer['dv_total'].to('km/s')}")
print(f"  Transfer time:        {transfer['tof'].to('hr')}")

# --- Step 4: Orbital periods ---
print(f"\n[4] Orbital periods:")
T_LEO = anvil.R.orbital_period(mu=mu_earth, a=r_LEO)
T_GEO = anvil.R.orbital_period(mu=mu_earth, a=r_GEO)
print(f"  LEO period: {T_LEO['T_orbital'].to('min')}")
print(f"  GEO period: {T_GEO['T_orbital'].to('hr')} hrs (should be ~24)")

# --- Step 5: Propellant budget using Tsiolkovsky ---
print(f"\n[5] Propellant budget (bipropellant engine, Isp = 320 s):")

# Build a mission system
mission = System("leo_to_geo")
mission.add("mu", mu_earth)
mission.add("r_LEO", r_LEO, "m")
mission.add("r_GEO", r_GEO, "m")
mission.add("Isp_engine", 320, "s", desc="Engine specific impulse")
mission.add("m_dry", 2500, "kg", desc="Dry mass (payload + structure)")

# Delta-V
mission.use("hohmann_transfer", map={"r1": "r_LEO", "r2": "r_GEO"})

# Propellant mass from Tsiolkovsky (inverted) -- registry relation
mission.use("propellant_mass", map={"dv": "dv_total", "Isp": "Isp_engine"})
result = mission.solve_forward()

print(f"  Mass ratio:     {result['mass_ratio']}")
print(f"  Propellant:     {result['m_propellant']}")
print(f"  Wet mass:       {result['m_wet']}")
print(f"  Payload frac:   {2000 / result['m_wet']}")

# --- Step 6: Sweep over engine Isp ---
print(f"\n[6] Sweep: propellant mass vs engine Isp...")
sweep = mission.sweep("Isp_engine", np.linspace(250, 450, 5))
sweep.summary(outputs=["m_propellant", "mass_ratio", "m_wet"])
sweep_dry = mission.sweep("m_dry", np.linspace(1500, 4500, 5))
sweep_dry.summary(outputs=["m_propellant", "mass_ratio", "m_wet"])

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 3: LEO to GEO Orbital Transfer
============================================================

[1] Orbit definitions:
  LEO: 400.00 km altitude, r = 6771.00 km
  GEO: 35786.00 km altitude, r = 42157.00 km

[2] Orbital velocities:
  V_LEO = 7.6726 km/s
  V_GEO = 3.0749 km/s

[3] Hohmann transfer:
  dV1 (LEO departure):  2.3994 km/s
  dV2 (GEO insertion):  1.4572 km/s
  Total delta-V:        3.8566 km/s
  Transfer time:        5.2890 hr

[4] Orbital periods:
  LEO period: 92.4143 min
  GEO period: 23.9284 hr hrs (should be ~24)

[5] Propellant budget (bipropellant engine, Isp = 320 s):
  Mass ratio:     3.4176 (mass_ratio)
  Propellant:     6044.03 kg (m_propellant)
  Wet mass:       8544.03 kg (m_wet)
  Payload frac:   0.234082 [M-1]

[6] Sweep: propellant mass vs engine Isp...

----------------------------------------------------------------------
  leo_to_geo -- sweep over Isp_engine
----------------------------------------------------------------------
      Isp_engine  m_propellant    mass_ratio         m_wet
... (25 more lines)

Example 4: Structural Beam Analysis¶

examples/ex04_beam_analysis.py

import numpy as np
import anvil
from anvil import Q, System

print("=" * 60)
print("  Example 4: Structural Beam Analysis")
print("=" * 60)

# Material: Aluminum 6061-T6
E        = Q(68.9e9,  "Pa")      # Young's modulus
sigma_y  = Q(276e6,   "Pa")      # yield strength
rho      = Q(2700,    "kg/m^3")  # density

# Cross section: 50 mm x 100 mm rectangular
b = Q(0.050, "m")
h = Q(0.100, "m")
A = b * h                        # m^2
I = b * h**3 / 12                # m^4

print(f"\n[1] Beam properties:")
print(f"  E        = {E.to('GPa')}")
print(f"  sigma_y  = {sigma_y.to('MPa')}")
print(f"  section  = {b.to('mm')} x {h.to('mm')}")
print(f"  Area     = {A}")
print(f"  I        = {I}")


# ── Part A: Cantilever under tip load ────────────────────────────────────────
print(f"\n[A] Cantilever beam, 5 kN tip load:")

F = Q(5000, "N")
L = Q(2.0,  "m")

r = anvil.R.beam_deflection_cantilever(
    F_tip=F.si, L_beam=L.si, E=E.si, I_moment=I.si
)
max_stress = r["max_moment"] * (h / 2) / I

print(f"  deflection  = {r['deflection'].to('mm')}")
print(f"  max moment  = {r['max_moment']}")
print(f"  max stress  = {max_stress.to('MPa')}")
print(f"  safety vs yield = {(sigma_y / max_stress):.2f}x")

# Sweep beam length
print(f"\n  Sweep: deflection vs length (0.5 to 4 m):")
cant = System("cantilever")
cant.add("F_tip",    F.si,  "N")
cant.add("L_beam",   L.si,  "m")
cant.add("E",        E.si,  "Pa")
cant.add("I_moment", I.si,  "m^4")
cant.use("beam_deflection_cantilever")
cant.sweep("L_beam", np.linspace(0.5, 4.0, 8)).summary(
    outputs=["deflection", "max_moment"])


# ── Part B: Simply-supported under uniform load ───────────────────────────────
print(f"\n[B] Simply-supported beam, 2 kN/m uniform load:")

w = Q(2000, "N/m")
L_ss = Q(3.0, "m")

r_ss = anvil.R.beam_deflection_simply_supported(
    w_load=w.si, L_beam=L_ss.si, E=E.si, I_moment=I.si
)
print(f"  max deflection = {r_ss['deflection'].to('mm')}")
print(f"  max moment     = {r_ss['max_moment']}")


# ── Part C: Euler column buckling ─────────────────────────────────────────────
print(f"\n[C] Column buckling (fixed-free, K=2):")

L_col = Q(1.5, "m")
L_eff = Q(2.0 * L_col.si, "m")   # effective length for fixed-free

r_buck = anvil.R.buckling_euler(E=E.si, I_moment=I.si, L_eff=L_eff.si)
print(f"  critical load = {r_buck['P_critical'].to('kN')}")
print(f"  safety at 50 kN = {r_buck['P_critical'] / Q(50e3,'N'):.2f}x")


# ── Part D: Thin-wall pressure vessel ─────────────────────────────────────────
print(f"\n[D] Thin-wall pressure vessel:")

r_pv = anvil.R.thin_wall_hoop_stress(
    P_internal=Q(5e6,"Pa").si, r_inner=Q(0.3,"m").si, t_wall=Q(0.005,"m").si
)
print(f"  hoop stress  = {r_pv['sigma_hoop'].to('MPa')}")
print(f"  axial stress = {r_pv['sigma_axial'].to('MPa')}")
print(f"  safety (yield / hoop) = {sigma_y / r_pv['sigma_hoop']:.2f}x")


print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 4: Structural Beam Analysis
============================================================

[1] Beam properties:
  E        = 68.9000 GPa
  sigma_y  = 276.00 MPa
  section  = 50.0000 mm x 100.00 mm
  Area     = 5.0000e-03 m^2
  I        = 4.1667e-06 [L4]

[A] Cantilever beam, 5 kN tip load:
  deflection  = 46.4441 mm
  max moment  = 10000.00 N*m
  max stress  = 120.00 MPa
  safety vs yield = 2.30x

  Sweep: deflection vs length (0.5 to 4 m):

----------------------------------------------------------------------
  cantilever -- sweep over L_beam
----------------------------------------------------------------------
          L_beam    deflection    max_moment
             [m]           [m]         [N*m]
  ------------------------------------------
             0.5     0.0007257          2500
               1      0.005806          5000
             1.5       0.01959          7500
               2       0.04644         1e+04
             2.5       0.09071      1.25e+04
               3        0.1567       1.5e+04
             3.5        0.2489      1.75e+04
               4        0.3716         2e+04
----------------------------------------------------------------------
... (17 more lines)

Example 5: Supersonic Wind Tunnel Sizing¶

examples/ex05_wind_tunnel.py

import numpy as np

import anvil
from anvil import Q, System

print("=" * 60)
print("  Example 5: Supersonic Wind Tunnel Design")
print("=" * 60)

# --- Build full tunnel system (registry relations only) ---

tunnel = System("wind_tunnel")
tunnel.add("M_test", 2.5, desc="Test section Mach number")
tunnel.add("T_test", 300, "K", desc="Test section static temperature")
tunnel.add("P_test", 50000, "Pa", desc="Test section static pressure")
tunnel.add("gamma", 1.4)
tunnel.add("R_gas", 287.058, "J/kg/K", desc="Air gas constant")
tunnel.add("A_test", 0.04, "m^2", desc="Test section area (20x20 cm)")
tunnel.add("L_char", 0.2, "m", desc="Model characteristic length")

# Isentropic ratios at test Mach
tunnel.use("isentropic_ratios", map={"M": "M_test"})

# Speed of sound and velocity in test section
tunnel.use("speed_of_sound", map={"T": "T_test"})
tunnel.use("velocity_from_mach", map={"M": "M_test"})

# Density from the ideal-gas law
tunnel.use("ideal_gas_density", map={"P": "P_test", "T": "T_test"})

# Dynamic pressure in the test section
tunnel.use("dynamic_pressure")

# Stagnation (settling chamber) conditions
tunnel.use("stagnation_conditions", map={"T": "T_test", "P": "P_test"})

# Normal shock at test Mach (diffuser entry)
tunnel.use("normal_shock", map={"M1": "M_test"})

# Prandtl-Meyer angle at test Mach
tunnel.use("prandtl_meyer", map={"M": "M_test"})

# Viscosity + Reynolds number on the model
tunnel.use("sutherland_viscosity", map={"T": "T_test"})
tunnel.use("reynolds_number")

result = tunnel.solve_forward()
result.summary(
    keys=[
        "M_test",
        "T_test",
        "P_test",
        "A_test",
        "T0",
        "P0",
        "V",
        "rho",
        "q_inf",
        "M2",
        "P2_P1",
        "P02_P01",
        "Re",
    ]
)

# --- Unit conversions using the unit engine ---
print("\n  Key results (converted via unit engine):")
print(f"  T0 = {result['T0']} {result['T0'].unit}  ({result['T0'].to('R')})")
print(f"  P0 = {result['P0'].to('kPa')}  ({result['P0'].to('atm')})")
print(f"  V_test = {result['V']} {result['V'].unit}")
print(f"  q_inf  = {result['q_inf'].to('kPa')}")
print(f"  Stagnation pressure recovery through shock = {result['P02_P01']:.4f}")
print(f"  nu(M_test) = {result['nu_deg']:.2f} degrees")
print(f"  Re (20 cm model) = {result['Re']:.3e}")

# --- Sweep test Mach ---
print("\n  Sweep: tunnel conditions vs test Mach...")
sweep = tunnel.sweep("M_test", np.linspace(1.5, 4.0, 6))
sweep.summary(outputs=["T0", "P0", "V", "q_inf", "P02_P01"])

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 5: Supersonic Wind Tunnel Design
============================================================
  WARNING: variable(s) declared via .add() are not used by any relation: ['A_test']
    (Possible typo or unused parameter.)

--------------------------------------------------------
  wind_tunnel -- results
--------------------------------------------------------
  M_test                    2.5000
  T_test                    300.00 K
  P_test                    50000.00 Pa
  A_test                    0.040000 m^2
                            ---
  T0                        675.00 K
  P0                        854296.88 Pa
  V                         868.06 m/s
  rho                       0.580603 kg/m^3
  q_inf                     218750.00 Pa
  M2                        0.512989
  P2_P1                     7.1250
  P02_P01                   0.499015
  Re                        5.4616e+06
--------------------------------------------------------

  Key results (converted via unit engine):
  T0 = 675.00 K (T0) K  (1215.00 R (T0))
  P0 = 854.30 kPa (P0)  (8.4313 atm (P0))
  V_test = 868.06 m/s (V) m/s
  q_inf  = 218.75 kPa (q_inf)
  Stagnation pressure recovery through shock = 0.4990
  nu(M_test) = 39.12 degrees
  Re (20 cm model) = 5.462e+06

... (19 more lines)

Example 6: Multi-Stage Rocket (Composition + Tsiolkovsky)¶

examples/ex06_two_stage_rocket.py

import sys, os
import numpy as np

import anvil
from anvil import Q, System

print("=" * 60)
print("  Example 6: Two-Stage Launch Vehicle")
print("=" * 60)

# ==========================================
# Stage 1: Kerosene/LOX booster
# ==========================================
print(f"\n--- Stage 1: Kerosene/LOX booster ---")

stage1_nozzle = anvil.S.rocket_nozzle.copy()
stage1_nozzle.set(
    P0=15e6,         # 15 MPa chamber pressure
    T0=3400,          # K
    gamma=1.22,       # RP-1/LOX products
    R_gas=340,        # J/kg/K
    A_throat=0.05,    # m^2 (large booster)
    A_exit=0.40,      # m^2
    P_amb=101325,     # sea-level launch
)

r1 = stage1_nozzle.solve_forward()
Isp_1 = r1["Isp"].si

print(f"  Isp (sea level): {Isp_1:.1f} s")
print(f"  Thrust:          {r1['thrust'].to('kN')}")
print(f"  Exit Mach:       {r1['M_exit']}")

# ==========================================
# Stage 2: LOX/LH2 upper stage
# ==========================================
print(f"\n--- Stage 2: LOX/LH2 upper stage ---")

stage2_nozzle = anvil.S.rocket_nozzle.copy()
stage2_nozzle.set(
    P0=8e6,           # 8 MPa
    T0=3200,           # K
    gamma=1.20,        # LOX/LH2 products
    R_gas=520,         # J/kg/K
    A_throat=0.01,     # m^2 (smaller upper stage)
    A_exit=0.12,       # m^2 (high expansion for vacuum)
    P_amb=0,           # vacuum
)

r2 = stage2_nozzle.solve_forward()
Isp_2 = r2["Isp"].si

print(f"  Isp (vacuum):    {Isp_2:.1f} s")
print(f"  Thrust:          {r2['thrust'].to('kN')}")
print(f"  Exit Mach:       {r2['M_exit']}")

# ==========================================
# Vehicle sizing with Tsiolkovsky
# ==========================================
print(f"\n--- Vehicle sizing ---")

# Mass breakdown
m_payload   = 5000    # kg
m_struct_2  = 2000    # kg (2nd stage dry mass)
m_struct_1  = 15000   # kg (1st stage dry mass)

# Required delta-V budget
dV_gravity_drag = 1500   # m/s (gravity + drag losses)
dV_orbit        = 9400   # m/s (LEO insertion velocity)
dV_total        = dV_orbit + dV_gravity_drag

# Split: 60% stage 1, 40% stage 2 (typical)
dV_1 = 0.60 * dV_total
dV_2 = 0.40 * dV_total

print(f"  Payload:     {m_payload} kg")
print(f"  dV target:   {dV_total} m/s")
print(f"  Stage 1 dV:  {dV_1:.0f} m/s (Isp = {Isp_1:.0f} s)")
print(f"  Stage 2 dV:  {dV_2:.0f} m/s (Isp = {Isp_2:.0f} s)")

# Stage 2 propellant (Tsiolkovsky)
g0 = 9.80665
MR_2 = np.exp(dV_2 / (Isp_2 * g0))
m_dry_2 = m_payload + m_struct_2
m_prop_2 = m_dry_2 * (MR_2 - 1)
m_wet_2 = m_dry_2 + m_prop_2

print(f"\n  Stage 2:")
print(f"    Mass ratio:  {MR_2:.3f}")
print(f"    Propellant:  {m_prop_2:.0f} kg")
print(f"    Wet mass:    {m_wet_2:.0f} kg")

# Stage 1 propellant
MR_1 = np.exp(dV_1 / (Isp_1 * g0))
m_dry_1 = m_wet_2 + m_struct_1  # stage 1 carries all of stage 2
m_prop_1 = m_dry_1 * (MR_1 - 1)
m_wet_1 = m_dry_1 + m_prop_1
m_liftoff = m_wet_1

print(f"\n  Stage 1:")
print(f"    Mass ratio:  {MR_1:.3f}")
print(f"    Propellant:  {m_prop_1:.0f} kg")
print(f"    Wet mass:    {m_wet_1:.0f} kg")

print(f"\n  Vehicle totals:")
print(f"    Liftoff mass:    {m_liftoff:.0f} kg ({m_liftoff/1000:.1f} tonnes)")
print(f"    Payload fraction: {m_payload/m_liftoff:.4f} ({m_payload/m_liftoff*100:.2f}%)")
print(f"    Propellant mass: {(m_prop_1 + m_prop_2):.0f} kg")

# ==========================================
# Using Anvil Systems for the same calculation
# ==========================================
print(f"\n--- Same calculation as an Anvil System ---")

vehicle = System("two_stage_vehicle")
vehicle.add("Isp_1",      Isp_1,     "s",  desc="Stage 1 Isp")
vehicle.add("Isp_2",      Isp_2,     "s",  desc="Stage 2 Isp")
vehicle.add("dV_1",       dV_1,      "m/s")
vehicle.add("dV_2",       dV_2,      "m/s")
vehicle.add("m_payload",  m_payload, "kg")
vehicle.add("m_struct_1", m_struct_1,"kg")
vehicle.add("m_struct_2", m_struct_2,"kg")

def stage_2_sizing(dV_2, Isp_2, m_payload, m_struct_2):
    MR = np.exp(dV_2 / (Isp_2 * 9.80665))
    m_dry = m_payload + m_struct_2
    m_prop = m_dry * (MR - 1)
    return {"m_prop_2": Q(m_prop, "kg"), "m_wet_2": Q(m_dry + m_prop, "kg")}

def stage_1_sizing(dV_1, Isp_1, m_wet_2, m_struct_1):
    MR = np.exp(dV_1 / (Isp_1 * 9.80665))
    m_dry = m_wet_2 + m_struct_1
    m_prop = m_dry * (MR - 1)
    m_liftoff = m_dry + m_prop
    return {"m_prop_1": Q(m_prop, "kg"), "m_liftoff": Q(m_liftoff, "kg")}

def payload_fraction(m_payload, m_liftoff):
    return {"payload_fraction": m_payload / m_liftoff}

vehicle.use(stage_2_sizing)
vehicle.use(stage_1_sizing)
vehicle.use(payload_fraction)

result = vehicle.solve_forward()
result.summary(keys=["Isp_1", "Isp_2", "dV_1", "dV_2",
                       "m_prop_2", "m_wet_2", "m_prop_1",
                       "m_liftoff", "payload_fraction"])

# --- Sweep: payload fraction vs dV split ---
print(f"\n--- Sweep: payload fraction vs Stage 1 dV fraction ---")
dV_splits = np.linspace(0.4, 0.8, 5)
results = []
for frac in dV_splits:
    vehicle.set(dV_1=frac * dV_total, dV_2=(1 - frac) * dV_total)
    r = vehicle.solve_forward()
    pf = r["payload_fraction"].si
    results.append(pf)
    print(f"  Stage 1 = {frac:.0%} of dV  -->  payload fraction = {pf:.4f}")

best_idx = np.argmax(results)
print(f"\n  Optimal split: {dV_splits[best_idx]:.0%} / {1-dV_splits[best_idx]:.0%}")
print(f"  Best payload fraction: {results[best_idx]:.4f}")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 6: Two-Stage Launch Vehicle
============================================================

--- Stage 1: Kerosene/LOX booster ---
  Isp (sea level): 277.1 s
  Thrust:          1236.81 kN (thrust)
  Exit Mach:       3.1725 (M_exit)

--- Stage 2: LOX/LH2 upper stage ---
  Isp (vacuum):    357.9 s
  Thrust:          141.16 kN (thrust)
  Exit Mach:       3.4052 (M_exit)

--- Vehicle sizing ---
  Payload:     5000 kg
  dV target:   10900 m/s
  Stage 1 dV:  6540 m/s (Isp = 277 s)
  Stage 2 dV:  4360 m/s (Isp = 358 s)

  Stage 2:
    Mass ratio:  3.463
    Propellant:  17244 kg
    Wet mass:    24244 kg

  Stage 1:
    Mass ratio:  11.094
    Propellant:  396118 kg
    Wet mass:    435363 kg

  Vehicle totals:
    Liftoff mass:    435363 kg (435.4 tonnes)
    Payload fraction: 0.0115 (1.15%)
    Propellant mass: 413363 kg
... (31 more lines)

Example 7: Combustion Chamber Analysis (Custom Empirical Adapter)¶

examples/ex07_combustion.py

import sys, os
import numpy as np

import anvil
from anvil import Q, System, Adapter

print("=" * 60)
print("  Example 7: Combustion + Nozzle Analysis")
print("=" * 60)

# =====================================================
# Empirical curve-fit adapter (LOX/RP-1, from NASA CEA data)
# =====================================================

def lox_rp1_curvefit(Pc, OF, fuel_name="RP1", oxidizer_name="LOX"):
    """
    LOX/RP-1 equilibrium properties from curve fits of NASA CEA data.
    Valid roughly for OF 1.5-4.0 and Pc 1-30 MPa.

    For exact equilibrium chemistry use anvil.adapters.cantera_thermo:
        import cantera as ct
        gas = ct.Solution('gri30.yaml')
        gas.set_equivalence_ratio(1/OF, fuel, oxidizer)
        gas.TP = 300, Pc
        gas.equilibrate('HP')
    """
    # Curve fits based on NASA CEA data for LOX/RP-1
    OF_opt = 2.7  # optimal O/F ratio
    Tc_peak = 3670  # K at optimal O/F

    # Temperature vs O/F (parabolic approximation)
    Tc = Tc_peak * (1 - 0.15 * ((OF - OF_opt) / OF_opt)**2)
    # Slight pressure dependence
    Tc = Tc * (1 + 0.02 * np.log(Pc / 1e6))

    # Molecular weight and gamma vary with O/F
    MW = 22.0 + 2.0 * (OF - 2.0)  # g/mol, approximate
    R_gas = 8314.46 / MW  # J/kg/K
    gamma = 1.15 + 0.03 * (OF - 2.0)  # approximate

    # Characteristic velocity
    cstar = (R_gas * Tc / gamma * ((gamma + 1) / 2)**((gamma + 1) / (gamma - 1)))**0.5

    return {
        "Tc": Q(Tc, "K"),
        "gamma_c": gamma,
        "R_gas_c": Q(R_gas, "J/kg/K"),
        "MW": Q(MW, "g/mol"),
        "cstar": Q(cstar, "m/s"),
    }


combustion = Adapter("lox_rp1_equilibrium",
    backend="python",
    call=lox_rp1_curvefit,
    inputs={
        "Pc":   {"unit": "Pa", "desc": "Chamber pressure"},
        "OF":   {"desc": "Oxidizer-to-fuel mass ratio", "default": 2.7},
        "fuel_name": {"desc": "Fuel identifier", "default": "RP1"},
        "oxidizer_name": {"desc": "Oxidizer identifier", "default": "LOX"},
    },
    outputs={
        "Tc":     {"unit": "K",      "desc": "Chamber temperature"},
        "gamma_c": {"desc": "Ratio of specific heats"},
        "R_gas_c": {"unit": "J/kg/K", "desc": "Specific gas constant"},
        "MW":     {"unit": "g/mol",  "desc": "Mean molecular weight"},
        "cstar":  {"unit": "m/s",    "desc": "Characteristic velocity"},
    },
    desc="LOX/RP-1 equilibrium combustion (empirical NASA CEA curve fits)",
    tags=["combustion", "propulsion", "curve-fit"],
)

# --- Direct call ---
print("\n[1] Direct combustion call (O/F = 2.7, Pc = 10 MPa):")
r = combustion(Pc=10e6, OF=2.7)
for k, v in r.items():
    if isinstance(v, Q):
        print(f"  {k:12s} = {v} {v.unit}")
    else:
        print(f"  {k:12s} = {v:.4f}")

# =====================================================
# Build integrated combustion + nozzle system
# =====================================================
print("\n[2] Integrated combustion + nozzle system:")

engine = System("lox_rp1_engine")

# Design inputs
engine.add("Pc",       10e6,    "Pa",  desc="Chamber pressure")
engine.add("OF",       2.7,            desc="O/F ratio")
engine.add("A_throat", 0.02,    "m^2", desc="Throat area")
engine.add("A_exit",   0.30,    "m^2", desc="Exit area")
engine.add("P_amb",    101325,  "Pa",  desc="Ambient pressure (sea level)")

# Combustion (adapter)
engine.use(combustion)

# Nozzle physics (from registry, with name mapping)
engine.use("nozzle_area_ratio")
engine.use("area_mach_supersonic")
engine.use("isentropic_ratios", map={"M": "M_exit", "gamma": "gamma_c"})

def exit_conditions_mapped(Tc, Pc, T0_T, P0_P, gamma_c, R_gas_c):
    T_exit = Tc / T0_T
    P_exit = Pc / P0_P
    a_exit = (gamma_c * R_gas_c * T_exit)**0.5
    return {"T_exit": Q(T_exit, "K"), "P_exit": Q(P_exit, "Pa"),
            "a_exit": Q(a_exit, "m/s")}
engine.use(exit_conditions_mapped)

def exit_velocity(M_exit, a_exit):
    return {"V_exit": Q(M_exit * a_exit, "m/s")}
engine.use(exit_velocity)

def choked_flow(Pc, A_throat, gamma_c, R_gas_c, Tc):
    t = (2 / (gamma_c + 1))**((gamma_c + 1) / (2 * (gamma_c - 1)))
    mdot = Pc * A_throat * (gamma_c / (R_gas_c * Tc))**0.5 * t
    return {"mdot": Q(mdot, "kg/s")}
engine.use(choked_flow)

engine.use("rocket_thrust", map={"P_exit": "P_exit", "V_exit": "V_exit"})
engine.use("specific_impulse")

result = engine.solve_forward()
result.summary(keys=["Pc", "OF", "A_throat", "A_exit",
                       "Tc", "gamma_c", "R_gas_c", "cstar",
                       "M_exit", "V_exit", "mdot", "thrust", "Isp"])

# --- Unit conversions ---
print(f"\n[3] Engine performance:")
print(f"  Thrust (SL):  {result['thrust'].to('kN')}")
print(f"  Isp (SL):     {result['Isp']}")
print(f"  c*:           {result['cstar']}")
print(f"  Mass flow:    {result['mdot']}")

# --- O/F ratio trade study ---
print(f"\n[4] Sweep: Isp vs O/F ratio...")
sweep = engine.sweep("OF", np.linspace(1.5, 4.0, 8))
sweep.summary(outputs=["Tc", "gamma_c", "cstar", "Isp", "thrust"])

# --- Sensitivity analysis ---
print(f"\n[5] Sensitivity analysis (which inputs drive Isp?):")
sens = engine.sensitivity(outputs=["Isp", "thrust"])
sens.summary()

print("\n  Top 3 drivers of Isp:")
for inp, val in sens.top("Isp", n=3):
    print(f"    {inp}: {val:+.4f}")

# --- Export ---
print(f"\n[6] Exporting results...")
result.to_csv("engine_results.csv")
print(f"  Saved: engine_results.csv")
sweep.to_csv("of_sweep.csv", outputs=["Tc", "Isp", "thrust"])
print(f"  Saved: of_sweep.csv")
json_str = result.to_json()
print(f"  JSON preview: {json_str[:100]}...")

# Cleanup
os.remove("engine_results.csv")
os.remove("of_sweep.csv")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 7: Combustion + Nozzle Analysis
============================================================

[1] Direct combustion call (O/F = 2.7, Pc = 10 MPa):
  Tc           = 3839.01 K K
  gamma_c      = 1.1710
  R_gas_c      = 355.32 J/kg/K J/kg/K
  MW           = 23.4000 g/mol g/mol
  cstar        = 1816.84 m/s m/s

[2] Integrated combustion + nozzle system:

--------------------------------------------------------
  lox_rp1_engine -- results
--------------------------------------------------------
  Pc                        1.0000e+07 Pa
  OF                        2.7000
  A_throat                  0.020000 m^2
  A_exit                    0.300000 m^2
                            ---
  Tc                        3839.01 K
  gamma_c                   1.1710
  R_gas_c                   355.32 J/kg/K
  cstar                     1816.84 m/s
  M_exit                    4.3838
  V_exit                    3407.92 m/s
  mdot                      110.08 kg/s
  thrust                    348609.96 N
  Isp                       322.93 s
--------------------------------------------------------

[3] Engine performance:
  Thrust (SL):  348.61 kN (thrust)
... (64 more lines)

Example 8: Research Workflow -- Thermal-Structural Coupled Analysis¶

examples/ex08_research_workflow.py: the full Anvil research workflow

import sys, os
import numpy as np

import anvil
from anvil import Q, System
from anvil.db import fluids, materials

print("=" * 60)
print("  Example 8: Combustion Chamber Wall Design")
print("=" * 60)

# =====================================================
# Step 1: Material selection database lookup
# =====================================================
print("\n[1] Material candidates:")
materials.compare("Copper-C101", "Inconel-718", "Steel-304")

# Select copper for thermal conductivity
mat = materials.get("Copper-C101")
print(f"  Selected: Copper-C101")
print(f"    k = {mat['k']} (high conductivity)")
print(f"    sigma_y = {mat['sigma_y'].to('MPa')}")
print(f"    T_max = {mat['T_max']}")

# =====================================================
# Step 2: Coolant properties
# =====================================================
print(f"\n[2] Coolant: RP-1 (modeled as air-like for demo)")
coolant = fluids.get("air", T=400)  # RP-1 approximation
print(f"  rho = {coolant['rho']}")
print(f"  cp  = {coolant['cp']}")
print(f"  mu  = {coolant['mu']}")

# =====================================================
# Step 3: Build thermal-structural wall system
# =====================================================
print(f"\n[3] Building coupled wall analysis system...")

wall = System("chamber_wall")

# Operating conditions
wall.add("T_gas",      3500,     "K",      desc="Hot gas temperature")
wall.add("h_gas",      5000,     "W",      desc="Gas-side heat transfer coeff")
wall.add("T_coolant",  400,      "K",      desc="Coolant bulk temperature")
wall.add("h_coolant",  15000,    "W",      desc="Coolant-side heat transfer coeff")

# Wall geometry
wall.add("t_wall",     0.003,    "m",      desc="Wall thickness")
wall.add("r_inner",    0.15,     "m",      desc="Chamber inner radius")

# Material (from database)
wall.add("k_wall",     mat["k"].si,           desc="Wall thermal conductivity")
wall.add("E",          mat["E"].si,  "Pa",    desc="Young's modulus")
wall.add("alpha_th",   mat["alpha"].si,        desc="Thermal expansion coeff")
wall.add("nu_poisson", mat["nu_poisson"],      desc="Poisson's ratio")
wall.add("sigma_y",    mat["sigma_y"].si, "Pa", desc="Yield strength")

# Thermal analysis: T_hot -> T_cold through wall
def wall_temperatures(T_gas, h_gas, T_coolant, h_coolant, k_wall, t_wall):
    """Steady-state 1D heat transfer through wall with convection on both sides."""
    # Total thermal resistance per unit area
    R_total = 1/h_gas + t_wall/k_wall + 1/h_coolant
    # Heat flux
    q_flux = (T_gas - T_coolant) / R_total
    # Surface temperatures
    T_hot_wall = T_gas - q_flux / h_gas
    T_cold_wall = T_coolant + q_flux / h_coolant
    T_avg_wall = (T_hot_wall + T_cold_wall) / 2
    return {
        "q_flux": Q(q_flux, "W"),
        "T_hot_wall": Q(T_hot_wall, "K"),
        "T_cold_wall": Q(T_cold_wall, "K"),
        "T_avg_wall": Q(T_avg_wall, "K"),
    }

# Thermal stress
def thermal_stress(T_hot_wall, T_cold_wall, E, alpha_th, nu_poisson):
    """Thermal stress from temperature gradient through wall."""
    dT = T_hot_wall - T_cold_wall
    # Biaxial thermal stress in a constrained plate
    sigma_th = E * alpha_th * dT / (2 * (1 - nu_poisson))
    return {"sigma_thermal": Q(sigma_th, "Pa"), "delta_T_wall": Q(dT, "K")}

# Pressure stress (hoop)
def pressure_stress(P_chamber, r_inner, t_wall):
    sigma_h = P_chamber * r_inner / t_wall
    return {"sigma_hoop": Q(sigma_h, "Pa")}

# Safety factor
def safety_factor(sigma_thermal, sigma_hoop, sigma_y):
    sigma_total = sigma_thermal + sigma_hoop
    SF = sigma_y / sigma_total if sigma_total > 0 else 999
    return {"sigma_total": Q(sigma_total, "Pa"), "safety_factor": SF}

wall.add("P_chamber", 10e6, "Pa", desc="Chamber pressure")
wall.use(wall_temperatures)
wall.use(thermal_stress)
wall.use(pressure_stress)
wall.use(safety_factor)

result = wall.solve_forward()
result.summary(keys=["T_gas", "T_coolant", "t_wall", "P_chamber",
                       "q_flux", "T_hot_wall", "T_cold_wall", "delta_T_wall",
                       "sigma_thermal", "sigma_hoop", "sigma_total", "safety_factor"])

# =====================================================
# Step 4: Sweep over wall thickness
# =====================================================
print(f"\n[4] Sweep: safety factor vs wall thickness...")
sweep = wall.sweep("t_wall", np.linspace(0.001, 0.008, 6))
sweep.summary(outputs=["T_hot_wall", "sigma_thermal", "sigma_hoop",
                          "sigma_total", "safety_factor"])

# =====================================================
# Step 5: Sensitivity analysis
# =====================================================
print(f"\n[5] Sensitivity: what drives safety factor?")
sens = wall.sensitivity(outputs=["safety_factor", "T_hot_wall"])
sens.summary()

print(f"\n  Top 3 drivers of safety factor:")
for inp, val in sens.top("safety_factor", n=3):
    print(f"    {inp}: {val:+.4f}")

# =====================================================
# Step 6: Material comparison
# =====================================================
print(f"\n[6] Material comparison for this wall:")
candidates = ["Copper-C101", "Inconel-718", "Steel-304"]
print(f"  {'Material':20s} {'T_hot(K)':>10s} {'sigma(MPa)':>12s} {'SF':>8s}")
print(f"  {'-'*52}")

for mat_name in candidates:
    m = materials.get(mat_name)
    wall.set(
        k_wall=m["k"].si,
        E=m["E"].si,
        alpha_th=m["alpha"].si,
        nu_poisson=m["nu_poisson"],
        sigma_y=m["sigma_y"].si,
    )
    r = wall.solve_forward()
    sf = r["safety_factor"].si
    thot = r["T_hot_wall"].si
    sig = r["sigma_total"].si / 1e6
    ok = "OK" if sf > 1.5 else "FAIL"
    print(f"  {mat_name:20s} {thot:10.0f} {sig:12.0f} {sf:8.2f}  [{ok}]")

# =====================================================
# Step 7: Export
# =====================================================
print(f"\n[7] Exporting sweep data...")
sweep.to_csv("wall_sweep.csv", outputs=["T_hot_wall", "sigma_total", "safety_factor"])
print(f"  Saved: wall_sweep.csv")
os.remove("wall_sweep.csv")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 8: Combustion Chamber Wall Design
============================================================

[1] Material candidates:

                         Copper-C101     Inconel-718       Steel-304
                    ------------------------------------------------
  E (GPa)                      117.0           200.0           193.0
  Yield (MPa)                     69            1035             215
  UTS (MPa)                      221            1240             505
  Density                     8940.0          8190.0          8000.0
  k (W/mK)                     391.0            11.4            16.2
  T_max (K)                    473.0           973.0          1089.0

  Selected: Copper-C101
    k = 391.00 W/m/K (high conductivity)
    sigma_y = 69.0000 MPa
    T_max = 473.00 K

[2] Coolant: RP-1 (modeled as air-like for demo)
  rho = 0.882444 kg/m^3
  cp  = 1005.00 J/kg/K
  mu  = 2.2852e-05 Pa*s

[3] Building coupled wall analysis system...

--------------------------------------------------------
  chamber_wall -- results
--------------------------------------------------------
  T_gas                     3500.00 K
  T_coolant                 400.00 K
  t_wall                    3.0000e-03 m
  P_chamber                 1.0000e+07 Pa
... (75 more lines)

Example 9: Cantera Combustion + Nozzle Design¶

examples/ex09_cantera_cea.py: A complete rocket engine analysis using Cantera for combustion

import sys, os
import numpy as np

import anvil
from anvil import Q, System, Adapter

# =====================================================
# Require Cantera (real only -- no mock)
# =====================================================
from anvil.adapters import cantera_thermo

if not cantera_thermo.is_available():
    print("  Cantera not installed -- skipping example.")
    print("  Install: conda install -c cantera cantera")
    print("  Or:      pip install cantera")
    raise SystemExit(0)

import cantera as ct
print(f"  Cantera {ct.__version__} found.")

print("=" * 60)
print("  Example 9: Cantera Combustion + Nozzle Design")
print("=" * 60)

from anvil.adapters.cantera_thermo import cea_rocket


# =====================================================
# 1. Direct combustion call
# =====================================================
print("\n[1] H2/O2 combustion at O/F=5, Pc=20 MPa:")
r = cea_rocket(fuel="H2", oxidizer="O2", OF=5.0, Pc=20e6)
print(f"  Tc     = {r['Tc']}")
print(f"  gamma  = {r['gamma_c']:.4f}")
print(f"  R_gas  = {r['R_gas_c']}")
print(f"  c*     = {r['cstar']}")


# =====================================================
# 2. Build full engine system
# =====================================================
print("\n[2] Full H2/O2 engine system:")

engine = System("h2o2_engine")
engine.add("OF",        5.0,          desc="Oxidizer/fuel ratio")
engine.add("Pc",        20e6,  "Pa",  desc="Chamber pressure")
engine.add("A_throat",  0.01,  "m^2", desc="Throat area")
engine.add("A_exit",    0.15,  "m^2", desc="Exit area")
engine.add("P_amb",     0,     "Pa",  desc="Vacuum")

# Combustion -- fix propellant choice, vary OF and Pc
def h2o2_combustion(OF, Pc):
    return cea_rocket(fuel="H2", oxidizer="O2", OF=OF, Pc=Pc)
engine.use(h2o2_combustion)

# Nozzle (from registry)
engine.use("nozzle_area_ratio")
engine.use("area_mach_supersonic", map={"gamma": "gamma_c"})

# Isentropic + exit conditions using combustion products
def exit_analysis(Tc, Pc, gamma_c, R_gas_c, M_exit):
    T0_T = 1 + ((gamma_c - 1) / 2) * M_exit**2
    P0_P = T0_T ** (gamma_c / (gamma_c - 1))
    T_exit = Tc / T0_T
    P_exit = Pc / P0_P
    a_exit = (gamma_c * R_gas_c * T_exit)**0.5
    V_exit = M_exit * a_exit
    return {"T_exit": Q(T_exit, "K"), "P_exit": Q(P_exit, "Pa"),
            "V_exit": Q(V_exit, "m/s")}

def thrust_isp(Pc, A_throat, gamma_c, R_gas_c, Tc, V_exit, P_exit, P_amb, A_exit):
    t = (2 / (gamma_c + 1))**((gamma_c + 1) / (2 * (gamma_c - 1)))
    mdot = Pc * A_throat * (gamma_c / (R_gas_c * Tc))**0.5 * t
    F = mdot * V_exit + (P_exit - P_amb) * A_exit
    Isp = F / (mdot * 9.80665)
    return {"mdot": Q(mdot, "kg/s"), "thrust": Q(F, "N"), "Isp": Q(Isp, "s")}

engine.use(exit_analysis)
engine.use(thrust_isp)

result = engine.solve_forward()
result.summary(keys=["OF", "Pc",
                       "Tc", "gamma_c", "R_gas_c", "cstar",
                       "M_exit", "V_exit", "thrust", "Isp"])

print(f"\n  Performance:")
print(f"    Thrust (vac): {result['thrust'].to('kN')}")
print(f"    Isp (vac):    {result['Isp']}")
print(f"    c*:           {result['cstar']}")


# =====================================================
# 3. O/F ratio sweep
# =====================================================
print("\n[3] Sweep: Isp vs O/F ratio (H2/O2)...")
sweep_of = engine.sweep("OF", np.linspace(3.0, 8.0, 6))
sweep_of.summary(outputs=["Tc", "gamma_c", "cstar", "Isp", "thrust"])


# =====================================================
# 4. Chamber pressure sweep
# =====================================================
print("\n[4] Sweep: Performance vs chamber pressure...")
engine.set(OF=5.0)  # reset to near-optimal
sweep_pc = engine.sweep("Pc", np.linspace(5e6, 30e6, 6))
sweep_pc.summary(outputs=["Tc", "cstar", "thrust", "Isp", "mdot"])


# =====================================================
# 5. Propellant comparison: H2/O2 vs CH4/O2
# =====================================================
print("\n[5] Propellant comparison:")
print(f"  {'Propellant':20s} {'Tc(K)':>8s} {'gamma':>8s} {'Isp(s)':>8s} {'c*(m/s)':>8s}")
print(f"  {'-'*56}")

for fuel_name, ox_name, of_ratio in [
    ("H2",  "O2", 5.0),
    ("CH4", "O2", 3.5),
]:
    # Rebuild engine with different propellant
    eng2 = System(f"{fuel_name}_{ox_name}_engine")
    eng2.add("OF", of_ratio); eng2.add("Pc", 20e6, "Pa")
    eng2.add("A_throat", 0.01, "m^2"); eng2.add("A_exit", 0.15, "m^2")
    eng2.add("P_amb", 0, "Pa")
    def make_comb(f, o):
        def comb(OF, Pc): return cea_rocket(fuel=f, oxidizer=o, OF=OF, Pc=Pc)
        return comb
    eng2.use(make_comb(fuel_name, ox_name))
    eng2.use("nozzle_area_ratio")
    eng2.use("area_mach_supersonic", map={"gamma": "gamma_c"})
    eng2.use(exit_analysis); eng2.use(thrust_isp)
    r = eng2.solve_forward()
    print(f"  {fuel_name + '/' + ox_name:20s} "
          f"{r['Tc']} {r['gamma_c']} "
          f"{r['Isp']} {r['cstar']}")


# =====================================================
# 6. Sensitivity analysis
# =====================================================
print("\n[6] Sensitivity: what drives Isp?")
engine.set(OF=5.0, Pc=20e6)
sens = engine.sensitivity(outputs=["Isp", "thrust"])
sens.summary(outputs=["Isp"])

print(f"\n  Top drivers of Isp:")
for inp, val in sens.top("Isp", n=5):
    print(f"    {inp}: {val:+.4f}")


# =====================================================
# 7. Export for report
# =====================================================
print("\n[7] Exporting data...")
result = engine.solve_forward()
result.to_csv("engine_h2o2.csv")
print("  Saved: engine_h2o2.csv")

sweep_of.to_csv("of_sweep_h2o2.csv", outputs=["Tc", "Isp", "thrust", "cstar"])
print("  Saved: of_sweep_h2o2.csv")

json_str = result.to_json("engine_h2o2.json")
print("  Saved: engine_h2o2.json")

# Show CSV content
print("\n  CSV preview:")
with open("engine_h2o2.csv") as f:
    for line in f.readlines()[:8]:
        print(f"    {line.rstrip()}")

# Cleanup
os.remove("engine_h2o2.csv")
os.remove("of_sweep_h2o2.csv")
os.remove("engine_h2o2.json")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example 10: Chapman-Jouguet Detonation Analysis¶

examples/ex10_detonation.py

import os

import numpy as np


import anvil
from anvil.adapters import nasa_cea_detonation
from anvil.adapters.nasa_cea_detonation import cea_detonation
from anvil import Q, System

print("=" * 60)
print("  Example 10: Chapman-Jouguet Detonation")
print("=" * 60)

if not nasa_cea_detonation.is_available():
    print("  NASA CEA not installed -- skipping example.")
    print("  Install: pip install cea")
    raise SystemExit(0)

# =====================================================
# 1. Direct detonation calls (inputs in SI)
# =====================================================
print("\n[1] CJ detonation for common mixtures (at 1 atm, 300 K):\n")

P_init = Q(1, "atm")  # define pressure with units
T_init = Q(300, "K")

print(
    f"  Initial: P = {P_init} {P_init.unit} ({P_init.to('Pa')}), T = {T_init} {T_init.unit}"
)
print()
print(f"  {'Mixture':25s} {'D_CJ':>10s} {'T_CJ':>10s} {'P2/P1':>8s} {'P_CJ':>10s}")
print(f"  {'-' * 65}")

cases = [
    ("H2/O2", "H2", "O2", 2.0, 1.0, None),
    ("H2/Air", "H2", "O2", 2.0, 1.0, {"N2": 3.76}),
    ("CH4/O2", "CH4", "O2", 1.0, 2.0, None),
    ("C2H4/O2", "C2H4", "O2", 1.0, 3.0, None),
]

for label, fuel, ox, fm, om, extra in cases:
    r = cea_detonation(
        fuel=fuel,
        oxidizer=ox,
        fuel_moles=fm,
        ox_moles=om,
        T1=T_init.si,
        P1=P_init.si,
        extra_species=extra,
    )
    print(
        f"  {label:25s} {r['D_CJ']} {r['T_CJ']} "
        f"{r['P_ratio']:8.1f} {r['P_CJ'].to('atm')}"
    )


# =====================================================
# 2. Anvil System with proper units
# =====================================================
print(f"\n[2] H2/O2 detonation system (all quantities with units):")

det = System("h2o2_detonation")
det.add("fuel_moles", 2.0, desc="Moles of H2")
det.add("ox_moles", 1.0, desc="Moles of O2")
det.add("T1", 300, "K", desc="Initial temperature")
det.add("P1", 1, "atm", desc="Initial pressure")


def h2o2_det(fuel_moles, ox_moles, T1, P1):
    """Wrapper: Anvil passes SI values (K, Pa) which the adapter handles."""
    return cea_detonation(
        fuel="H2", oxidizer="O2", fuel_moles=fuel_moles, ox_moles=ox_moles, T1=T1, P1=P1
    )


det.use(h2o2_det)

result = det.solve_forward()
result.summary(
    keys=[
        "fuel_moles",
        "ox_moles",
        "T1",
        "P1",
        "D_CJ",
        "T_CJ",
        "P_CJ",
        "P_ratio",
        "gamma_CJ",
        "MW_CJ",
        "a_CJ",
    ]
)

# Unit conversions using the engine
print(f"\n  Key results (unit engine conversions):")
print(
    f"    D_CJ  = {result['D_CJ']} {result['D_CJ'].unit}  ({result['D_CJ'].to('km/s')})"
)
print(f"    T_CJ  = {result['T_CJ']} {result['T_CJ'].unit}")
print(
    f"    P_CJ  = {result['P_CJ'].to('atm')}  ({result['P_CJ'].to('bar')})"
)


# =====================================================
# 3. Sweep: initial pressure
# =====================================================
print(f"\n[3] Sweep: D_CJ vs initial pressure...")
sweep_p = det.sweep("P1", np.array([0.5, 1.0, 2.0, 5.0, 10.0, 20.0]))
sweep_p.summary(outputs=["D_CJ", "T_CJ", "P_ratio", "P_CJ"])


# =====================================================
# 4. Sweep: initial temperature
# =====================================================
print(f"\n[4] Sweep: D_CJ vs initial temperature...")
det.set(P1=1)  # reset to 1 atm
sweep_t = det.sweep("T1", np.linspace(250, 600, 5))
sweep_t.summary(outputs=["D_CJ", "T_CJ", "P_ratio", "a_CJ"])


# =====================================================
# 5. Sensitivity analysis
# =====================================================
print(f"\n[5] Sensitivity: what drives D_CJ?")
det.set(T1=300, P1=1)
sens = det.sensitivity(outputs=["D_CJ", "T_CJ"])
sens.summary()


# =====================================================
# 6. COMPOSITION: Detonation system -> PDE nozzle
# =====================================================
print(f"\n[6] Composition: Detonation -> PDE Nozzle")
print(f"    The det system is used as a sub-system feeding the nozzle.\n")

# Build the PDE system that USES the det system via composition
pde = System("pulse_det_engine")

# PDE inputs (same names as det system, so composition inherits them)
pde.add("fuel_moles", 2.0)
pde.add("ox_moles", 1.0)
pde.add("T1", 300, "K", desc="Initial mixture temperature")
pde.add("P1", 600, "psi", desc="Initial mixture pressure")
pde.add("A_throat", 0.005, "m^2", desc="Nozzle throat area")
pde.add("A_exit", 0.05, "m^2", desc="Nozzle exit area")
pde.add("P_amb", 101325, "Pa", desc="Ambient pressure")

# USE the detonation system as a sub-system (composition!)
pde.use(det)

# Nozzle expansion of detonation products
pde.use("nozzle_area_ratio")
pde.use("area_mach_supersonic", map={"gamma": "gamma_CJ"})


def pde_exit(T_CJ, P_CJ, gamma_CJ, MW_CJ, M_exit):
    """Compute nozzle exit conditions from CJ state."""
    R_gas = 8314.46 / (MW_CJ * 1000)  # MW_CJ is in kg/mol
    T0_T = 1 + ((gamma_CJ - 1) / 2) * M_exit**2
    P0_P = T0_T ** (gamma_CJ / (gamma_CJ - 1))
    T_exit = T_CJ / T0_T
    P_exit = P_CJ / P0_P
    V_exit = M_exit * (gamma_CJ * R_gas * T_exit) ** 0.5
    return {
        "T_exit": Q(T_exit, "K"),
        "P_exit": Q(P_exit, "Pa"),
        "V_exit": Q(V_exit, "m/s"),
    }


def pde_performance(
    P_CJ, A_throat, gamma_CJ, MW_CJ, T_CJ, V_exit, P_exit, P_amb, A_exit
):
    """Compute PDE thrust and Isp."""
    R_gas = 8314.46 / (MW_CJ * 1000)
    t = (2 / (gamma_CJ + 1)) ** ((gamma_CJ + 1) / (2 * (gamma_CJ - 1)))
    mdot = P_CJ * A_throat * (gamma_CJ / (R_gas * T_CJ)) ** 0.5 * t
    F = mdot * V_exit + (P_exit - P_amb) * A_exit
    Isp = F / (mdot * 9.80665)
    return {
        "mdot_pde": Q(mdot, "kg/s"),
        "thrust_pde": Q(F, "N"),
        "Isp_pde": Q(Isp, "s"),
    }


pde.use(pde_exit)
pde.use(pde_performance)

r_pde = pde.solve_forward()
r_pde.summary(
    keys=[
        "T1",
        "P1",
        "fuel_moles",
        "ox_moles",
        "D_CJ",
        "T_CJ",
        "P_CJ",
        "M_exit",
        "V_exit",
        "thrust_pde",
        "Isp_pde",
        "mdot_pde",
    ]
)

print(f"\n  PDE performance (unit conversions):")
print(
    f"    Thrust = {r_pde['thrust_pde'].to('kN')} ({r_pde['thrust_pde'].to('lbf')})"
)
print(f"    Isp    = {r_pde['Isp_pde']} {r_pde['Isp_pde'].unit}")
print(f"    V_exit = {r_pde['V_exit'].to('km/s')}")

# Sweep the PDE over initial pressure
print(f"\n  Sweep: PDE performance vs initial pressure...")
sweep_pde = pde.sweep("P1", np.array([0.5, 1.0, 2.0, 5.0, 10.0]))
sweep_pde.summary(outputs=["D_CJ", "T_CJ", "thrust_pde", "Isp_pde"])


# =====================================================
# 7. Export
# =====================================================
print(f"\n[7] Exporting results...")
r_pde.to_csv("pde_results.csv")
print(f"  Saved: pde_results.csv")
sweep_pde.to_csv("pde_pressure_sweep.csv", outputs=["D_CJ", "thrust_pde", "Isp_pde"])
print(f"  Saved: pde_pressure_sweep.csv")

# Cleanup
os.remove("pde_results.csv")
os.remove("pde_pressure_sweep.csv")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example 11: ODE, BVP, and PDE Solvers¶

examples/ex11_ode_solvers.py

import sys, os
import numpy as np


import anvil
from anvil import Q
from anvil import solvers

print("=" * 60)
print("  Example 11: ODE / BVP / PDE Solvers")
print("=" * 60)


# =====================================================
# Part A: Non-stiff ODE, satellite reentry drag
#
# State: [v, h]   v = speed (m/s), h = altitude (m)
# dv/dt = -D/m - g*sin(gamma)     (deceleration)
# dh/dt = -v * sin(gamma)          (altitude loss)
#
# Simplified: constant flight-path angle gamma = 3 deg,
# exponential atmosphere, drag-only deceleration.
# =====================================================
print("\n" + "=" * 40)
print("  Part A: Satellite Reentry (RK45)")
print("=" * 40)

rho0    = 1.225       # kg/m^3, sea-level density
H_scale = 8500.0      # m     , scale height
Cd      = 1.2         # drag coefficient (blunt capsule)
A       = 10.0        # m^2   , cross-section area
m       = 1500.0      # kg    , capsule mass
g       = 9.80665     # m/s^2
gamma   = np.radians(3.0)  # flight-path angle (shallow entry)

def reentry(t, y):
    v, h = y
    h = max(h, 0.0)
    rho = rho0 * np.exp(-h / H_scale)
    D = 0.5 * rho * Cd * A * v**2
    dvdt = -(D / m) - g * np.sin(gamma)
    dhdt = -v * np.sin(gamma)
    return [dvdt, dhdt]

v0 = 7800.0   # m/s, orbital entry speed
h0 = 120e3    # m  , entry altitude 120 km

t_eval = np.linspace(0, 500, 2000)
sol_a = solvers.solve_ode(
    reentry,
    t_span=(0, 500),
    y0=[v0, h0],
    method="RK45",
    t_eval=t_eval,
    rtol=1e-8,
    atol=1e-10,
    verbose=True,
)

v_final = sol_a["y"][0, -1]
h_final = sol_a["y"][1, -1]
t_ground_idx = np.argmin(np.abs(sol_a["y"][1]))  # closest to h=0

print(f"\n  Entry conditions:")
print(f"    v0 = {v0:.0f} m/s,  h0 = {h0/1e3:.0f} km")
print(f"  After {sol_a['t'][-1]:.0f} s:")
print(f"    v  = {v_final:.0f} m/s,  h = {h_final/1e3:.1f} km")
print(f"  Peak deceleration at t ≈ {sol_a['t'][np.gradient(sol_a['y'][0]).argmin()]:.0f} s")
print(f"  ODE solved in {sol_a['nfev']} function evaluations")


# =====================================================
# Part B: Stiff ODE, chemical kinetics (A → B → C)
#
# Classic stiff problem: two reactions with very
# different time constants (τ1 << τ2).
#
# d[A]/dt = -k1 * [A]
# d[B]/dt =  k1 * [A]  -  k2 * [B]
# d[C]/dt =  k2 * [B]
#
# k1 = 1000 s^-1 (fast),  k2 = 0.01 s^-1 (slow)
# =====================================================
print("\n" + "=" * 40)
print("  Part B: Chemical Kinetics A→B→C (BDF)")
print("=" * 40)

k1 = 1000.0   # fast reaction
k2 = 0.01     # slow reaction

def kinetics(t, y):
    A, B, C = y
    dA = -k1 * A
    dB =  k1 * A - k2 * B
    dC =  k2 * B
    return [dA, dB, dC]

t_end = 300.0   # s, watch the slow reaction complete

sol_b = solvers.solve_ode_stiff(
    kinetics,
    t_span=(0, t_end),
    y0=[1.0, 0.0, 0.0],    # all species A initially
    method="BDF",
    t_eval=np.linspace(0, t_end, 500),
    rtol=1e-6,
    atol=1e-10,
    verbose=True,
)

A_f, B_f, C_f = sol_b["y"][:, -1]
print(f"\n  Rate constants: k1 = {k1} s⁻¹ (fast),  k2 = {k2} s⁻¹ (slow)")
print(f"  At t = {t_end:.0f} s:")
print(f"    [A] = {A_f:.6f}   (consumed by fast reaction)")
print(f"    [B] = {B_f:.6f}   (intermediate)")
print(f"    [C] = {C_f:.6f}   (product of slow reaction)")
print(f"    Sum = {A_f+B_f+C_f:.8f}  (should be 1.0, mass conservation)")
print(f"  Solved in {sol_b['nfev']} rhs evaluations")

# Compare: would RK45 fail on this stiff system?
print(f"\n  Note: RK45 step-size constraint ≈ 1/k1 = {1/k1:.1e} s")
print(f"  BDF adapts automatically, no user tuning needed.")


# =====================================================
# Part C: Boundary Value Problem, fin temperature
#
# Extended surface (fin) with tip insulated:
#   d²T/dx² - m² * (T - T_inf) = 0
#
#   BC: T(0) = T_base  (fin base temperature)
#       dT/dx|_{x=L} = 0  (insulated tip)
#
# Solution: T(x) = T_inf + (T_base - T_inf) * cosh(m*(L-x)) / cosh(m*L)
# =====================================================
print("\n" + "=" * 40)
print("  Part C: Fin Temperature (BVP)")
print("=" * 40)

T_base = 400.0    # K, fin base
T_inf  = 300.0    # K, ambient
h_conv = 50.0     # W/m^2/K, convection coefficient
k_fin  = 200.0    # W/m/K, aluminum
t_fin  = 0.002    # m, fin thickness
L_fin  = 0.1      # m, fin length (10 cm)

P_perim = 2 * (t_fin + 0.05)   # m, perimeter (assume 5 cm width)
A_cs    = t_fin * 0.05          # m^2, cross section
m_fin   = np.sqrt(h_conv * P_perim / (k_fin * A_cs))

print(f"\n  Fin: L={L_fin*100:.0f} cm,  t={t_fin*1000:.0f} mm,  k={k_fin} W/m/K")
print(f"  h_conv = {h_conv} W/m²/K,  m = {m_fin:.2f} m⁻¹")

def fin_ode(x, y):
    # y[0] = T - T_inf,  y[1] = dT/dx
    return np.vstack([y[1], m_fin**2 * y[0]])

def fin_bc(ya, yb):
    # ya[0] = T_base - T_inf (at x=0)
    # yb[1] = 0             (insulated tip)
    return np.array([ya[0] - (T_base - T_inf), yb[1]])

x_init = np.linspace(0, L_fin, 8)
theta0 = (T_base - T_inf) * np.cosh(m_fin * (L_fin - x_init)) / np.cosh(m_fin * L_fin)
y_init = np.zeros((2, x_init.size))
y_init[0] = theta0
y_init[1] = -m_fin * (T_base - T_inf) * np.sinh(m_fin * (L_fin - x_init)) / np.cosh(m_fin * L_fin)

sol_c = solvers.solve_bvp(
    fin_ode,
    fin_bc,
    x=x_init,
    y_init=y_init,
    tol=1e-6,
    verbose=False,
)

x_fine = np.linspace(0, L_fin, 50)
T_numerical = T_inf + sol_c["sol"](x_fine)[0]
T_analytical = T_inf + (T_base - T_inf) * np.cosh(m_fin * (L_fin - x_fine)) / np.cosh(m_fin * L_fin)
max_err = np.max(np.abs(T_numerical - T_analytical))

T_tip_num = T_inf + sol_c["y"][0, -1]
T_tip_ana = T_inf + (T_base - T_inf) / np.cosh(m_fin * L_fin)

print(f"\n  Tip temperature (numerical):  {T_tip_num:.3f} K")
print(f"  Tip temperature (analytical): {T_tip_ana:.3f} K")
print(f"  Max error vs analytical:      {max_err:.2e} K")

# Fin efficiency
Q_actual  = k_fin * A_cs * m_fin * (T_base - T_inf) * np.tanh(m_fin * L_fin)
Q_max     = h_conv * P_perim * L_fin * (T_base - T_inf)
eta_fin   = Q_actual / Q_max
print(f"\n  Heat removed: {Q_actual:.1f} W")
print(f"  Fin efficiency: {eta_fin:.3f}  ({eta_fin*100:.1f}%)")


# =====================================================
# Part D: 1D Heat Equation (PDE), wall thermal soak
#
# Steel wall initially at T_amb. One face suddenly
# exposed to high-temperature gas (step input).
# Track temperature history through the wall.
#
# ∂T/∂t = α ∂²T/∂x²
# BC: T(0,t) = T_gas (hot face)
#     T(L,t) = T_amb (cold face, heat sink)
# IC: T(x,0) = T_amb
# =====================================================
print("\n" + "=" * 40)
print("  Part D: Wall Thermal Soak (1D PDE)")
print("=" * 40)

T_gas   = 1200.0    # K, gas temperature (step input)
T_amb   = 300.0     # K, initial wall / cold-face temperature
L_wall  = 0.025     # m, 25 mm steel wall
alpha   = 1.2e-5    # m^2/s, thermal diffusivity of steel
rho_cp  = 3.9e6     # J/m^3/K, volumetric heat capacity (for Q calc)

print(f"\n  Wall: L={L_wall*1000:.0f} mm,  α={alpha:.2e} m²/s")
print(f"  Step from T_amb={T_amb} K to T_gas={T_gas} K on hot face")
print(f"  Fourier number at t=60s: Fo = α·t/L² = {alpha*60/L_wall**2:.2f}")

sol_d = solvers.solve_pde_heat_1d(
    alpha=alpha,
    x_span=(0, L_wall),
    t_span=(0, 120),
    u_init=lambda x: np.full_like(x, T_amb),
    bc_left=T_gas,
    bc_right=T_amb,
    nx=80,
    verbose=True,
)

x_wall = sol_d["x"]
t_pde  = sol_d["t"]
T_pde  = sol_d["u"]

# Print temperature profile at several time snapshots
print(f"\n  Temperature profile through wall at key times (K):")
print(f"  {'x(mm)':>6s}", end="")
for t_snap in [5, 15, 30, 60, 120]:
    print(f"  {t_snap:>6.0f}s", end="")
print()

for xi in [0.0, 0.005, 0.010, 0.015, 0.020, 0.025]:
    ix = np.argmin(np.abs(x_wall - xi))
    print(f"  {xi*1000:>6.1f}", end="")
    for t_snap in [5, 15, 30, 60, 120]:
        it = np.argmin(np.abs(t_pde - t_snap))
        print(f"  {T_pde[it, ix]:>6.0f}", end="")
    print()

# Time to reach 500 K at mid-wall
ix_mid = len(x_wall) // 2
T_mid  = T_pde[:, ix_mid]
i_500  = np.argmax(T_mid >= 500.0)
if i_500 > 0:
    print(f"\n  Time for mid-wall to reach 500 K: {t_pde[i_500]:.1f} s")

# Heat flux at hot face (Fourier's law, approximate)
dTdx_hot = (T_pde[-1, 1] - T_pde[-1, 0]) / sol_d["dx"]
k_steel   = alpha * rho_cp
q_flux    = -k_steel * dTdx_hot
print(f"  Heat flux at hot face (t=120s): {q_flux/1000:.1f} kW/m²")


# =====================================================
# Part E: Use the dense ODE output (callable solution)
# =====================================================
print("\n" + "=" * 40)
print("  Part E: Dense ODE Output")
print("=" * 40)

# The sol object from solve_ode is callable: sol(t) → y(t)
dense_sol = sol_b["sol"]   # from the kinetics problem
t_query = np.array([0.001, 0.01, 0.1, 1.0, 10.0, 100.0])
y_query = dense_sol.sol(t_query)

print(f"\n  Kinetics concentrations at arbitrary t (dense output):")
print(f"  {'t(s)':>10s}  {'[A]':>12s}  {'[B]':>12s}  {'[C]':>12s}")
for i, t in enumerate(t_query):
    print(f"  {t:>10.3f}  {y_query[0, i]:>12.6f}  {y_query[1, i]:>12.6f}  {y_query[2, i]:>12.6f}")


print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 11: ODE / BVP / PDE Solvers
============================================================

========================================
  Part A: Satellite Reentry (RK45)
========================================
  ODE t = 5.0669e+01  (y[0] = 7.7271e+03)
  ODE t = 1.0359e+02  (y[0] = 7.1720e+03)
  ODE t = 1.5559e+02  (y[0] = 4.5060e+03)
  ODE t = 2.0573e+02  (y[0] = 1.9231e+03)
  ODE t = 2.5625e+02  (y[0] = 9.2363e+02)
  ODE t = 3.0707e+02  (y[0] = 5.3196e+02)
  ODE t = 3.5733e+02  (y[0] = 3.4562e+02)
  ODE t = 4.0806e+02  (y[0] = 2.3836e+02)
  ODE t = 4.6286e+02  (y[0] = 1.6480e+02)
  ODE converged: 446 function evaluations, t_final = 5.0000e+02

  Entry conditions:
    v0 = 7800 m/s,  h0 = 120 km
  After 500 s:
    v  = 128 m/s,  h = 45.9 km
  Peak deceleration at t ≈ 153 s
  ODE solved in 446 function evaluations

========================================
  Part B: Chemical Kinetics A→B→C (BDF)
========================================
  ODE(stiff) t = 3.1508e+01  (y[0] = -6.6611e-18)
  ODE(stiff) t = 6.8148e+01  (y[0] = -4.2482e-20)
  ODE(stiff) t = 1.0638e+02  (y[0] = -3.4203e-22)
  ODE(stiff) t = 1.4043e+02  (y[0] = 1.2952e-24)
  ODE(stiff) t = 1.7258e+02  (y[0] = 4.8805e-27)
  ODE(stiff) t = 2.0378e+02  (y[0] = -6.0653e-29)
... (77 more lines)

Example 12: Project Registry¶

examples/ex12_project_registry.py

import sys, os, tempfile
import numpy as np


import anvil
from anvil import Q, System

# Use a temp directory so the example is self-contained
_tmp = tempfile.mkdtemp(prefix="anvil_ex12_")

print("=" * 60)
print("  Example 12: Project Registry")
print("=" * 60)


# =====================================================
# 1. Open a project store
# =====================================================
print("\n[1] Opening project store for 'hx_correlations'...")

proj = anvil.project("hx_correlations", path=_tmp)
print(f"  Repr: {proj}")


# =====================================================
# 2. Push draft RSQs to the project
# =====================================================
print("\n[2] Registering draft correlations...")

def ntu_crossflow(UA, C_min, C_max):
    """NTU for cross-flow heat exchanger (both fluids unmixed)."""
    NTU = UA / C_min
    C_r = C_min / C_max
    # Kays & London correlation
    eps = 1 - np.exp((NTU**0.22 / C_r) * (np.exp(-C_r * NTU**0.78) - 1))
    return {"NTU_cf": NTU, "effectiveness_cf": eps, "C_ratio": C_r}

def shell_tube_ntu(UA, mdot_shell, mdot_tube, Cp_shell, Cp_tube):
    """NTU and effectiveness for 1-shell-pass 2-tube-pass (TEMA E)."""
    C_shell = mdot_shell * Cp_shell
    C_tube  = mdot_tube  * Cp_tube
    C_min   = min(C_shell, C_tube)
    C_max   = max(C_shell, C_tube)
    NTU     = UA / C_min
    C_r     = C_min / C_max
    # Shah & Sekulic formula for 1-2 shell-and-tube
    if C_r < 1.0:
        sqrt_term = np.sqrt(1 + C_r**2)
        eps = 2 / (1 + C_r + sqrt_term * (1 + np.exp(-NTU * sqrt_term)) / (1 - np.exp(-NTU * sqrt_term)))
    else:
        eps = NTU / (1 + NTU)   # limit for C_r → 1
    return {
        "NTU_st": NTU,
        "effectiveness_st": eps,
        "C_min_st": Q(C_min, "W/K"),
        "C_max_st": Q(C_max, "W/K"),
    }

def log_mean_temp(T_hot_in, T_hot_out, T_cold_in, T_cold_out):
    """Log Mean Temperature Difference for counter-flow arrangement."""
    dT1 = T_hot_in  - T_cold_out
    dT2 = T_hot_out - T_cold_in
    if abs(dT1 - dT2) < 1e-6:
        LMTD = dT1
    else:
        LMTD = (dT1 - dT2) / np.log(dT1 / max(dT2, 1e-6))
    return {"LMTD": Q(LMTD, "K")}

proj.push(ntu_crossflow,   domain="heat_transfer", description="Cross-flow NTU (Kays & London)")
proj.push(shell_tube_ntu,  domain="heat_transfer", description="1-2 shell-and-tube NTU (Shah & Sekulic)")
proj.push(log_mean_temp,   domain="heat_transfer", description="Log Mean Temperature Difference")

proj.list()


# =====================================================
# 3. Use project RSQs directly
# =====================================================
print("\n[3] Direct calls via proj.R.*")

r_cf = proj.R.ntu_crossflow(UA=3500, C_min=1800, C_max=2400)
print(f"\n  Cross-flow HX (UA=3500, C_min=1800):")
print(f"    NTU         = {r_cf['NTU_cf']:.3f}")
print(f"    C_ratio     = {r_cf['C_ratio']:.3f}")
print(f"    effectiveness = {r_cf['effectiveness_cf']:.4f}")

r_st = proj.R.shell_tube_ntu(UA=5000, mdot_shell=2.0, mdot_tube=1.5,
                               Cp_shell=4186, Cp_tube=1005)
print(f"\n  Shell-and-tube HX (UA=5000):")
print(f"    NTU         = {r_st['NTU_st']:.3f}")
print(f"    effectiveness = {r_st['effectiveness_st']:.4f}")


# =====================================================
# 4. Build a System using project RSQs
# =====================================================
print("\n[4] System using project RSQ for outlet temperature calculation...")

def outlet_temps_from_eff(effectiveness_cf, C_min, C_hot_in, T_hot_in, T_cold_in, C_hot, C_cold):
    Q_actual = effectiveness_cf * C_min * (T_hot_in - T_cold_in)
    T_hot_out  = T_hot_in  - Q_actual / C_hot
    T_cold_out = T_cold_in + Q_actual / C_cold
    return {
        "Q_actual": Q(Q_actual, "W"),
        "T_hot_out":  Q(T_hot_out,  "K"),
        "T_cold_out": Q(T_cold_out, "K"),
    }

hx = System("crossflow_hx")
hx.add("T_hot_in",   450,   "K")
hx.add("T_cold_in",  290,   "K")
hx.add("mdot_hot",   1.2,   "kg/s")
hx.add("mdot_cold",  2.0,   "kg/s")
hx.add("Cp_hot",    1050,   "J/kg/K")
hx.add("Cp_cold",   4186,   "J/kg/K")
hx.add("UA",        3500,   "W/K")

def compute_capacity_rates(mdot_hot, Cp_hot, mdot_cold, Cp_cold):
    C_hot  = mdot_hot  * Cp_hot
    C_cold = mdot_cold * Cp_cold
    C_min  = min(C_hot, C_cold)
    C_max  = max(C_hot, C_cold)
    C_hot_in = C_hot   # pass through for outlet_temps
    return {"C_hot": Q(C_hot, "W/K"), "C_cold": Q(C_cold, "W/K"),
            "C_min": Q(C_min, "W/K"), "C_max": Q(C_max, "W/K"),
            "C_hot_in": Q(C_hot, "W/K")}

hx.use(compute_capacity_rates)
hx.use(proj.R.ntu_crossflow)     # project RSQ used directly in System
hx.use(outlet_temps_from_eff)

result = hx.solve_forward()
result.summary(keys=["T_hot_in", "T_cold_in", "UA",
                      "NTU_cf", "effectiveness_cf",
                      "T_hot_out", "T_cold_out", "Q_actual"])


# =====================================================
# 5. Context manager, route anvil.push() to project
# =====================================================
print("\n[5] Context manager: push drafts inside 'with' block...")

proj2 = anvil.project("fouling_study", path=_tmp)

with proj2:
    @anvil.relation(domain="heat_transfer", register=False)
    def fouling_resistance(mdot, rho_fluid, mu_fluid, D_tube, L_tube, k_fluid):
        """Estimate fouling resistance from Dittus-Boelter Nu and fouling factor."""
        V = mdot / (rho_fluid * np.pi * (D_tube / 2)**2)
        Re = rho_fluid * V * D_tube / mu_fluid
        Pr = mu_fluid * 4186 / k_fluid   # approximate Prandtl
        Nu = 0.023 * Re**0.8 * Pr**0.4  # Dittus-Boelter
        h = Nu * k_fluid / D_tube
        Rf = 0.0002    # typical fouling resistance (m^2·K/W)
        U_fouled = 1.0 / (1.0 / h + Rf)
        A_tube = np.pi * D_tube * L_tube
        return {"Re_tube": Re, "Nu_tube": Nu, "h_tube": Q(h, "W/m^2/K"),
                "U_fouled": Q(U_fouled, "W/m^2/K"), "UA_fouled": Q(U_fouled * A_tube, "W/K")}

    proj2.push(fouling_resistance)

# Outside the with block, context no longer active
proj2.list()

r_foul = proj2.R.fouling_resistance(
    mdot=0.5, rho_fluid=1000, mu_fluid=0.001,
    D_tube=0.02, L_tube=2.0, k_fluid=0.6
)
print(f"\n  Fouling study (D={20}mm, L=2m):")
print(f"    Re      = {r_foul['Re_tube']:.0f}")
print(f"    Nu      = {r_foul['Nu_tube']:.0f}")
print(f"    h       = {r_foul['h_tube']}")
print(f"    UA_foul = {r_foul['UA_fouled']}")


# =====================================================
# 6. Search within project
# =====================================================
print("\n[6] Searching project for 'NTU'...")
proj.search("NTU")

print("\n  Searching for 'effectiveness'...")
proj.search("effectiveness")


# =====================================================
# 7. Promote a tested RSQ to global registry
# =====================================================
print("\n[7] Promoting 'log_mean_temp' to global registry...")

# Verify it isn't already global
existing = anvil.registry.search("log_mean_temp")
if not existing:
    proj.promote("log_mean_temp")
    print("  Verifying it's in global registry:")
    anvil.registry.search("log_mean_temp")

    # Use via global namespace
    r_lmtd = anvil.R.log_mean_temp(
        T_hot_in=result["T_hot_in"].si,
        T_hot_out=result["T_hot_out"].si,
        T_cold_in=result["T_cold_in"].si,
        T_cold_out=result["T_cold_out"].si,
    )
    print(f"\n  LMTD via global registry: {r_lmtd['LMTD']}")

    # Clean up global registry
    anvil.registry.remove("log_mean_temp")
    print("  Cleaned up: removed 'log_mean_temp' from global registry.")
else:
    print("  (already in global registry)")


# =====================================================
# 8. Two projects open simultaneously
# =====================================================
print("\n[8] Two projects open simultaneously (no conflict)...")

proj_a = anvil.project("project_A", path=_tmp)
proj_b = anvil.project("project_B", path=_tmp)

def my_rsq_v1(x, k=1.0):
    return {"y_v1": k * x}

def my_rsq_v2(x, k=1.2):
    return {"y_v2": k * x + 0.5}

proj_a.push(my_rsq_v1, domain="test")
proj_b.push(my_rsq_v2, domain="test")

ra = proj_a.R.my_rsq_v1(x=5.0)
rb = proj_b.R.my_rsq_v2(x=5.0)
print(f"\n  Project A, my_rsq_v1(5): y = {ra['y_v1']}")
print(f"  Project B, my_rsq_v2(5): y = {rb['y_v2']}")
print(f"  Global registry: unaffected (no 'my_rsq_v1' or 'my_rsq_v2' there)")


# =====================================================
# Cleanup temp directory
# =====================================================
import shutil
shutil.rmtree(_tmp, ignore_errors=True)

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 12: Project Registry
============================================================

[1] Opening project store for 'hx_correlations'...
  Project 'hx_correlations' opened  (C:\Users\rc\AppData\Local\Temp\anvil_ex12_ft5adu2u\.anvil\project_hx_correlations.db)
  Repr: <Project 'hx_correlations': 0 RSQs at C:\Users\rc\AppData\Local\Temp\anvil_ex12_ft5adu2u>

[2] Registering draft correlations...
  [hx_correlations] Registered 'ntu_crossflow' (R) in domain 'heat_transfer'.
  [hx_correlations] Registered 'shell_tube_ntu' (R) in domain 'heat_transfer'.
  [hx_correlations] Registered 'log_mean_temp' (R) in domain 'heat_transfer'.

  Project: hx_correlations  (C:\Users\rc\AppData\Local\Temp\anvil_ex12_ft5adu2u)

  Relations (3):
    log_mean_temp                   [heat_transfer]
      Log Mean Temperature Difference
    ntu_crossflow                   [heat_transfer]
      Cross-flow NTU (Kays & London)
    shell_tube_ntu                  [heat_transfer]
      1-2 shell-and-tube NTU (Shah & Sekulic)

  Total: 3 RSQs

[3] Direct calls via proj.R.*

  Cross-flow HX (UA=3500, C_min=1800):
    NTU         = 1.944
    C_ratio     = 0.750
    effectiveness = 0.6690

  Shell-and-tube HX (UA=5000):
    NTU         = 3.317
... (71 more lines)

Example 13: Control Systems Analysis¶

examples/ex13_controls_analysis.py

import sys, os

# Windows consoles default to cp1252; this output uses Greek symbols.
if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import numpy as np


import anvil
from anvil import Q, System
from anvil import solvers

print("=" * 60)
print("  Example 13: Control Systems Analysis")
print("=" * 60)


# =====================================================
# 1. Plant model: second-order system
#
# G(s) = K_plant / (τ^2 s^2 + 2ζτ s + 1)
#
# Natural frequency ωn = 1/τ = 2 rad/s
# Open-loop damping   ζ_ol = 0.1 (lightly damped)
# DC gain             K_plant = 1.0
# =====================================================
print("\n[1] Plant: Second-order system")

omega_n_plant = 2.0    # rad/s, natural frequency
zeta_plant    = 0.1    #, open-loop damping (lightly damped)
K_plant       = 1.0    #, DC gain

print(f"\n  ωn = {omega_n_plant} rad/s,  ζ_ol = {zeta_plant},  K = {K_plant}")
print(f"  Open-loop step response characteristics:")

r_ol = anvil.R.second_order_metrics(omega_n=omega_n_plant, zeta=zeta_plant)
print(f"    Overshoot:  {r_ol['overshoot_pct']}%")
print(f"    t_settle:   {r_ol['t_settle']}  (2% criterion)")
print(f"    t_rise:     {r_ol['t_rise']}")
print(f"    t_peak:     {r_ol['t_peak']}")
print(f"    ωd:         {r_ol['omega_d']}")


# =====================================================
# 2. Ziegler-Nichols PID tuning
#
# Find the ultimate gain Ku by increasing proportional
# gain until sustained oscillations. Here we use a
# known value for the second-order plant.
# =====================================================
print("\n[2] Ziegler-Nichols PID Tuning")

# For this plant, ultimate gain and period are known analytically:
# Ku = (2*zeta*omega_n)^2 / (omega_n^2 * K_plant) * ... (simplified here)
# Using rule-of-thumb values for demonstration:
Ku = 12.0    # ultimate gain (proportional only, at onset of oscillation)
Tu = 2.2     # s, ultimate period

print(f"\n  Ultimate gain Ku = {Ku},  Ultimate period Tu = {Tu} s")
print(f"\n  Ziegler-Nichols tuning methods:")

for method in ["classic", "no_overshoot", "some_overshoot"]:
    r_zn = anvil.R.ziegler_nichols_pid(Ku=Ku, Tu=Tu, method=method)
    Kp = r_zn["Kp"].si if hasattr(r_zn["Kp"], "si") else r_zn["Kp"]
    Ti = r_zn["Ti"].si if hasattr(r_zn["Ti"], "si") else r_zn["Ti"]
    Td = r_zn["Td"].si if hasattr(r_zn["Td"], "si") else r_zn["Td"]
    print(f"  [{method:>15s}]  Kp={Kp:.3f}  Ti={Ti:.3f}s  Td={Td:.4f}s")

# Use classic Z-N as starting point
r_zn = anvil.R.ziegler_nichols_pid(Ku=Ku, Tu=Tu, method="classic")
def _v(x):
    return float(x.si) if hasattr(x, "si") else float(x)
Kp_zn = _v(r_zn["Kp"])
Ki_zn = _v(r_zn["Ki"])
Kd_zn = _v(r_zn["Kd"])


# =====================================================
# 3. Closed-loop step response via ODE simulation
#
# Plant: d²y/dt² + 2ζωn dy/dt + ωn² y = ωn² K_plant u
# PID:   u = Kp*e + Ki∫e dt + Kd*de/dt
# =====================================================
print("\n[3] Closed-loop step response simulation (PID)")

def closed_loop_ode(t, state, Kp, Ki, Kd, ref=1.0):
    """
    State: [y, dy_dt, integral_e]
    Plant: second-order + PID feedback
    """
    y, dydt, int_e = state
    e     = ref - y
    de_dt = -dydt   # de/dt = d(ref-y)/dt = -dy/dt (constant ref)
    u     = Kp * e + Ki * int_e + Kd * de_dt
    # Plant: d²y/dt² = ωn²(K_plant * u - y) - 2ζωn * dy/dt
    d2ydt2 = omega_n_plant**2 * (K_plant * u - y) - 2 * zeta_plant * omega_n_plant * dydt
    return [dydt, d2ydt2, e]

t_sim = np.linspace(0, 8, 1000)

# Open-loop step (Kp=1, Ki=0, Kd=0)
sol_ol = solvers.solve_ode(
    lambda t, s: closed_loop_ode(t, s, Kp=1.0, Ki=0.0, Kd=0.0),
    t_span=(0, 8), y0=[0.0, 0.0, 0.0], t_eval=t_sim, rtol=1e-8
)

# Z-N tuned PID
sol_zn = solvers.solve_ode(
    lambda t, s: closed_loop_ode(t, s, Kp=Kp_zn, Ki=Ki_zn, Kd=Kd_zn),
    t_span=(0, 8), y0=[0.0, 0.0, 0.0], t_eval=t_sim, rtol=1e-8
)

y_ol = sol_ol["y"][0]
y_zn = sol_zn["y"][0]

# Measure step response metrics
def step_metrics(t, y, ref=1.0, band=0.02):
    OS_pct = (y.max() - ref) / ref * 100 if y.max() > ref else 0.0
    settled = np.where(np.abs(y - ref) <= band * ref)[0]
    t_settle = t[settled[0]] if len(settled) else float("inf")
    above_half = np.where(y >= 0.5 * ref)[0]
    t_rise = t[above_half[0]] if len(above_half) else float("inf")
    return OS_pct, t_settle, t_rise

os_ol, ts_ol, tr_ol = step_metrics(t_sim, y_ol)
os_zn, ts_zn, tr_zn = step_metrics(t_sim, y_zn)

print(f"\n  Step response summary (unit step, 2% band):")
print(f"  {'Controller':>16s}  {'OS%':>6s}  {'t_settle(s)':>12s}  {'t_rise(s)':>10s}")
print(f"  {'P only (K=1)':>16s}  {os_ol:>6.1f}  {ts_ol:>12.3f}  {tr_ol:>10.3f}")
print(f"  {'Z-N PID':>16s}  {os_zn:>6.1f}  {ts_zn:>12.3f}  {tr_zn:>10.3f}")


# =====================================================
# 4. PID output RSQ, compute instantaneous control action
# =====================================================
print("\n[4] PID output RSQ")

pid_sys = System("pid_controller")
pid_sys.add("error",            0.35,     desc="Tracking error (rad)")
pid_sys.add("integral_error",   0.12,     desc="Integral of error (rad·s)")
pid_sys.add("derivative_error", -0.08,    desc="Derivative of error (rad/s)")
pid_sys.add("Kp",               Kp_zn,    desc="Proportional gain")
pid_sys.add("Ki",               Ki_zn,    desc="Integral gain")
pid_sys.add("Kd",               Kd_zn,    desc="Derivative gain")
pid_sys.use("pid_output")

r_pid = pid_sys.solve_forward()
result_u = r_pid["u_pid"].si if hasattr(r_pid["u_pid"], "si") else r_pid["u_pid"]
print(f"\n  Error = 0.35,  Integral = 0.12,  Derivative = -0.08")
print(f"  Z-N PID: Kp={Kp_zn:.3f},  Ki={Ki_zn:.3f},  Kd={Kd_zn:.4f}")
print(f"  Control action u = {result_u:.4f}")


# =====================================================
# 5. Stability check, Routh-Hurwitz (2nd order)
# =====================================================
print("\n[5] Routh-Hurwitz Stability Check")

print(f"\n  Characteristic polynomial: τ²s² + 2ζτs + 1  (2nd order)")
print(f"  Coefficients: a1 = 2ζ/ωn,  a0 = 1/ωn²")

test_cases = [
    ("Open-loop (ζ=0.1)",   2*0.1/omega_n_plant, 1/omega_n_plant**2),
    ("Negative damping",    -0.5,                  1.0),
    ("Unstable (a0<0)",      1.0,                 -1.0),
    ("Critically damped",    2/omega_n_plant,       1/omega_n_plant**2),
]

print(f"\n  {'Case':30s}  {'a1':>6s}  {'a0':>8s}  {'Stable?':>8s}")
print(f"  {'-'*60}")
for name, a1, a0 in test_cases:
    r_rh = anvil.R.routh_hurwitz_2nd(a1=a1, a0=a0)
    stable = r_rh["stable"]
    stable_v = stable if isinstance(stable, bool) else bool(stable)
    print(f"  {name:30s}  {a1:>6.3f}  {a0:>8.5f}  {'YES' if stable_v else 'NO':>8s}")


# =====================================================
# 6. Second-order metrics sweep, ωn and ζ trade study
# =====================================================
print("\n[6] Second-order metrics sweep, ζ trade study")

metrics_sys = System("step_response_design")
metrics_sys.add("omega_n", 5.0)   # rad/s, closed-loop natural frequency
metrics_sys.add("zeta",    0.7)   # damping ratio
metrics_sys.use("second_order_metrics")

print(f"\n  Sweep ζ at ωn = 5 rad/s:")
print(f"  {'ζ':>6s}  {'OS%':>8s}  {'t_settle(s)':>12s}  {'t_rise(s)':>10s}  {'BW(Hz)':>8s}")
print(f"  {'-'*52}")

for zeta in [0.3, 0.5, 0.7, 1.0, 1.5]:
    metrics_sys.set(zeta=zeta)
    r = metrics_sys.solve_forward()
    def _v(x): return float(x.si) if hasattr(x, "si") else float(x)
    print(f"  {zeta:>6.2f}  {_v(r['overshoot_pct']):>8.1f}  "
          f"{_v(r['t_settle']):>12.4f}  {_v(r['t_rise']):>10.4f}  "
          f"{_v(r['bandwidth_Hz']) if 'bandwidth_Hz' in r else 0.0:>8.3f}")

sweep_zeta = metrics_sys.sweep("zeta", np.linspace(0.2, 2.0, 10))
sweep_zeta.summary(outputs=["overshoot_pct", "t_settle", "t_rise", "omega_d"])

print(f"\n  Design choice: ζ = 0.7 balances OS% and t_settle (classic choice).")


# =====================================================
# 7. First-order step response RSQ
# =====================================================
print("\n[7] First-order step response metrics")

fo_sys = System("first_order_control")
fo_sys.add("K",        2.0,   desc="DC gain")
fo_sys.add("tau",      0.5,   desc="Time constant (s)")
fo_sys.use("first_order_step")

r_fo = fo_sys.solve_forward()
def _v(x): return float(x.si) if hasattr(x, "si") else float(x)
print(f"\n  Plant: K={2.0}, τ={0.5}s")
print(f"    Settling time (2%): {_v(r_fo['t_settle']):.3f} s")
print(f"    Rise time:          {_v(r_fo['t_rise']):.3f} s")
print(f"    Bandwidth:          {_v(r_fo['bandwidth_Hz']):.3f} Hz")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 13: Control Systems Analysis
============================================================

[1] Plant: Second-order system

  ωn = 2.0 rad/s,  ζ_ol = 0.1,  K = 1.0
  Open-loop step response characteristics:
    Overshoot:  72.9247614287671%
    t_settle:   20.0  (2% criterion)
    t_rise:     0.49375
    t_peak:     1.5787097084991382
    ωd:         1.98997487421324

[2] Ziegler-Nichols PID Tuning

  Ultimate gain Ku = 12.0,  Ultimate period Tu = 2.2 s

  Ziegler-Nichols tuning methods:
  [        classic]  Kp=7.200  Ti=1.100s  Td=0.2750s
  [   no_overshoot]  Kp=7.200  Ti=1.100s  Td=0.2750s
  [ some_overshoot]  Kp=7.200  Ti=1.100s  Td=0.2750s

[3] Closed-loop step response simulation (PID)

  Step response summary (unit step, 2% band):
        Controller     OS%   t_settle(s)   t_rise(s)
      P only (K=1)     0.0           inf       0.585
           Z-N PID    12.1         0.529       0.264

[4] PID output RSQ

  Error = 0.35,  Integral = 0.12,  Derivative = -0.08
  Z-N PID: Kp=7.200,  Ki=6.545,  Kd=1.9800
... (54 more lines)

Example 14: Materials, Fatigue, Fracture, and Composites¶

examples/ex14_materials_fatigue.py

import sys, os

# Windows consoles default to cp1252; this output uses Greek symbols.
if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import numpy as np


import anvil
from anvil import Q, System

print("=" * 60)
print("  Example 14: Materials, Fatigue, Fracture, Composites")
print("=" * 60)


# =====================================================
# Material: 300M High-Strength Steel (typical turbopump shaft)
# =====================================================
E_steel       = 207e9      # Pa, Young's modulus
sigma_y       = 1700e6     # Pa, 0.2% yield strength
sigma_uts     = 1950e6     # Pa, ultimate tensile strength
sigma_f_prime = 2100e6     # Pa, fatigue strength coefficient (Basquin)
b_exp         = -0.07      #,   Basquin exponent (typical for high-strength steel)
KIc           = 70e6       # Pa√m, plane strain fracture toughness

print(f"\n  Material: 300M Steel")
print(f"    E       = {E_steel/1e9:.0f} GPa")
print(f"    σ_y     = {sigma_y/1e6:.0f} MPa")
print(f"    σ_UTS   = {sigma_uts/1e6:.0f} MPa")
print(f"    σ_f'    = {sigma_f_prime/1e6:.0f} MPa  (Basquin coeff)")
print(f"    b       = {b_exp}  (Basquin exponent)")
print(f"    KIc     = {KIc/1e6:.0f} MPa√m")


# =====================================================
# 1. Safety Factor Check, nominal operating stress
# =====================================================
print("\n[1] Safety Factor, Nominal Operating Stress")

design_stress = 750e6    # Pa, stress amplitude at max load

r_sf = anvil.R.safety_factor(allowable_stress=sigma_y, applied_stress=design_stress)
def _v(x): return float(x.si) if hasattr(x, "si") else float(x)
SF   = _v(r_sf["safety_factor"])
MoS  = _v(r_sf["margin_of_safety"])
pass_ = bool(r_sf["pass"])

print(f"\n  σ_applied = {design_stress/1e6:.0f} MPa,  σ_allowable = {sigma_y/1e6:.0f} MPa")
print(f"  Safety factor:   SF = {SF:.3f}  ({'PASS' if pass_ else 'FAIL'})")
print(f"  Margin of safety: MoS = {MoS:.3f}  ({MoS*100:.1f}%)")

# Sweep over applied stress to find failure boundary
sf_sys = System("safety_check")
sf_sys.add("allowable_stress", sigma_y, "Pa")
sf_sys.add("applied_stress",   design_stress, "Pa")
sf_sys.use("safety_factor")

sweep_sf = sf_sys.sweep("applied_stress", np.linspace(500e6, 2000e6, 7))
sweep_sf.summary(outputs=["safety_factor", "margin_of_safety", "pass"])


# =====================================================
# 2. Fatigue Life, Basquin's Law  N = (σ_a / σ_f')^(1/b)
# =====================================================
print("\n[2] Fatigue Life, Basquin's Law")

stress_amplitudes = [300e6, 500e6, 750e6, 1000e6, 1200e6]

print(f"\n  S-N curve for 300M Steel:")
print(f"  {'σ_a (MPa)':>12s}  {'N_cycles':>14s}  {'Life(flights)':>14s}")
print(f"  {'-'*42}")

flights_per_cycle = 100   # cycles per flight for this shaft

for sigma_a in stress_amplitudes:
    r_fat = anvil.R.fatigue_life_basquin(
        sigma_a=sigma_a,
        sigma_f_prime=sigma_f_prime,
        b_exponent=b_exp
    )
    N = _v(r_fat["N_cycles"])
    flights = N / flights_per_cycle
    print(f"  {sigma_a/1e6:>12.0f}  {N:>14.2e}  {flights:>14.1f}")

# Build a fatigue system for sweep
fatigue_sys = System("basquin_fatigue")
fatigue_sys.add("sigma_a",       design_stress, "Pa",  desc="Stress amplitude")
fatigue_sys.add("sigma_f_prime", sigma_f_prime, "Pa",  desc="Basquin coefficient")
fatigue_sys.add("b_exponent",    b_exp,                desc="Basquin exponent")
fatigue_sys.use("fatigue_life_basquin")

print(f"\n  Fatigue life sweep (stress amplitude vs N_cycles):")
sweep_fat = fatigue_sys.sweep("sigma_a", np.linspace(200e6, 1200e6, 8))
sweep_fat.summary(outputs=["N_cycles"])


# =====================================================
# 3. Miner's Rule, Cumulative Fatigue Damage
#
# Flight spectrum: 3 distinct load levels, each with
# a known number of cycles per flight.
# =====================================================
print("\n[3] Miner's Rule, Cumulative Damage")

# Flight spectrum: [stress level (Pa), cycles per flight]
spectrum = [
    ("Taxi/ground",     150e6,  500),   # low stress, many cycles
    ("Cruise",          400e6,   80),   # moderate stress
    ("Maneuver/launch", 750e6,   20),   # high stress, few cycles
]

# Compute fatigue life for each level
cycle_limits = []
cycle_counts = []
print(f"\n  Flight spectrum:")
print(f"  {'Level':20s}  {'σ_a(MPa)':>10s}  {'n/flight':>10s}  {'N_f':>14s}  {'n/N_f':>10s}")
print(f"  {'-'*68}")

for level, sigma_a, n_per_flight in spectrum:
    r_fat = anvil.R.fatigue_life_basquin(
        sigma_a=sigma_a, sigma_f_prime=sigma_f_prime, b_exponent=b_exp
    )
    N_f = _v(r_fat["N_cycles"])
    cycle_limits.append(N_f)
    cycle_counts.append(float(n_per_flight))
    ratio = n_per_flight / N_f
    print(f"  {level:20s}  {sigma_a/1e6:>10.0f}  {n_per_flight:>10d}  {N_f:>14.2e}  {ratio:>10.4e}")

# Compute damage per flight
damage_per_flight = sum(n / N for n, N in zip(cycle_counts, cycle_limits))
flights_to_failure = 1.0 / damage_per_flight

print(f"\n  Damage per flight: D = Σ(n/N) = {damage_per_flight:.6e}")
print(f"  Flights to failure (D = 1):  {flights_to_failure:.0f} flights")

# Full Miner's rule RSQ call
r_miner = anvil.R.miners_rule(
    cycle_counts=cycle_counts,
    cycle_limits=cycle_limits,
)
D_total = _v(r_miner["damage_index"])
failed  = bool(r_miner["failed"])
remain  = _v(r_miner["remaining_life_fraction"])

print(f"\n  After 1 flight:")
print(f"    Damage index D = {D_total:.6f}  ({'FAILED' if failed else 'OK'})")
print(f"    Remaining life fraction: {remain:.6f}")

# After 100 flights
cycle_counts_100 = [n * 100 for n in cycle_counts]
r_miner_100 = anvil.R.miners_rule(
    cycle_counts=cycle_counts_100,
    cycle_limits=cycle_limits,
)
D_100 = _v(r_miner_100["damage_index"])
print(f"\n  After 100 flights: D = {D_100:.4f}  ({'FAILED' if bool(r_miner_100['failed']) else 'OK'})")
print(f"  Flights to inspection limit (D=0.5): {0.5/damage_per_flight:.0f} flights")


# =====================================================
# 4. Fracture Toughness Check
#
# NDI detected a surface semi-circular crack of radius a.
# Check if the stress intensity factor KI exceeds KIc.
# =====================================================
print("\n[4] Fracture Toughness Check (LEFM)")

a_crack_ndi = 0.0008   # m, 0.8 mm crack from NDI (near detection limit)

print(f"\n  Crack size from NDI: a = {a_crack_ndi*1000:.1f} mm")
print(f"  KIc = {KIc/1e6:.0f} MPa√m")
print(f"\n  {'σ (MPa)':>10s}  {'KI (MPa√m)':>12s}  {'SF_frac':>10s}  {'Fail?':>8s}")
print(f"  {'-'*44}")

for sigma in [300e6, 500e6, 750e6, 1000e6, 1400e6]:
    r_frac = anvil.R.fracture_toughness_check(
        sigma=sigma,
        a_crack=a_crack_ndi,
        KIc=KIc,
        F_geometry=1.12,   # free-surface correction for semi-circular crack
    )
    KI    = _v(r_frac["KI"])
    sf_fr = _v(r_frac["safety_factor"])
    fail  = bool(r_frac["failed"])
    print(f"  {sigma/1e6:>10.0f}  {KI/1e6:>12.2f}  {sf_fr:>10.2f}  {'YES' if fail else 'no':>8s}")

# Critical crack size at operating stress
r_frac_op = anvil.R.fracture_toughness_check(
    sigma=design_stress, a_crack=a_crack_ndi, KIc=KIc
)
KI_op = _v(r_frac_op["KI"])
a_critical = (KIc / (1.12 * design_stress * np.sqrt(np.pi)))**2
print(f"\n  At operating stress {design_stress/1e6:.0f} MPa:")
print(f"    KI = {KI_op/1e6:.2f} MPa√m  (KIc = {KIc/1e6:.0f})")
print(f"    Critical crack size: a_crit = {a_critical*1000:.2f} mm")
print(f"    Safety factor: {_v(r_frac_op['safety_factor']):.2f}")


# =====================================================
# 5. Thermal Expansion Stress
# =====================================================
print("\n[5] Thermal Expansion Stress, cryogenic refueling")

# Temperature change during LOX propellant loading
E_al    = 72e9     # Pa, aluminum alloy
alpha_al = 23e-6   # 1/K, thermal expansion coefficient (aluminum)
dT_cry  = -180     # K, cryogenic cooling (ambient → -180°C delta)

r_th = anvil.R.thermal_expansion_stress(E=E_al, alpha_thermal=alpha_al, dT=dT_cry)
sigma_th = abs(_v(r_th["sigma_thermal"]))

print(f"\n  Aluminum structure (E={E_al/1e9:.0f} GPa, α={alpha_al*1e6:.0f} µ/K)")
print(f"  Cooling ΔT = {dT_cry} K (cryogenic LOX loading)")
print(f"  Thermal stress: σ_th = {sigma_th/1e6:.0f} MPa")

sigma_y_al = 503e6  # Pa, Al 7075-T6
r_sf_th = anvil.R.safety_factor(allowable_stress=sigma_y_al, applied_stress=sigma_th)
print(f"  Safety factor (Al 7075-T6, σ_y={sigma_y_al/1e6:.0f} MPa): {_v(r_sf_th['safety_factor']):.2f}")


# =====================================================
# 6. Composite Laminate Stiffness (rule of mixtures)
# =====================================================
print("\n[6] Composite Laminate Stiffness (CFRP)")

# Carbon fiber / epoxy composite (typical UD ply)
Ef     = 230e9   # Pa, fiber modulus (carbon)
Em     = 3.5e9   # Pa, matrix modulus (epoxy)
Gf     = 90e9    # Pa, fiber shear modulus
Gm     = 1.3e9   # Pa, matrix shear modulus
nu_f   = 0.20    #, fiber Poisson's ratio
nu_m   = 0.35    #, matrix Poisson's ratio
Vf     = 0.60    #, fiber volume fraction (60%)

r_comp = anvil.R.composite_laminate_stiffness(
    Ef=Ef, Em=Em, Gf=Gf, Gm=Gm, nu_f=nu_f, nu_m=nu_m, Vf=Vf
)

E1  = _v(r_comp["E1"])
E2  = _v(r_comp["E2"])
G12 = _v(r_comp["G12"])
nu12 = _v(r_comp["nu12"])

print(f"\n  CFRP UD ply (Vf = {Vf*100:.0f}%):")
print(f"    E1   = {E1/1e9:.1f} GPa  (axial, fiber dominated)")
print(f"    E2   = {E2/1e9:.2f} GPa  (transverse, matrix dominated)")
print(f"    G12  = {G12/1e9:.2f} GPa  (shear)")
print(f"    ν12  = {nu12:.4f}")
print(f"    E1/E2 ratio = {E1/E2:.1f}  (strong anisotropy)")

# Sweep Vf
comp_sys = System("composite_design")
comp_sys.add("Ef",   Ef);   comp_sys.add("Em",   Em)
comp_sys.add("Gf",   Gf);   comp_sys.add("Gm",   Gm)
comp_sys.add("nu_f", nu_f); comp_sys.add("nu_m", nu_m)
comp_sys.add("Vf",   Vf)
comp_sys.use("composite_laminate_stiffness")

print(f"\n  Stiffness vs fiber volume fraction:")
sweep_comp = comp_sys.sweep("Vf", np.linspace(0.35, 0.70, 7))
sweep_comp.summary(outputs=["E1", "E2", "G12", "nu12"])


# =====================================================
# 7. Full structural assessment system
# =====================================================
print("\n[7] Integrated structural life system")

struct_sys = System("shaft_life_assessment")
struct_sys.add("sigma_a",       design_stress,  "Pa")
struct_sys.add("sigma_f_prime", sigma_f_prime,  "Pa")
struct_sys.add("b_exponent",    b_exp)
struct_sys.add("allowable_stress", sigma_y,     "Pa")
struct_sys.add("applied_stress",   design_stress, "Pa")
struct_sys.add("sigma",            design_stress, "Pa")
struct_sys.add("a_crack",          a_crack_ndi)
struct_sys.add("KIc",              KIc)
struct_sys.add("F_geometry",       1.12)
struct_sys.use("fatigue_life_basquin")
struct_sys.use("safety_factor")


def fracture_check(sigma, a_crack, KIc, F_geometry):
    """fracture_toughness_check with its safety_factor renamed so it doesn't
    collide with the static safety_factor RSQ's output in the same system."""
    r = dict(anvil.R.fracture_toughness_check(
        sigma=sigma, a_crack=a_crack, KIc=KIc, F_geometry=F_geometry))
    r["SF_fracture"] = r.pop("safety_factor")
    return r


struct_sys.use(fracture_check)

r_final = struct_sys.solve_forward()
print(f"\n  Integrated assessment at σ={design_stress/1e6:.0f} MPa:")
print(f"    Fatigue life:     {_v(r_final['N_cycles']):.2e} cycles")
print(f"    Static SF:        {_v(r_final['safety_factor']):.2f}")
print(f"    Fracture SF:      {_v(r_final['SF_fracture']):.2f}")
print(f"    Fracture KI/KIc:  {_v(r_final['KI'])/_v(r_final['KIc']):.3f}"
      f"  ({'CRITICAL' if bool(r_final['failed']) else 'safe'})")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 14: Materials, Fatigue, Fracture, Composites
============================================================

  Material: 300M Steel
    E       = 207 GPa
    σ_y     = 1700 MPa
    σ_UTS   = 1950 MPa
    σ_f'    = 2100 MPa  (Basquin coeff)
    b       = -0.07  (Basquin exponent)
    KIc     = 70 MPa√m

[1] Safety Factor, Nominal Operating Stress

  σ_applied = 750 MPa,  σ_allowable = 1700 MPa
  Safety factor:   SF = 2.267  (PASS)
  Margin of safety: MoS = 1.267  (126.7%)

----------------------------------------------------------------------
  safety_check -- sweep over applied_stress
----------------------------------------------------------------------
  applied_stress safety_factormargin_of_safety          pass
            [Pa]                                          
  --------------------------------------------------------
           5e+08           3.4           2.4             1
         7.5e+08         2.267         1.267             1
           1e+09           1.7           0.7             1
        1.25e+09          1.36          0.36             1
         1.5e+09         1.133        0.1333             1
        1.75e+09        0.9714      -0.02857             0
           2e+09          0.85         -0.15             0
----------------------------------------------------------------------

[2] Fatigue Life, Basquin's Law
... (110 more lines)

Example 15: Aerodynamic Performance Analysis¶

examples/ex15_aero_performance.py

import sys, os

# Windows consoles default to cp1252; this output uses Greek symbols.
if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import numpy as np


import anvil
from anvil import Q, System
from anvil import in_    # `in` is a Python keyword; Anvil provides `in_`

print("=" * 60)
print("  Example 15: Aerodynamic Performance Analysis")
print("=" * 60)


# =====================================================
# Aircraft parameters (narrow-body jet transport)
# =====================================================
W_MTOW     = 750e3    # N , max takeoff weight (≈76 t)
W_OEW      = 420e3    # N , operating empty weight
S_ref      = 122.4    # m^2, wing reference area
AR         = 9.5      #, aspect ratio
sweep_deg  = 25.0     # deg, quarter-chord sweep
taper      = 0.25     #, taper ratio
CD0        = 0.025    #, zero-lift drag coefficient
CLmax      = 2.8      #, max lift coefficient (flaps extended)
TSFC       = 1.8e-5   # kg/N/s, thrust specific fuel consumption (SI)

print(f"\n  Aircraft parameters:")
print(f"    MTOW   = {W_MTOW/1e3:.0f} kN ({W_MTOW/9.80665/1000:.0f} t)")
print(f"    S_ref  = {S_ref} m²,  AR = {AR},  Sweep = {sweep_deg}°")
print(f"    CD0    = {CD0},  CLmax = {CLmax}")
print(f"    TSFC   = {TSFC:.2e} kg/N/s")

# Demonstrate in_ alias (inches as a unit)
wing_chord = 5.5 * in_    # 5.5 inches (model scale test)
print(f"\n  Model scale test chord: {5.5} in = {wing_chord.to('m')}")
print(f"  (in_ alias used since 'in' is a Python keyword)")


# =====================================================
# 1. ISA Standard Atmosphere
# =====================================================
print("\n[1] ISA Standard Atmosphere")

altitudes = [0, 5000, 10000, 11000, 15000, 20000]

print(f"\n  {'h (m)':>8s}  {'T (K)':>8s}  {'P (kPa)':>10s}  {'ρ (kg/m³)':>12s}  {'a (m/s)':>10s}")
print(f"  {'-'*54}")

for h in altitudes:
    r_isa = anvil.R.isa_atmosphere(h=h)
    def _v(x): return float(x.si) if hasattr(x, "si") else float(x)
    T   = _v(r_isa["T_atm"])
    P   = _v(r_isa["P_atm"])
    rho = _v(r_isa["rho_atm"])
    a   = _v(r_isa["a_atm"])
    print(f"  {h:>8.0f}  {T:>8.2f}  {P/1000:>10.3f}  {rho:>12.4f}  {a:>10.2f}")


# =====================================================
# 2. Thin Airfoil Theory + Prandtl-Glauert Correction
# =====================================================
print("\n[2] Thin Airfoil Lift Coefficient (with Prandtl-Glauert)")

angles = [-4, 0, 2, 4, 6, 8, 10]
machs  = [0.0, 0.3, 0.6, 0.75]

print(f"\n  CL vs angle-of-attack (α_L0 = -2°):")
print(f"  {'α (°)':>8s}", end="")
for M in machs:
    print(f"  {'M='+str(M):>10s}", end="")
print()
print(f"  {'-'*48}")

for alpha in angles:
    print(f"  {alpha:>8.1f}", end="")
    for M in machs:
        r_cl = anvil.R.thin_airfoil_cl(alpha_deg=float(alpha), alpha_L0_deg=-2.0, M=M)
        CL = _v(r_cl["CL"])
        print(f"  {CL:>10.4f}", end="")
    print()

print(f"\n  CL_alpha (2π/rad × P-G) at M=0.6:")
r_cla = anvil.R.thin_airfoil_cl(alpha_deg=5.0, alpha_L0_deg=-2.0, M=0.6)
print(f"    CL_alpha = {_v(r_cla['CL_alpha']):.4f} per degree  ({_v(r_cla['CL_alpha'])*180/np.pi:.4f}/rad)")


# =====================================================
# 3. Oswald Efficiency + Drag Polar
# =====================================================
print("\n[3] Drag Polar and L/D at Cruise")

r_oswald = anvil.R.oswald_efficiency(AR=AR, sweep_deg=sweep_deg, taper=taper)
e_oswald = _v(r_oswald["e_oswald"])
print(f"\n  Oswald efficiency: e = {e_oswald:.4f}  (AR={AR}, sweep={sweep_deg}°)")

CL_cruise  = 0.52   # typical cruise CL

r_polar = anvil.R.drag_polar(CL=CL_cruise, CD0=CD0, AR=AR, e=e_oswald)
CD_cr   = _v(r_polar["CD"])
CDi_cr  = _v(r_polar["CDi"])
LoD_cr  = _v(r_polar["LoD"])

print(f"\n  At CL = {CL_cruise} (cruise):")
print(f"    CD0  = {CD0:.4f}  (parasite drag)")
print(f"    CDi  = {CDi_cr:.4f}  (induced drag)")
print(f"    CD   = {CD_cr:.4f}  (total)")
print(f"    L/D  = {LoD_cr:.2f}  (lift-to-drag)")

# Sweep CL to find L/D max
polar_sys = System("wing_polar")
polar_sys.add("CL",  0.5)
polar_sys.add("CD0", CD0)
polar_sys.add("AR",  AR)
polar_sys.add("e",   e_oswald)
polar_sys.use("drag_polar")

print(f"\n  L/D vs CL (drag polar sweep):")
sweep_polar = polar_sys.sweep("CL", np.linspace(0.2, 1.2, 9))
sweep_polar.summary(outputs=["CDi", "CD", "LoD"])

# Find optimum CL
LoD_vals = [_v(sweep_polar["LoD"][i]) if hasattr(sweep_polar["LoD"][i], "si")
            else float(sweep_polar["LoD"][i])
            for i in range(len(sweep_polar["LoD"]))]
CL_vals  = np.linspace(0.2, 1.2, 9)
idx_opt  = np.argmax(LoD_vals)
print(f"\n  Optimal CL = {CL_vals[idx_opt]:.2f}  (L/D_max = {LoD_vals[idx_opt]:.2f})")
print(f"  Analytical: CL_opt = sqrt(π·e·AR·CD0) = {np.sqrt(np.pi*e_oswald*AR*CD0):.3f}")


# =====================================================
# 4. Stall Speed at Different Altitudes
# =====================================================
print("\n[4] Stall Speed vs Altitude")

print(f"\n  {'Alt (m)':>8s}  {'ρ (kg/m³)':>12s}  {'V_stall (m/s)':>14s}  {'V_stall (kt)':>14s}")
print(f"  {'-'*52}")

for h in [0, 2000, 5000, 8000, 10000]:
    r_isa = anvil.R.isa_atmosphere(h=h)
    rho   = _v(r_isa["rho_atm"])
    r_stall = anvil.R.stall_speed(W=W_MTOW, rho=rho, S_ref=S_ref, CLmax=CLmax)
    Vs = _v(r_stall["V_stall"])
    Vs_kt = Vs / 0.5144    # m/s → knots
    print(f"  {h:>8.0f}  {rho:>12.4f}  {Vs:>14.1f}  {Vs_kt:>14.1f}")


# =====================================================
# 5. Induced Drag at Different Lift Coefficients
# =====================================================
print("\n[5] Induced Drag RSQ")

CDi_sys = System("induced_drag")
CDi_sys.add("CL", 0.52)
CDi_sys.add("AR", AR)
CDi_sys.add("e",  e_oswald)
CDi_sys.use("induced_drag")

r_cdi = CDi_sys.solve_forward()
print(f"\n  CL={0.52}, AR={AR}, e={e_oswald:.4f}:")
print(f"    CDi = {_v(r_cdi['CDi']):.5f}")

sweep_cdi = CDi_sys.sweep("CL", np.linspace(0.1, 1.2, 8))
sweep_cdi.summary(outputs=["CDi"])


# =====================================================
# 6. Breguet Range, cruise altitude trade study
# =====================================================
print("\n[6] Breguet Range vs Cruise Altitude")

# Fuel weight = MTOW - OEW (fully loaded)
W_fuel = W_MTOW - W_OEW

print(f"\n  Fuel weight: {W_fuel/1e3:.0f} kN  ({W_fuel/9.80665/1000:.0f} t)")
print(f"  TSFC = {TSFC:.2e} kg/N/s = {TSFC*9.80665*3600:.4f} /hr")

print(f"\n  Breguet range at different cruise altitudes:")
print(f"  {'Alt (m)':>8s}  {'TAS (m/s)':>10s}  {'Mach':>6s}  {'ρ':>10s}  {'L/D':>6s}  {'Range (km)':>12s}")
print(f"  {'-'*60}")

for h_cruise in [7000, 9000, 10668, 12000]:    # FL230, FL295, FL350 (36kft), FL394
    r_isa  = anvil.R.isa_atmosphere(h=h_cruise)
    rho    = _v(r_isa["rho_atm"])
    a_spd  = _v(r_isa["a_atm"])

    # Compute cruise Mach from CL = W/(0.5*rho*V^2*S)
    V_cruise = np.sqrt(W_MTOW / (0.5 * rho * S_ref * CL_cruise))
    M_cruise = V_cruise / a_spd

    # L/D at this condition
    r_pol = anvil.R.drag_polar(CL=CL_cruise, CD0=CD0, AR=AR, e=e_oswald)
    LoD   = _v(r_pol["LoD"])

    # Breguet range
    r_bq = anvil.R.range_breguet(
        V=V_cruise,
        TSFC=TSFC,
        LoD=LoD,
        W_initial=W_MTOW,
        W_final=W_OEW,
    )
    range_km = _v(r_bq["range_km"])

    print(f"  {h_cruise:>8.0f}  {V_cruise:>10.1f}  {M_cruise:>6.3f}  {rho:>10.4f}  {LoD:>6.2f}  {range_km:>12.0f}")


# =====================================================
# 7. Integrated aircraft performance System
# =====================================================
print("\n[7] Integrated aircraft performance System")

acft = System("aircraft_performance")
acft.add("h_cruise",   10668,   "m",      desc="Cruise altitude (FL350)")
acft.add("W_initial",  W_MTOW,  "N",      desc="Initial weight (MTOW)")
acft.add("W_final",    W_OEW,   "N",      desc="Final weight (OEW)")
acft.add("S_ref",      S_ref,   "m^2",    desc="Wing reference area")
acft.add("AR",         AR,                desc="Aspect ratio")
acft.add("CD0",        CD0,               desc="Zero-lift drag coefficient")
acft.add("CLmax",      CLmax,             desc="Max lift coefficient (flaps)")
acft.add("TSFC",       TSFC,             desc="Thrust specific fuel consumption")
acft.add("sweep_deg",  sweep_deg,         desc="Wing quarter-chord sweep")
acft.add("taper",      taper,             desc="Wing taper ratio")
acft.add("CL_cruise",  CL_cruise,         desc="Cruise lift coefficient")
acft.add("e_base",     0.85,              desc="Base Oswald efficiency (fallback)")

def isa_and_cruise(h_cruise):
    r = anvil.R.isa_atmosphere(h=h_cruise)
    def v(x): return float(x.si) if hasattr(x, "si") else float(x)
    return {"rho_cr": Q(v(r["rho_atm"]), "kg/m^3"),
            "a_cr":   Q(v(r["a_atm"]),   "m/s"),
            "T_cr":   Q(v(r["T_atm"]),   "K"),
            "P_cr":   Q(v(r["P_atm"]),   "Pa")}

def cruise_speed_and_mach(W_initial, S_ref, CL_cruise, rho_cr, a_cr):
    V = float((W_initial / (0.5 * float(rho_cr.si if hasattr(rho_cr, 'si') else rho_cr)
                             * float(S_ref.si if hasattr(S_ref, 'si') else S_ref)
                             * (CL_cruise.si if hasattr(CL_cruise, 'si') else CL_cruise)))**0.5)
    a  = float(a_cr.si if hasattr(a_cr, 'si') else a_cr)
    return {"V_cr": Q(V, "m/s"), "M_cr": V / a}

def polar_and_range(CL_cruise, CD0, AR, e_base, V_cr, TSFC, W_initial, W_final):
    CL = float(CL_cruise.si if hasattr(CL_cruise, 'si') else CL_cruise)
    e  = float(e_base.si if hasattr(e_base, 'si') else e_base)
    r  = anvil.R.drag_polar(CL=CL, CD0=float(CD0.si if hasattr(CD0, 'si') else CD0),
                              AR=float(AR.si if hasattr(AR, 'si') else AR), e=e)
    def v(x): return float(x.si) if hasattr(x, "si") else float(x)
    LoD = v(r["LoD"])
    V   = v(V_cr)
    tsfc = v(TSFC)
    Wi   = v(W_initial)
    Wf   = v(W_final)
    rng_r = anvil.R.range_breguet(V=V, TSFC=tsfc, LoD=LoD, W_initial=Wi, W_final=Wf)
    return {"LoD_cr": LoD, "CD_cr": v(r["CD"]), "CDi_cr": v(r["CDi"]),
            "range_km": Q(v(rng_r["range_km"]), "km")}

acft.use(isa_and_cruise)
acft.use(cruise_speed_and_mach)
acft.use(polar_and_range)

r_acft = acft.solve_forward()
def v(x): return float(x.si) if hasattr(x, "si") else float(x)
print(f"\n  Cruise performance summary (FL350 / {10668} m):")
print(f"    V_cruise = {v(r_acft['V_cr']):.1f} m/s  (M = {v(r_acft['M_cr']):.3f})")
print(f"    L/D      = {v(r_acft['LoD_cr']):.2f}")
print(f"    Range    = {v(r_acft['range_km']):.0f} km")

# Sweep altitude
print(f"\n  Range vs cruise altitude:")
sweep_alt = acft.sweep("h_cruise", np.array([7000, 8000, 9000, 10000, 11000, 12000, 13000]))
sweep_alt.summary(outputs=["T_cr", "rho_cr", "M_cr", "LoD_cr", "range_km"])

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 15: Aerodynamic Performance Analysis
============================================================

  Aircraft parameters:
    MTOW   = 750 kN (76 t)
    S_ref  = 122.4 m²,  AR = 9.5,  Sweep = 25.0°
    CD0    = 0.025,  CLmax = 2.8
    TSFC   = 1.80e-05 kg/N/s

  Model scale test chord: 5.5 in = 0.139700 m
  (in_ alias used since 'in' is a Python keyword)

[1] ISA Standard Atmosphere

     h (m)     T (K)     P (kPa)     ρ (kg/m³)     a (m/s)
  ------------------------------------------------------
         0    288.15     101.325        1.2250      340.30
      5000    255.65      54.020        0.7361      320.53
     10000    223.15      26.437        0.4127      299.47
     11000    216.65      22.633        0.3639      295.07
     15000    216.65      12.045        0.1937      295.07
     20000    216.65       5.475        0.0880      295.07

[2] Thin Airfoil Lift Coefficient (with Prandtl-Glauert)

  CL vs angle-of-attack (α_L0 = -2°):
     α (°)       M=0.0       M=0.3       M=0.6      M=0.75
  ------------------------------------------------
      -4.0     -0.2193     -0.2299     -0.2742     -0.3071
       0.0      0.2193      0.2299      0.2742      0.3071
       2.0      0.4386      0.4598      0.5483      0.6142
       4.0      0.6580      0.6897      0.8225      0.9213
       6.0      0.8773      0.9197      1.0966      1.2285
... (109 more lines)

Example 16: Global Optimization and System.optimize()¶

examples/ex16_optimization.py

import sys, os
import numpy as np

import anvil
from anvil import solvers, Q

print("=" * 60)
print("  Example 16: Optimization")
print("=" * 60)


# --------------------------------------------------------------
# Part 1: minimize_global, direct function optimization
# --------------------------------------------------------------
print("\n[1] Direct global optimization: Himmelblau's function")
print("    f(x,y) = (x²+y-11)² + (x+y²-7)²  has 4 global minima at f=0")

def himmelblau(x):
    return (x[0]**2 + x[1] - 11)**2 + (x[0] + x[1]**2 - 7)**2

bounds = [(-5, 5), (-5, 5)]

for method in ["differential_evolution", "dual_annealing", "shgo", "basinhopping"]:
    r = solvers.minimize_global(himmelblau, bounds, method=method, seed=0)
    status = "OK" if r["fun"] < 1e-6 else "MISSED"
    print(f"  {method:28s}  f={r['fun']:.2e}  x=[{r['x'][0]:+.4f}, {r['x'][1]:+.4f}]  [{status}]")

# --------------------------------------------------------------
# Part 2: System.optimize(), nozzle thrust maximization
# --------------------------------------------------------------
print("\n[2] System.optimize(): maximize nozzle thrust")
print("    Design variables: A_throat, A_exit")
print("    Fixed: chamber conditions, ambient pressure")

nozzle = anvil.S.rocket_nozzle.copy()

# Fix chamber conditions and ambient
nozzle.set(
    P0=8e6,       # 8 MPa
    T0=3200,      # K
    gamma=1.25,
    R_gas=400.0,  # J/kg/K
    P_amb=101325,
)

# Maximize thrust by sizing throat and exit
opt = nozzle.optimize(
    objective="thrust",
    design_vars={
        "A_throat": (0.002, 0.030),   # 20 to 300 cm²
        "A_exit":   (0.010, 0.300),   # 100 to 3000 cm²
    },
    minimize=False,
    method="differential_evolution",
    seed=42,
    maxiter=80,
    verbose=False,
)

print(f"\n  Status : {'CONVERGED' if opt.success else 'NOT CONVERGED'}")
print(f"  Evals  : {opt.nfev}")
print(f"  Thrust : {opt.fun/1000:.2f} kN  ({opt.fun:.0f} N)")
print(f"  A_throat: {opt.x['A_throat']*1e4:.1f} cm²")
print(f"  A_exit  : {opt.x['A_exit']*1e4:.1f} cm²")
print(f"  Area ratio: {opt.x['A_exit'] / opt.x['A_throat']:.1f}")

# Access other quantities from the optimal result
print(f"\n  Other results at optimum:")
print(f"    Isp    : {float(opt['Isp'].value):.1f} s")
print(f"    M_exit : {float(opt['M_exit'].value):.2f}")
print(f"    mdot   : {float(opt['mdot'].value):.3f} kg/s")
print(f"    V_exit : {opt['V_exit'].to('km/s')}")

# --------------------------------------------------------------
# Part 3: Maximize Isp (efficiency), different objective
# --------------------------------------------------------------
print("\n[3] Same system, different objective: maximize Isp")

opt_isp = nozzle.optimize(
    objective="Isp",
    design_vars={
        "A_throat": (0.002, 0.030),
        "A_exit":   (0.010, 0.300),
    },
    minimize=False,
    method="differential_evolution",
    seed=42,
    maxiter=80,
    verbose=False,
)

print(f"  Isp    : {opt_isp.fun:.1f} s")
print(f"  Thrust : {float(opt_isp['thrust'].value)/1000:.2f} kN")
print(f"  A_throat: {opt_isp.x['A_throat']*1e4:.1f} cm²")
print(f"  A_exit  : {opt_isp.x['A_exit']*1e4:.1f} cm²  (-> area ratio {opt_isp.x['A_exit']/opt_isp.x['A_throat']:.1f})")
print("  (Higher Isp favours large expansion ratio; thrust trades off mdot vs Ve)")

# --------------------------------------------------------------
# Part 4: Custom system, optimize a heat exchanger NTU
# --------------------------------------------------------------
print("\n[4] Custom system: optimal NTU for heat exchanger effectiveness")

hx = anvil.system("hx_opt")
hx.add("NTU",    2.0)    # number of transfer units
hx.add("Cr",     0.5)    # capacity rate ratio Cmin/Cmax
hx.add("C_min",  500.0, "W/K")
hx.add("T_h_in", 90.0,  "K")   # hot inlet (relative, used for Q calc)
hx.add("T_c_in", 20.0,  "K")   # cold inlet

@anvil.relation
def hx_eff_ntu(NTU, Cr):
    eps = (1 - np.exp(-NTU * (1 - Cr))) / (1 - Cr * np.exp(-NTU * (1 - Cr)))
    return {"effectiveness": eps}

@anvil.relation
def hx_duty(effectiveness, C_min, T_h_in, T_c_in):
    Q_max = C_min * (T_h_in - T_c_in)
    return {"Q_duty": effectiveness * Q_max}

hx.use(hx_eff_ntu)
hx.use(hx_duty)

# Maximize effectiveness by tuning NTU (proxy for heat exchanger size/cost)
opt_hx = hx.optimize(
    objective="effectiveness",
    design_vars={"NTU": (0.1, 10.0), "Cr": (0.1, 1.0)},
    minimize=False,
    method="L-BFGS-B",     # gradient-based: smooth landscape
    maxiter=200,
)

print(f"  Best effectiveness : {opt_hx.fun:.4f}  (max possible = 1.0)")
print(f"  Optimal NTU        : {opt_hx.x['NTU']:.3f}")
print(f"  Optimal Cr         : {opt_hx.x['Cr']:.3f}")
print(f"  Heat duty          : {float(opt_hx['Q_duty'].value):.0f} W")

# --------------------------------------------------------------
# Part 5: OptimizeResult API summary
# --------------------------------------------------------------
print("\n[5] OptimizeResult API")
print(f"  opt.x         = {dict(opt.x)}")
print(f"  opt.fun       = {opt.fun:.4g}")
print(f"  opt.success   = {opt.success}")
print(f"  opt.nfev      = {opt.nfev}  (system solves)")
print(f"  opt.nit       = {opt.nit}  (optimizer iterations)")
print(f"  opt.message   = {opt.message!r}")
print(f"  opt['Isp']    = {opt['Isp']}  (subscript -> Quantity at optimum)")
print(f"  'thrust' in opt = {'thrust' in opt}")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 16: Optimization
============================================================

[1] Direct global optimization: Himmelblau's function
    f(x,y) = (x²+y-11)² + (x+y²-7)²  has 4 global minima at f=0
  differential_evolution        f=1.34e-29  x=[+3.5844, -1.8481]  [OK]
  dual_annealing                f=6.36e-14  x=[+3.0000, +2.0000]  [OK]
  shgo                          f=1.01e-08  x=[+3.0000, +2.0000]  [OK]
  basinhopping                  f=6.12e-18  x=[+3.0000, +2.0000]  [OK]

[2] System.optimize(): maximize nozzle thrust
    Design variables: A_throat, A_exit
    Fixed: chamber conditions, ambient pressure

  Status : CONVERGED
  Evals  : 948
  Thrust : 381.22 kN  (381219 N)
  A_throat: 300.0 cm²
  A_exit  : 2695.2 cm²
  Area ratio: 9.0

  Other results at optimum:
    Isp    : 278.5 s
    M_exit : 3.34
    mdot   : 139.597 kg/s
    V_exit : 2.7309 km/s (V_exit)

[3] Same system, different objective: maximize Isp
  Isp    : 278.5 s
  Thrust : 294.53 kN
  A_throat: 231.8 cm²
  A_exit  : 2082.3 cm²  (-> area ratio 9.0)
  (Higher Isp favours large expansion ratio; thrust trades off mdot vs Ve)
... (26 more lines)

Example 17: Rayleigh Flow via Project Registry¶

examples/ex17_rayleigh_flow.py

import sys, os
import numpy as np


import anvil
from anvil import Q, solvers

print("=" * 60)
print("  Example 17: Rayleigh Flow via Project Registry")
print("=" * 60)


# =====================================================
# 1. Define the RSQs as plain Python functions
# =====================================================

def rayleigh_ratios(M, gamma=1.4):
    """Rayleigh flow ratios at Mach M referenced to sonic (★) conditions."""
    g = float(gamma); M = float(M)
    gp1 = g + 1
    denom = 1 + g * M**2
    P_Pstar     = gp1 / denom
    T_Tstar     = (gp1 * M / denom)**2
    rho_rhostar = denom / (gp1 * M**2)
    t0          = 1 + (g - 1) / 2 * M**2
    T0_T0star   = 2 * gp1 * M**2 * t0 / denom**2
    P0_P0star   = P_Pstar * (2 * t0 / gp1) ** (g / (g - 1))
    return {
        "T0_T0star":   T0_T0star,
        "T_Tstar":     T_Tstar,
        "P_Pstar":     P_Pstar,
        "P0_P0star":   P0_P0star,
        "rho_rhostar": rho_rhostar,
        "V_Vstar":     1.0 / rho_rhostar,
    }


def rayleigh_heat(M1, T01, P1, q_heat, cp, gamma=1.4):
    """
    Rayleigh flow with heat addition in a constant-area duct.
    q_heat [J/kg]: >0 heating, <0 cooling.
    Raises ValueError if heat addition would choke the flow.
    """
    g      = float(gamma); M1 = float(M1)
    T01    = float(getattr(T01,    "si", T01))
    P1     = float(getattr(P1,     "si", P1))
    q_heat = float(getattr(q_heat, "si", q_heat))
    cp     = float(getattr(cp,     "si", cp))
    gp1 = g + 1

    def _T0r(M):
        d = 1 + g * M**2
        return 2 * gp1 * M**2 * (1 + (g - 1) / 2 * M**2) / d**2
    def _Pr(M):  return gp1 / (1 + g * M**2)
    def _Tr(M):  return (gp1 * M / (1 + g * M**2))**2

    r1     = _T0r(M1)
    T02    = T01 + q_heat / cp
    T0star = T01 / r1
    r2     = T02 / T0star

    if r2 > 1.0:
        raise ValueError(
            f"Flow chokes: T02/T0* = {r2:.4f} > 1.0. "
            f"Max q_heat = {cp * (T0star - T01):.1f} J/kg"
        )

    bracket = (1.0001, 50.0) if M1 >= 1.0 else (0.001, 0.9999)
    M2  = solvers.find_root(lambda M: _T0r(M) - r2, bracket=bracket,
                            method="brent", tol=1e-12)
    P2  = P1  / _Pr(M1) * _Pr(M2)
    T1  = T01 / (1 + (g - 1) / 2 * M1**2)
    T2  = T1  / _Tr(M1) * _Tr(M2)
    P01 = P1  * (1 + (g - 1) / 2 * M1**2) ** (g / (g - 1))
    P02 = P2  * (1 + (g - 1) / 2 * M2**2) ** (g / (g - 1))
    return {
        "M2":        M2,
        "T02":       Q(T02, "K"),
        "T2":        Q(T2,  "K"),
        "P2":        Q(P2,  "Pa"),
        "P02":       Q(P02, "Pa"),
        "P01":       Q(P01, "Pa"),
        "P02_P01":   P02 / P01,
        "T0_T0star": r2,
    }


# =====================================================
# 2. Push to a project registry
# =====================================================

proj = anvil.project("rayleigh_study", path="./rayleigh_work")

proj.push(rayleigh_ratios,
          domain="aero.compressible",
          description="Rayleigh flow ratios at Mach M referenced to sonic conditions",
          tags=["rayleigh", "compressible", "heat_addition"])

proj.push(rayleigh_heat,
          domain="aero.compressible",
          description="Rayleigh flow exit conditions given inlet state + heat addition",
          tags=["rayleigh", "compressible", "heat_addition", "combustion"])

print("\n--- Project registry contents ---")
proj.list()


# =====================================================
# 3. Verify ratios at M=1 (all should equal 1.0)
# =====================================================

print("\n--- rayleigh_ratios at M=1.0 (all ratios = 1.0) ---")
r = proj.R.rayleigh_ratios(M=1.0)
for k, v in r.items():
    print(f"  {k:15s} = {v:.6f}")


# =====================================================
# 4. Build a System and solve
#    Inlet: M=0.3, T01=400 K, P1=200 kPa, q=300 kJ/kg (air)
# =====================================================

print("\n--- System solve: single heat addition ---")

duct = anvil.system("rayleigh_duct")
duct.add("M1",     0.3)
duct.add("T01",    400.0,   "K")
duct.add("P1",     200e3,   "Pa")
duct.add("q_heat", 300e3,   "J/kg")
duct.add("cp",     1005.0,  "J/kg/K")
duct.add("gamma",  1.4)
duct.use(proj.R.rayleigh_heat)

result = duct.solve_forward()
result.summary()


# =====================================================
# 5. Sweep heat addition from 0 to 80% of choke limit
# =====================================================

print("\n--- Sweep: q_heat from 0 to 80% of choke limit ---")

# Find choke limit for these inlet conditions
r_inlet = proj.R.rayleigh_ratios(M=0.3, gamma=1.4)
T0star  = 400.0 / r_inlet["T0_T0star"]
q_choke = 1005.0 * (T0star - 400.0)
print(f"  Choke limit: q_max = {q_choke/1e3:.1f} kJ/kg")

q_values = np.linspace(0, 0.80 * q_choke, 30)
sweep = duct.sweep("q_heat", q_values, skip_errors=True)
sweep.summary(outputs=["M2", "T02", "P2", "P02_P01"])


# =====================================================
# 6. Promote to global registry when satisfied
# =====================================================

# Uncomment when ready to make these globally available:
# proj.promote("rayleigh_ratios")
# proj.promote("rayleigh_heat")
# print("\nPromoted rayleigh_ratios and rayleigh_heat to global registry.")
# print("Now accessible as anvil.R.rayleigh_ratios / anvil.R.rayleigh_heat")

Output:

============================================================
  Example 17: Rayleigh Flow via Project Registry
============================================================
  Project 'rayleigh_study' opened  (rayleigh_work\.anvil\project_rayleigh_study.db)
  [rayleigh_study] Registered 'rayleigh_ratios' (R) in domain 'aero.compressible'.
  [rayleigh_study] Registered 'rayleigh_heat' (R) in domain 'aero.compressible'.

--- Project registry contents ---

  Project: rayleigh_study  (rayleigh_work)

  Relations (2):
    rayleigh_heat                   [aero.compressible]
      Rayleigh flow exit conditions given inlet state + heat addition
    rayleigh_ratios                 [aero.compressible]
      Rayleigh flow ratios at Mach M referenced to sonic conditions

  Total: 2 RSQs

--- rayleigh_ratios at M=1.0 (all ratios = 1.0) ---
  T0_T0star       = 1.000000
  T_Tstar         = 1.000000
  P_Pstar         = 1.000000
  P0_P0star       = 1.000000
  rho_rhostar     = 1.000000
  V_Vstar         = 1.000000

--- System solve: single heat addition ---

--------------------------------------------------------
  rayleigh_duct -- results
--------------------------------------------------------
  M1                        0.300000
  T01                       400.00 K
... (55 more lines)

Example 18: POD and DMD Signal Decomposition¶

examples/ex18_decomp.py

import sys, os
import numpy as np

import anvil
import anvil.decomp as decomp

print("=" * 60)
print("  Example 18: POD and DMD Decomposition")
print("=" * 60)

rng = np.random.default_rng(42)


# --------------------------------------------------------------
# Part 1: Synthetic 1D signal, known frequencies
# --------------------------------------------------------------
print("\n[1] Synthetic signal: 3 Hz + 11 Hz + noise")

dt   = 0.005          # 200 Hz sample rate
t    = np.arange(0, 8, dt)
N    = len(t)

# True signal: two sinusoids + broadband noise
x = (1.0 * np.sin(2*np.pi*3*t)
   + 0.4 * np.sin(2*np.pi*11*t + 0.7)
   + 0.08 * rng.standard_normal(N))

print(f"  Signal length: {N} samples  ({t[-1]:.1f} s at {1/dt:.0f} Hz)")

# --------------------------------------------------------------
# Part 2: Hankel embedding
# --------------------------------------------------------------
print("\n[2] Hankel embedding")

window = N // 4     # rule of thumb: N/4 to N/3
H = decomp.hankel(x, window=window)
print(f"  window = {window},  Hankel shape = {H.shape}  (rows x columns)")
print(f"  Each column: one {window}-sample snapshot")

# --------------------------------------------------------------
# Part 3: POD
# --------------------------------------------------------------
print("\n[3] POD, energy decomposition")

pod_r = decomp.pod(H)   # all modes first

print(f"  Total modes retained: {pod_r['rank']}")
print(f"  Singular values (top 6): {pod_r['singular_values'][:6].round(1)}")
print(f"  Energy per mode  (top 6): {(pod_r['energy_fractions'][:6]*100).round(2)} %")
print(f"  Cumulative energy (top 6): {(pod_r['cumulative_energy'][:6]*100).round(2)} %")

r_99   = decomp.pod_rank(pod_r, 0.99)
r_999  = decomp.pod_rank(pod_r, 0.999)
print(f"\n  Modes for 99.0% energy : {r_99}")
print(f"  Modes for 99.9% energy : {r_999}")

# --------------------------------------------------------------
# Part 4: POD reconstruction
# --------------------------------------------------------------
print("\n[4] POD reconstruction error vs rank")

for r in [2, 4, 6, 10, 20]:
    X_hat = decomp.pod_reconstruct(pod_r, r=r)
    err = np.linalg.norm(H - X_hat) / np.linalg.norm(H)
    # Recover 1D signal from first row of reconstruction
    x_hat = X_hat[0, :]
    print(f"  r={r:3d}  matrix error = {err:.4f}  "
          f"  cumE = {pod_r['cumulative_energy'][r-1]*100:.2f}%")

# --------------------------------------------------------------
# Part 5: DMD, frequency identification
# --------------------------------------------------------------
print("\n[5] DMD, frequency and growth rate identification")

dmd_r = decomp.dmd(H, dt=dt, r=12)

print(f"  DMD rank used : {len(dmd_r['eigenvalues'])}")
print(f"  {'Mode':>4}  {'|eval|':>8}  {'Freq (Hz)':>12}  {'Growth rate':>12}  {'|Amplitude|':>12}")
print(f"  {'-'*55}")

dom_idx = decomp.dmd_dominant(dmd_r, n=8, by="amplitude")
for i in dom_idx:
    lam  = dmd_r["eigenvalues"][i]
    freq = dmd_r["frequencies"][i]
    grow = dmd_r["growth_rates"][i]
    amp  = np.abs(dmd_r["amplitudes"][i])
    print(f"  {i:4d}  {abs(lam):8.5f}  {freq:+12.4f}  {grow:+12.4f}  {amp:12.4f}")

print("\n  Note: dominant frequencies should match +/-3 Hz and +/-11 Hz")

# --------------------------------------------------------------
# Part 6: DMD reconstruction
# --------------------------------------------------------------
print("\n[6] DMD reconstruction")

X_dmd = decomp.dmd_reconstruct(dmd_r, n_steps=H.shape[1])
dmd_err = np.linalg.norm(H - X_dmd) / np.linalg.norm(H)
print(f"  Reconstruction error (r=12): {dmd_err:.4f}")

# Future prediction: extend 20% beyond training data
n_future = H.shape[1] + int(0.2 * H.shape[1])
X_future = decomp.dmd_reconstruct(dmd_r, n_steps=n_future)
print(f"  Extended to {n_future} steps ({n_future*dt:.2f} s) for future prediction")
print(f"  (DMD extrapolates via eigenvalue powers, valid for stable modes)")

# --------------------------------------------------------------
# Part 7: Multi-dim snapshot matrix (simulated sensor array)
# --------------------------------------------------------------
print("\n[7] Multi-dimensional snapshot matrix: 64-sensor vibration array")

n_sensors   = 64
n_snapshots = 500
dt_vib      = 1e-3     # 1 kHz

# Simulated: mode 1 at 80 Hz decaying, mode 2 at 220 Hz growing slightly
t_vib = np.arange(n_snapshots) * dt_vib
locs  = np.linspace(0, 1, n_sensors)

mode1_space = np.sin(np.pi * locs)           # first bending mode
mode2_space = np.sin(2 * np.pi * locs)       # second bending mode

mode1_time = np.exp(-0.5*t_vib) * np.sin(2*np.pi*80*t_vib)
mode2_time = np.exp(0.3*t_vib)  * np.sin(2*np.pi*220*t_vib) * 0.3

X_vib = (np.outer(mode1_space, mode1_time)
        + np.outer(mode2_space, mode2_time)
        + 0.02 * rng.standard_normal((n_sensors, n_snapshots)))

print(f"  Snapshot matrix shape: {X_vib.shape}  (sensors x time)")

# POD on vibration data
pod_vib = decomp.pod(X_vib, r=6)
print(f"\n  POD energy (top 6 modes):")
for i in range(6):
    bar = "#" * int(pod_vib["energy_fractions"][i] * 50)
    print(f"    Mode {i+1}: {pod_vib['energy_fractions'][i]*100:6.2f}%  {bar}")

# DMD on vibration data
dmd_vib = decomp.dmd(X_vib, dt=dt_vib, r=6)
print(f"\n  DMD dominant frequencies (top 4 by amplitude):")
idx_vib = decomp.dmd_dominant(dmd_vib, n=4, by="amplitude")
for i in idx_vib:
    freq = abs(dmd_vib["frequencies"][i])
    grow = dmd_vib["growth_rates"][i]
    amp  = np.abs(dmd_vib["amplitudes"][i])
    stab = "DECAYING" if grow < -0.1 else ("GROWING" if grow > 0.1 else "neutral")
    print(f"    freq = {freq:7.1f} Hz   growth = {grow:+.2f}   amp = {amp:.2f}  [{stab}]")

print("\n  (Should identify ~80 Hz decaying + ~220 Hz growing modes)")

# POD projection: project last 50 snapshots onto training basis
coeff = decomp.pod_project(pod_vib, X_vib[:, -50:])
print(f"\n  pod_project(): projected last 50 snapshots -> coefficients shape {coeff.shape}")

# --------------------------------------------------------------
# Part 8: Viz (optional, skipped if no matplotlib)
# --------------------------------------------------------------
print("\n[8] Visualization (requires matplotlib)")
try:
    from anvil import viz
    viz.pod_energy(pod_r, show=False)
    viz.dmd_spectrum(dmd_r, show=False)
    print("  Figures created. Call plt.show() or save with fig.savefig().")
    print("  (Running headless, no display. Remove show=False for interactive use.)")
except ImportError:
    print("  matplotlib not installed, skipping plots.")
except Exception as e:
    print(f"  Viz skipped: {e}")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 18: POD and DMD Decomposition
============================================================

[1] Synthetic signal: 3 Hz + 11 Hz + noise
  Signal length: 1600 samples  (8.0 s at 200 Hz)

[2] Hankel embedding
  window = 400,  Hankel shape = (400, 1201)  (rows x columns)
  Each column: one 400-sample snapshot

[3] POD, energy decomposition
  Total modes retained: 400
  Singular values (top 6): [345.  344.7 139.5 139.4   4.5   4.5]
  Energy per mode  (top 6): [4.254e+01 4.246e+01 6.950e+00 6.940e+00 1.000e-02 1.000e-02] %
  Cumulative energy (top 6): [42.54 85.   91.95 98.9  98.91 98.91] %

  Modes for 99.0% energy : 20
  Modes for 99.9% energy : 308

[4] POD reconstruction error vs rank
  r=  2  matrix error = 0.3873    cumE = 85.00%
  r=  4  matrix error = 0.1050    cumE = 98.90%
  r=  6  matrix error = 0.1043    cumE = 98.91%
  r= 10  matrix error = 0.1030    cumE = 98.94%
  r= 20  matrix error = 0.0999    cumE = 99.00%

[5] DMD, frequency and growth rate identification
  DMD rank used : 12
  Mode    |eval|     Freq (Hz)   Growth rate   |Amplitude|
  -------------------------------------------------------
     3   1.00001       +3.0003       +0.0013        9.9570
     4   1.00001       -3.0003       +0.0013        9.9570
     9   1.00003      +10.9994       +0.0052        3.8833
... (41 more lines)

Example 19: Abel Transform and Inversion¶

examples/ex19_abel.py

import sys, os
import numpy as np

import anvil
import anvil.decomp as decomp

print("=" * 60)
print("  Example 19: Abel Transform and Inversion")
print("=" * 60)


# ---------------------------------------------------------------
# Part 1: 1D validation -- Gaussian ring (analytic solution known)
# ---------------------------------------------------------------
print("\n[1] Forward Abel: Gaussian radial distribution -> projection")
print("    f(r) = exp(-r^2/sigma^2),  F(y) = sigma*sqrt(pi)*exp(-y^2/sigma^2)")

N = 300
dr = 0.05
r = np.arange(N) * dr
sigma = 3.0   # radial width in physical units

fr_true = np.exp(-(r / sigma) ** 2)
Fy_analytic = sigma * np.sqrt(np.pi) * np.exp(-(r / sigma) ** 2)

Fy_numerical = decomp.abel_forward(fr_true, dr=dr)

err_fwd = np.linalg.norm(Fy_numerical - Fy_analytic) / np.linalg.norm(Fy_analytic)
print(f"  Forward transform error vs analytic: {err_fwd:.2e}")

# ---------------------------------------------------------------
# Part 2: Inversion -- recover f(r) from F(y)
# ---------------------------------------------------------------
print("\n[2] Abel inversion: projection -> radial distribution")

fr_onion = decomp.abel_onion(Fy_numerical, dr=dr)
fr_3pt   = decomp.abel_three_point(Fy_numerical, dr=dr)

# Trim edge (last pixel often inaccurate due to boundary)
check = slice(1, N - 5)
err_onion = np.linalg.norm(fr_onion[check] - fr_true[check]) / np.linalg.norm(fr_true[check])
err_3pt   = np.linalg.norm(fr_3pt[check]   - fr_true[check]) / np.linalg.norm(fr_true[check])

print(f"  Onion peeling  error vs ground truth: {err_onion:.4f}")
print(f"  Three-point    error vs ground truth: {err_3pt:.4f}")
print(f"  Peak (onion)  : f[0] = {fr_onion[0]:.4f}  (true: {fr_true[0]:.4f})")
print(f"  Peak (3-point): f[0] = {fr_3pt[0]:.4f}  (true: {fr_true[0]:.4f})")

# ---------------------------------------------------------------
# Part 3: Hollow sphere / bright ring (harder test)
# ---------------------------------------------------------------
print("\n[3] Harder test: hollow sphere (bright ring)")
print("    f(r) = ring at r=R with Gaussian width")

R = 8.0
width = 1.0
fr_ring = np.exp(-((r - R) / width) ** 2)
fr_ring[r > R + 4 * width] = 0.0

Fy_ring = decomp.abel_forward(fr_ring, dr=dr)

fr_ring_onion = decomp.abel_onion(Fy_ring, dr=dr)
fr_ring_3pt   = decomp.abel_three_point(Fy_ring, dr=dr)

# Peak position recovery
r_peak_true  = r[np.argmax(fr_ring)]
r_peak_onion = r[np.argmax(fr_ring_onion)]
r_peak_3pt   = r[np.argmax(fr_ring_3pt)]
print(f"  True ring center  : r = {r_peak_true:.3f}")
print(f"  Onion peak        : r = {r_peak_onion:.3f}  (err {abs(r_peak_onion-r_peak_true)/r_peak_true*100:.1f}%)")
print(f"  Three-point peak  : r = {r_peak_3pt:.3f}  (err {abs(r_peak_3pt-r_peak_true)/r_peak_true*100:.1f}%)")

# ---------------------------------------------------------------
# Part 4: Noise robustness
# ---------------------------------------------------------------
print("\n[4] Noise robustness (SNR ~ 50 added to projection)")

rng = np.random.default_rng(99)
noise_level = Fy_ring.max() / 50.0
Fy_noisy = Fy_ring + noise_level * rng.standard_normal(N)

fr_noisy_onion = decomp.abel_onion(Fy_noisy, dr=dr)
fr_noisy_3pt   = decomp.abel_three_point(Fy_noisy, dr=dr)

r_peak_on = r[np.argmax(fr_noisy_onion)]
r_peak_tp = r[np.argmax(fr_noisy_3pt)]
print(f"  True peak: {r_peak_true:.3f}")
print(f"  Noisy onion peak : {r_peak_on:.3f}  (err {abs(r_peak_on-r_peak_true)/r_peak_true*100:.1f}%)")
print(f"  Noisy 3-pt  peak : {r_peak_tp:.3f}  (err {abs(r_peak_tp-r_peak_true)/r_peak_true*100:.1f}%)")
print("  (Three-point typically less noisy than onion peeling at center)")

# ---------------------------------------------------------------
# Part 5: 2D image -- simulated plasma emission
# ---------------------------------------------------------------
print("\n[5] 2D image: simulated plasma emission (cylindrical symmetry)")

n_rows, n_cols = 120, 201
cx = n_cols // 2   # center column

x = np.arange(n_cols) - cx
y_ax = np.arange(n_rows) - n_rows // 2

X, Y = np.meshgrid(x, y_ax)
R_img = np.abs(X).astype(float)   # radial distance from axis (2D, using Abel convention)

# Each row: hollow emission ring profile (varying intensity along axis)
axial_profile = np.exp(-(y_ax / 20.0) ** 2)
ring_r = 25.0
ring_w = 4.0

image = np.zeros((n_rows, n_cols))
for i, ax_amp in enumerate(axial_profile):
    fr_row = ax_amp * np.exp(-((np.arange(n_cols // 2 + 1) - ring_r) / ring_w) ** 2)
    Fy_row = decomp.abel_forward(fr_row, dr=1.0)
    m = len(Fy_row)
    image[i, cx:cx + m] = Fy_row
    if cx > 0:
        ml = min(m - 1, cx)
        image[i, cx - ml:cx] = Fy_row[ml:0:-1]

print(f"  Image shape: {image.shape}")

# Find center (should detect cx = {cx})
cr_found, cc_found = decomp.abel_center(image)
print(f"  abel_center() found: col = {cc_found}  (true: {cx})")

# Invert with both methods
result_3pt   = decomp.abel_image(image, method="three_point", center=(cr_found, cc_found))
result_onion = decomp.abel_image(image, method="onion",       center=(cr_found, cc_found))

print(f"  Radial image shape: {result_3pt['radial'].shape}")

# Check ring recovery at central row (highest intensity)
mid_row = n_rows // 2
fr_mid_3pt   = result_3pt["radial"][mid_row, cx:]
fr_mid_onion = result_onion["radial"][mid_row, cx:]

peak_3pt   = np.argmax(fr_mid_3pt)
peak_onion = np.argmax(fr_mid_onion)
print(f"  Ring peak at pixel (true: {int(ring_r)}) -- 3pt: {peak_3pt}  onion: {peak_onion}")

# ---------------------------------------------------------------
# Part 6: Round-trip validation
# ---------------------------------------------------------------
print("\n[6] Round-trip: invert -> re-project -> compare with original")

reprojected = decomp.abel_forward_image(result_3pt["radial"],
                                         center=(cr_found, cc_found))
err_rt = np.linalg.norm(image - reprojected) / np.linalg.norm(image)
print(f"  Round-trip error (project(invert(image)) vs image): {err_rt:.4f}")
print("  (Should be small; remaining error from edge effects and discretisation)")

# ---------------------------------------------------------------
# Part 7: Viz (optional)
# ---------------------------------------------------------------
print("\n[7] Visualization (requires matplotlib)")
try:
    from anvil import viz
    viz.abel_compare(image, result_3pt, show=False)
    print("  abel_compare() figure created. Call plt.show() or fig.savefig().")
    viz.abel_compare(image, result_onion, show=False, cmap="inferno")
    print("  Second figure (onion, inferno colormap) created.")
except ImportError:
    print("  matplotlib not installed -- skipping.")
except Exception as e:
    print(f"  Viz skipped: {e}")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Example 19: Abel Transform and Inversion
============================================================

[1] Forward Abel: Gaussian radial distribution -> projection
    f(r) = exp(-r^2/sigma^2),  F(y) = sigma*sqrt(pi)*exp(-y^2/sigma^2)
  Forward transform error vs analytic: 2.05e-04

[2] Abel inversion: projection -> radial distribution
  Onion peeling  error vs ground truth: 0.0000
  Three-point    error vs ground truth: 0.0009
  Peak (onion)  : f[0] = 1.0000  (true: 1.0000)
  Peak (3-point): f[0] = 0.9929  (true: 1.0000)

[3] Harder test: hollow sphere (bright ring)
    f(r) = ring at r=R with Gaussian width
  True ring center  : r = 8.000
  Onion peak        : r = 8.000  (err 0.0%)
  Three-point peak  : r = 8.000  (err 0.0%)

[4] Noise robustness (SNR ~ 50 added to projection)
  True peak: 8.000
  Noisy onion peak : 0.150  (err 98.1%)
  Noisy 3-pt  peak : 7.950  (err 0.6%)
  (Three-point typically less noisy than onion peeling at center)

[5] 2D image: simulated plasma emission (cylindrical symmetry)
  Image shape: (120, 201)
  abel_center() found: col = 100  (true: 100)
  Radial image shape: (120, 201)
  Ring peak at pixel (true: 25) -- 3pt: 25  onion: 25

[6] Round-trip: invert -> re-project -> compare with original
  Round-trip error (project(invert(image)) vs image): 0.0166
... (9 more lines)

Example 20: Space Dynamics, Attitude, and Mission Budgets¶

examples/ex20_space_dynamics.py: the space-focused RSQs added to the Anvil seed library.

import numpy as np

import anvil
from anvil import Q, System

W = 65
print("=" * W)
print("  Example 20: Space Dynamics, Attitude & Mission Budgets")
print("=" * W)

mu_E = 3.986004418e14
R_E = 6.371e6
J2 = 1.08263e-3
a_SSO = R_E + 500e3  # m
i_SSO = 97.4  # deg (SSO for 500 km)


# =============================================================================
# [1] Orbital state
# =============================================================================

print("\n[1] Orbital state: Keplerian <-> Cartesian")

eci = anvil.R.keplerian_to_cartesian(
    a=a_SSO,
    e=0.001,
    i_deg=i_SSO,
    RAAN_deg=45.0,
    omega_deg=90.0,
    nu_deg=0.0,
    mu=mu_E,
)
T_orbit = Q(2 * 3.141592653589793 * (a_SSO**3 / mu_E) ** 0.5, "s")
print(f"  r_mag  = {eci['r_mag']}")
print(f"  v_mag  = {eci['v_mag']}")
print(f"  period = {T_orbit}")

back = anvil.R.cartesian_to_keplerian(
    r_vec=eci["r_eci"],
    v_vec=eci["v_eci"],
    mu=mu_E,
)
print(f"  Round-trip:  a = {back['a']}  e = {back['e']:.5f}")
print(f"               i = {back['i_deg']:.3f} deg  RAAN = {back['RAAN_deg']:.3f} ")


# =============================================================================
# [2] J2 and eclipse
# =============================================================================

print("\n[2] J2 precession and eclipse at SSO")

j2 = anvil.R.j2_precession(a=a_SSO, e=0.001, i_deg=i_SSO)
print(f"  RAAN drift   : {j2['d_RAAN_dt']}  (SSO target ~+1.99e-7 rad/s)")
print(f"  omega drift  : {j2['d_omega_dt']}")

ecl_worst = anvil.R.eclipse_fraction(a=a_SSO, beta_deg=0.0)
ecl_best = anvil.R.eclipse_fraction(a=a_SSO, beta_deg=70.0)
print(f"  Eclipse worst (beta=0):   {ecl_worst['eclipse_frac']:.3f}")
print(f"  Eclipse best  (beta=70):  {ecl_best['eclipse_frac']:.3f}")
print(f"  Max-eclipse beta:         {ecl_worst['beta_max_deg']:.1f} deg")


# =============================================================================
# [3] Delta-V budget
# =============================================================================

print("\n[3] Delta-V budget")

dv = anvil.R.delta_v_budget(
    dv1=50,
    dv2=20,
    dv3=30,
    dv4=80,
    margin_pct=10.0,
)
print(f"  dv total (no margin)  : {dv['dv_total']}")
print(f"  dv with 10% margin    : {dv['dv_with_margin']}")

prop = anvil.R.propellant_mass(
    dv=dv["dv_with_margin"].si,
    Isp=220,
    m_dry=200,
)
print(f"  Propellant (Isp=220 s): {prop['m_propellant']}")
print(f"  Wet mass              : {prop['m_wet']}")
print(f"  Mass ratio            : {prop['mass_ratio']:.4f}")

print(f"\n  Propellant vs Isp (same dv):")
prop_sys = System("propulsion")
prop_sys.add("dv", dv["dv_with_margin"].si, "m/s")
prop_sys.add("Isp", 220, "s")
prop_sys.add("m_dry", 200, "kg")
prop_sys.use("propellant_mass")
prop_sys.sweep("Isp", [80, 150, 220, 300, 450, 3000]).summary(
    outputs=["Isp", "m_propellant", "mass_ratio"]
)


# =============================================================================
# [4] Gravity gradient torque and reaction wheel sizing
# =============================================================================

print("\n[4] Attitude disturbances and actuator sizing")

Ix, Iy, Iz = 8.0, 10.0, 12.0

gg = anvil.R.gravity_gradient_torque(
    mu=mu_E,
    r=a_SSO,
    Ix=Ix,
    Iy=Iy,
    Iz=Iz,
    theta_pitch_deg=1.0,
    phi_roll_deg=0.5,
)
print(f"  T_roll     : {gg['T_roll']}")
print(f"  T_pitch    : {gg['T_pitch']}")
print(f"  T_gg_max   : {gg['T_gg_max']}")

rw = anvil.R.reaction_wheel_sizing(
    I_sc=Iz,
    theta_slew_deg=5.0,
    t_slew=30.0,
    margin=1.5,
)
print(f"\n  5 deg slew in 30 s (1.5x margin):")
print(f"  H_rw       : {rw['H_rw']}")
print(f"  tau_rw     : {rw['tau_rw']}")
print(f"  omega_slew : {rw['omega_slew_max']}")
print(f"  P_peak     : {rw['P_peak']}")


# =============================================================================
# [5] Quaternion kinematics
# =============================================================================

print("\n[5] Quaternion kinematics -- 0.01 rad/s pitch for 10 steps")

dt = 1.0
q = [1.0, 0.0, 0.0, 0.0]
print(f"  {'t':>4}  {'q_w':>8}  {'q_x':>8}  {'q_y':>8}  {'q_z':>8}  {'|q|':>6}")
for step in range(11):
    if step % 2 == 0:
        print(
            f"  {step * dt:>4.0f}  {q[0]:>8.5f}  {q[1]:>8.5f}  {q[2]:>8.5f}  {q[3]:>8.5f}  "
            f"{sum(x**2 for x in q) ** 0.5:>6.4f}"
        )
    if step < 10:
        qd = anvil.R.quaternion_kinematics(
            q_w=q[0],
            q_x=q[1],
            q_y=q[2],
            q_z=q[3],
            omega_x=0.0,
            omega_y=0.01,
            omega_z=0.0,
        )
        q = [
            q[0] + qd["qw_dot"] * dt,
            q[1] + qd["qx_dot"] * dt,
            q[2] + qd["qy_dot"] * dt,
            q[3] + qd["qz_dot"] * dt,
        ]
        n = sum(x**2 for x in q) ** 0.5
        q = [x / n for x in q]


# =============================================================================
# [6] TRIAD attitude determination
# =============================================================================

print("\n[6] TRIAD attitude determination")

theta = np.radians(30)
C_true = np.array(
    [[np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1]]
)
sun_ref = np.array([0.8, 0.6, 0.0])
mag_ref = np.array([0.3, 0.0, 0.95])
sun_body = C_true @ sun_ref
mag_body = C_true @ mag_ref

tr = anvil.R.triad_attitude(
    b1_x=sun_body[0],
    b1_y=sun_body[1],
    b1_z=sun_body[2],
    b2_x=mag_body[0],
    b2_y=mag_body[1],
    b2_z=mag_body[2],
    r1_x=sun_ref[0],
    r1_y=sun_ref[1],
    r1_z=sun_ref[2],
    r2_x=mag_ref[0],
    r2_y=mag_ref[1],
    r2_z=mag_ref[2],
)
print(
    f"  True rotation: 30 deg about Z  ->  q_z = sin(15 deg) = {np.sin(np.radians(15)):.5f}"
)
print(
    f"  TRIAD q: w={tr['q_w']:.5f}  x={tr['q_x']:.5f}  y={tr['q_y']:.5f}  z={tr['q_z']:.5f}"
)


# =============================================================================
# [7] Euler equations
# =============================================================================

print("\n[7] Euler equations -- spin stability check")

for label, (Ix_, Iy_, Iz_), (ox, oy, oz) in [
    ("Major axis (stable)", (100, 80, 60), (0.01, 0.01, 1.0)),
    ("Minor axis (stable)", (60, 80, 100), (0.01, 0.01, 1.0)),
    ("Intermediate (unstable)", (60, 100, 80), (0.01, 0.01, 1.0)),
]:
    r = anvil.R.euler_equations(
        omega_x=ox, omega_y=oy, omega_z=oz, Ix=Ix_, Iy=Iy_, Iz=Iz_
    )
    alpha_perp = (r["alpha_x"].si ** 2 + r["alpha_y"].si ** 2) ** 0.5
    print(f"  {label:32s}: perp accel = {Q(alpha_perp, 'rad/s^2')}")


# =============================================================================
# [8] Power budget
# =============================================================================

print("\n[8] Power budget -- 200 kg EO smallsat")

pwr = anvil.R.power_budget(
    P_load_W=80,
    T_orbit_min=94.6,
    eclipse_frac=ecl_worst["eclipse_frac"],
    eta_solar=0.30,
    flux_solar=1361.0,
    DOD=0.8,
    eta_battery=0.9,
)
print(f"  Solar array  : {pwr['A_panel_m2']}")
print(f"  Battery      : {pwr['E_bat_Wh']}  /  {pwr['m_bat_kg']}")
print(f"  Panel output : {pwr['P_from_panel_W']}")

print(f"\n  Sensitivity: panel area vs eclipse fraction:")
pwr_sys = System("power_sizing")
pwr_sys.add("P_load_W", 80)
pwr_sys.add("T_orbit_min", 94.6)
pwr_sys.add("eclipse_frac", 0.35)
pwr_sys.add("eta_solar", 0.30)
pwr_sys.add("flux_solar", 1361.0)
pwr_sys.add("DOD", 0.8)
pwr_sys.add("eta_battery", 0.9)
pwr_sys.use("power_budget")
pwr_sys.sweep("eclipse_frac", np.linspace(0.1, 0.5, 5)).summary(
    outputs=["eclipse_frac", "A_panel_m2", "E_bat_Wh", "m_bat_kg"]
)


# =============================================================================
# [9] Link budget
# =============================================================================

print("\n[9] Link budget -- X-band downlink at 500 km")

lnk = anvil.R.link_budget(
    P_tx_W=5,
    G_tx_dBi=3,
    G_rx_dBi=47,
    freq_Hz=8.4e9,
    distance_m=a_SSO,
    losses_dB=4.0,
)
print(f"  FSPL         : {lnk['FSPL_dB']:.1f} dB")
print(f"  EIRP         : {lnk['EIRP_dBW']:.1f} dBW")
print(f"  P_rx         : {lnk['P_rx_dBW']:.1f} dBW  =  {lnk['P_rx_W']}")

print(f"\n  Range sweep:")
print(f"  {'Range (km)':>12}  {'FSPL (dB)':>10}  {'P_rx (dBW)':>12}")
for d_km in [400, 600, 800, 1200, 2000]:
    r = anvil.R.link_budget(
        P_tx_W=5,
        G_tx_dBi=3,
        G_rx_dBi=47,
        freq_Hz=8.4e9,
        distance_m=d_km * 1e3,
        losses_dB=4.0,
    )
    print(f"  {d_km:>12}  {r['FSPL_dB']:>10.1f}  {r['P_rx_dBW']:>12.1f}")


# =============================================================================
# [10] Attitude controller analysis
# =============================================================================

print("\n[10] Attitude controller analysis (pitch PD)")

Kp, Kd = 0.15, 1.2
omega_n = (Kp / Iz) ** 0.5
zeta = Kd / (2 * (Kp * Iz) ** 0.5)

m = anvil.R.second_order_metrics(omega_n=omega_n, zeta=zeta)
print(f"  omega_n = {Q(omega_n, 'rad/s')}  zeta = {zeta:.4f}")
print(
    f"  Overshoot: {m['overshoot_pct']:.1f}%   t_settle: {m['t_settle']:.1f} s   t_rise: {m['t_rise']:.1f} s"
)

poles = anvil.R.state_space_poles(A_flat=[0, 1, -Kp / Iz, -Kd / Iz], n_states=2)
print(
    f"  Poles: {[complex(round(r, 4), round(i, 4)) for r, i in zip(poles['poles_real'], poles['poles_imag'])]}"
)
print(f"  Stable: {poles['stable']}  min damping: {poles['min_damping']:.4f}")

gm = anvil.R.gain_phase_margin(num_coeffs=[Kd, Kp], den_coeffs=[Iz, 0, 0])
print(
    f"  GM = {gm['GM_dB']:.1f} dB   PM = {gm['PM_deg']:.1f} deg   stable = {gm['stable']}"
)

q_lqr = anvil.R.lqr_bryson(
    state_bounds=[np.radians(5)] * 3,
    input_bounds=[0.5] * 3,
)
print(
    f"  LQR Q = {[round(x, 1) for x in q_lqr['Q_diag']]}  R = {[round(x, 2) for x in q_lqr['R_diag']]}"
)


# =============================================================================
# [11] Sphere of influence
# =============================================================================

print("\n[11] Sphere of influence")

soi_moon = anvil.R.sphere_of_influence(
    a_body=384400e3, m_body=7.342e22, m_parent=5.972e24
)
soi_mars = anvil.R.sphere_of_influence(
    a_body=1.524 * 1.496e11, m_body=6.390e23, m_parent=1.989e30
)
print(f"  Moon SOI : {soi_moon['r_SOI'].to('km')}  (expected 66100 km)")
print(f"  Mars SOI : {soi_mars['r_SOI'].to('km')}  (expected ~577000 km)")


# =============================================================================
# [12] Hohmann vs bi-elliptic
# =============================================================================

print("\n[12] Hohmann vs bi-elliptic  LEO -> GEO")

h = anvil.R.hohmann_transfer(mu=mu_E, r1=R_E + 400e3, r2=42164e3)
print(f"  Hohmann:   dv = {h['dv_total'].to('km/s')}   tof = {h['tof']}")

for r_b_km in [100_000, 200_000, 384_400]:
    be = anvil.R.bielliptic_transfer(
        mu=mu_E, r1=R_E + 400e3, r2=42164e3, rb=r_b_km * 1e3
    )
    print(
        f"  Bi-elliptic rb={r_b_km:7d} km:  dv = {be['dv_total'].to('km/s')}   tof = {be['tof']}"
    )


print("\n" + "=" * W)
print("  Done.")
print("=" * W)

Output:

=================================================================
  Example 20: Space Dynamics, Attitude & Mission Budgets
=================================================================

[1] Orbital state: Keplerian <-> Cartesian
  r_mag  = 6.8641e+06 m
  v_mag  = 7624.18 m/s
  period = 5668.14 s
  Round-trip:  a = 6.8710e+06 m  e = 0.00100
               i = 97.400 deg  RAAN = 45.000 

[2] J2 precession and eclipse at SSO
  RAAN drift   : 1.9934e-07 rad/s  (SSO target ~+1.99e-7 rad/s)
  omega drift  : -7.0967e-07 rad/s
  Eclipse worst (beta=0):   0.378
  Eclipse best  (beta=70):  0.000
  Max-eclipse beta:         68.0 deg

[3] Delta-V budget
  dv total (no margin)  : 180.00 m/s
  dv with 10% margin    : 198.00 m/s
  Propellant (Isp=220 s): 19.2235 kg
  Wet mass              : 219.22 kg
  Mass ratio            : 1.0961

  Propellant vs Isp (same dv):

----------------------------------------------------------------------
  propulsion -- sweep over Isp
----------------------------------------------------------------------
             Isp           Isp  m_propellant    mass_ratio
             [s]           [s]          [kg]              
  --------------------------------------------------------
              80            80         57.42         1.287
... (90 more lines)

Example 21: poliastro Adapter -- Orbit Design in Anvil¶

examples/ex21_poliastro_adapter.py: the poliastro adapter for orbit state, Hohmann transfers,

import os
import math
import numpy as np


import anvil
from anvil import Q

from anvil.adapters import poliastro_orbits
from anvil.adapters.poliastro_orbits import (
    poliastro_orbit, poliastro_hohmann, poliastro_propagate, register
)

W = 64
R_E   = 6371e3      # m
MU_E  = 3.986004418e14

print("=" * W)
print("  Example 21: poliastro Adapter")
print("=" * W)

if not poliastro_orbits.is_available():
    print("  poliastro not installed -- skipping example.")
    print("  Install: pip install poliastro astropy")
    raise SystemExit(0)

import poliastro
print(f"  poliastro {poliastro.__version__} found.")
print()


# ── 1. Direct adapter calls ───────────────────────────────────────────────────
print("[1] Orbit state -- direct adapter calls")

orbits = [
    ("ISS / LEO",  R_E + 407e3,  0.0000, math.radians(51.6)),
    ("GTO",        24396e3,      0.7311, math.radians(27.0)),
    ("GEO",        42164e3,      0.0000, math.radians(0.0)),
    ("Polar LEO",  R_E + 500e3,  0.0000, math.radians(98.0)),
]

print(f"  {'Label':14s}  {'a (km)':>10s}  {'ecc':>6s}  {'T (min)':>8s}  {'v (m/s)':>9s}")
print(f"  {'-'*14}  {'-'*10}  {'-'*6}  {'-'*8}  {'-'*9}")
for label, a, ecc, inc in orbits:
    r = poliastro_orbit(a=a, ecc=ecc, inc=inc, raan=0.0, argp=0.0, nu=0.0)
    print(f"  {label:14s}  {a/1e3:10.1f}  {ecc:6.4f}  "
          f"{r['period'].to('min')}  {r['v_mag']}")


# ── 2. Hohmann transfers ──────────────────────────────────────────────────────
print(f"\n[2] Hohmann transfers")

transfers = [
    ("LEO 200km -> GEO",       R_E + 200e3,  42164e3),
    ("LEO 200km -> Moon dist", R_E + 200e3,  384400e3),
    ("LEO 400km -> LEO 600km", R_E + 400e3,  R_E + 600e3),
]

print(f"  {'Transfer':28s}  {'dv1 (m/s)':>10s}  {'dv2 (m/s)':>10s}  "
      f"{'total (m/s)':>11s}  {'TOF (h)':>8s}")
print(f"  {'-'*28}  {'-'*10}  {'-'*10}  {'-'*11}  {'-'*8}")
for label, a_i, a_f in transfers:
    r = poliastro_hohmann(a_i=a_i, a_f=a_f)
    print(f"  {label:28s}  {r['dv_1']}  {r['dv_2']}  "
          f"  {r['dv_total']}  {r['t_transfer'].to('hr')}")


# ── 3. Orbit System -- chain with propellant_mass RSQ ────────────────────────
print(f"\n[3] LEO->GEO mission budget (orbit + propellant_mass in one System)")

register()   # push adapters to global registry so sys.use() can find them

mission = anvil.system("leo_geo_mission")
mission.add("a_i",   R_E + 200e3, "m",   desc="Departure orbit radius")
mission.add("a_f",   42164e3,     "m",   desc="Target orbit radius (GEO)")
mission.add("Isp",   450.0,       "s",   desc="Engine specific impulse")
mission.add("m_wet", 5000.0,      "kg",  desc="Spacecraft wet mass")
mission.add("g0",    9.80665,     "m/s^2")

mission.use(poliastro_hohmann)          # Adapter object directly (not registry lookup)

def rocket_budget(dv_total, Isp, g0, m_wet):
    """Tsiolkovsky: given m_wet, compute propellant and dry mass."""
    mr = math.exp(dv_total / (Isp * g0))
    m_prop = m_wet * (1.0 - 1.0 / mr)
    return {"m_prop": Q(m_prop, "kg"), "m_dry": Q(m_wet - m_prop, "kg"), "mass_ratio": mr}
mission.use(rocket_budget)

result = mission.solve_forward()
result.summary(keys=["a_i", "a_f", "dv_total", "t_transfer", "m_prop", "m_dry"])

print(f"\n  Total dv   = {result['dv_total'].to('km/s')}")
print(f"  Transfer   = {result['t_transfer'].to('hr')}")
print(f"  Propellant = {result['m_prop']}  "
      f"({result['m_prop'].value / 5000.0 * 100:.1f}% of wet mass)")
print(f"  Dry mass   = {result['m_dry']}")


# ── 4. Sweep: transfer dv vs target altitude ──────────────────────────────────
print(f"\n[4] Sweep: transfer dv vs target altitude (100 km to 42 164 km)")

altitudes_km = np.linspace(100, 42164, 8)
print(f"  {'Alt (km)':>10s}  {'dv_total (m/s)':>15s}  {'TOF (h)':>8s}")
print(f"  {'-'*10}  {'-'*15}  {'-'*8}")
for alt in altitudes_km:
    r = poliastro_hohmann(a_i=R_E + 200e3, a_f=R_E + alt * 1e3)
    print(f"  {alt:10.0f}  {r['dv_total']}  "
          f"{r['t_transfer'].to('hr')}")


# ── 5. Sensitivity analysis ───────────────────────────────────────────────────
print(f"\n[5] Sensitivity: what drives orbital speed in LEO?")

leo_sys = anvil.system("leo_orbit")
leo_sys.add("a",    R_E + 400e3, "m")
leo_sys.add("ecc",  0.0)
leo_sys.add("inc",  math.radians(51.6), "rad")
leo_sys.add("raan", 0.0, "rad")
leo_sys.add("argp", 0.0, "rad")
leo_sys.add("nu",   0.0, "rad")
leo_sys.use(poliastro_orbit)            # Adapter object directly

sens = leo_sys.sensitivity(outputs=["v_mag", "period"])
print(f"\n  Top drivers of orbital speed (v_mag):")
for inp, val in sens.top("v_mag", n=4):
    print(f"    {inp:8s}  {val:+.4f}")

print(f"\n  Top drivers of orbital period:")
for inp, val in sens.top("period", n=4):
    print(f"    {inp:8s}  {val:+.4f}")


# ── 6. Propagation checks ─────────────────────────────────────────────────────
print(f"\n[6] Propagation checks (circular LEO, 400 km)")

a0 = R_E + 400e3
T_s = poliastro_orbit(a=a0, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
T   = T_s["period"].si

print(f"  Orbital period = {T/60:.2f} min")
print(f"  {'Fraction':12s}  {'nu_f (deg)':>12s}  {'r_mag (km)':>12s}")
print(f"  {'-'*12}  {'-'*12}  {'-'*12}")
for frac, label in [(0.25, "T/4"), (0.5, "T/2"), (1.0, "T")]:
    r = poliastro_propagate(a=a0, ecc=0.0, inc=0.0, raan=0.0,
                             argp=0.0, nu=0.0, dt=T * frac)
    r_km = math.sqrt(r["r_x"].si**2 + r["r_y"].si**2 + r["r_z"].si**2) / 1e3
    print(f"  {label:12s}  {math.degrees(r['nu_f'].si):12.2f}  {r_km:12.1f}")


# ── 7. Eccentric orbit: GTO propagation ──────────────────────────────────────
print(f"\n[7] GTO propagation -- quarter period from perigee")

# GTO: 200 km x 35786 km
a_gto  = (R_E + 200e3 + 42164e3) / 2
ecc_gto = (42164e3 - (R_E + 200e3)) / (42164e3 + R_E + 200e3)
T_gto = poliastro_orbit(a=a_gto, ecc=ecc_gto, inc=0.0,
                         raan=0.0, argp=0.0, nu=0.0)["period"].si

print(f"  a_gto   = {a_gto/1e6:.3f} Mm,  ecc = {ecc_gto:.4f}")
print(f"  r_p     = {(R_E + 200e3)/1e6:.3f} Mm (perigee)")
print(f"  r_a     = {42164e3/1e6:.3f} Mm (apogee)")
print(f"  period  = {T_gto/3600:.2f} h")

rp = poliastro_propagate(a=a_gto, ecc=ecc_gto, inc=0.0,
                          raan=0.0, argp=0.0, nu=0.0,
                          dt=T_gto / 2)
r_apo = math.sqrt(rp["r_x"].si**2 + rp["r_y"].si**2 + rp["r_z"].si**2)
print(f"\n  After T/2 (at apogee):")
print(f"  r_mag   = {r_apo/1e6:.3f} Mm  (expect {42164e3/1e6:.3f} Mm)")

print(f"\n{'='*W}")
print("  Done.")
print(f"{'='*W}")

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example 22: pykep Adapter -- Trajectory Design in Anvil¶

examples/ex22_pykep_adapter.py: the pykep adapter for Lambert arc solutions, Keplerian

import os
import math
import numpy as np


import anvil
from anvil import Q

from anvil.adapters import pykep_trajectories
from anvil.adapters.pykep_trajectories import (
    pykep_lambert, pykep_propagate, pykep_planet_state, register
)

W      = 64
AU     = 1.495978707e11
MU_SUN = 1.32712440018e20

print("=" * W)
print("  Example 22: pykep Adapter")
print("=" * W)

if not pykep_trajectories.is_available():
    print("  pykep not installed -- skipping example.")
    print("  Install: pip install pykep")
    raise SystemExit(0)

import pykep
print(f"  pykep {pykep.__version__} found.")
print()


# ── 1. Planet states ──────────────────────────────────────────────────────────
print("[1] Planet states at J2000 (epoch = 0 MJD2000)")

planets = ["mercury", "venus", "earth", "mars", "jupiter"]
print(f"  {'Planet':10s}  {'|r| (AU)':>10s}  {'|v| (km/s)':>12s}")
print(f"  {'-'*10}  {'-'*10}  {'-'*12}")
for planet in planets:
    r = pykep_planet_state(planet=planet, epoch_mjd2000=0.0)
    print(f"  {planet:10s}  {r['r_mag'].value / AU:10.4f}  "
          f"{r['v_mag'].value / 1e3:12.3f}")


# ── 2. Propagate Earth 1 year -- closure check ───────────────────────────────
print(f"\n[2] Propagate Earth state by 1 year (expect |r| ~ 1 AU at end)")

r_e0 = pykep_planet_state(planet="earth", epoch_mjd2000=0.0)
r_e1 = pykep_propagate(
    r_x=r_e0["r_x"].si, r_y=r_e0["r_y"].si, r_z=r_e0["r_z"].si,
    v_x=r_e0["v_x"].si, v_y=r_e0["v_y"].si, v_z=r_e0["v_z"].si,
    dt=365.25 * 86400,
    mu=MU_SUN,
)
print(f"  |r| at t=0   : {r_e0['r_mag'].value / AU:.5f} AU")
print(f"  |r| at t=1yr : {r_e1['r_mag_f'].value / AU:.5f} AU  (expect ~1.00000)")

# Cross-check: Earth state 1 year later from ephemeris
r_e1_eph = pykep_planet_state(planet="earth", epoch_mjd2000=365.25)
dr = math.sqrt(
    (r_e1["r_x_f"].si - r_e1_eph["r_x"].si)**2 +
    (r_e1["r_y_f"].si - r_e1_eph["r_y"].si)**2 +
    (r_e1["r_z_f"].si - r_e1_eph["r_z"].si)**2
)
print(f"  Closure error : {dr/AU:.4f} AU  (two-body propagation vs JPL low-precision ephemeris)")


# ── 3. Lambert arc Earth->Mars ────────────────────────────────────────────────
print(f"\n[3] Lambert arc Earth->Mars at J2000 positions, tof=200 days")

r_earth = pykep_planet_state(planet="earth", epoch_mjd2000=0.0)
r_mars  = pykep_planet_state(planet="mars",  epoch_mjd2000=200.0)  # Mars 200 days later

sol = pykep_lambert(
    r0_x=r_earth["r_x"].si, r0_y=r_earth["r_y"].si, r0_z=r_earth["r_z"].si,
    r1_x=r_mars["r_x"].si,  r1_y=r_mars["r_y"].si,  r1_z=r_mars["r_z"].si,
    tof=200 * 86400,
)
print(f"  Departure v   : ({sol['v_dep_x'].value/1e3:.3f}, "
      f"{sol['v_dep_y'].value/1e3:.3f}, {sol['v_dep_z'].value/1e3:.3f}) km/s")
print(f"  Arrival v     : ({sol['v_arr_x'].value/1e3:.3f}, "
      f"{sol['v_arr_y'].value/1e3:.3f}, {sol['v_arr_z'].value/1e3:.3f}) km/s")
print(f"  |v_dep|       : {sol['dv_dep'].value/1e3:.3f} km/s")
print(f"  |v_arr|       : {sol['dv_arr'].value/1e3:.3f} km/s")

# Verify arc: propagate departure state forward tof
r_check = pykep_propagate(
    r_x=r_earth["r_x"].si, r_y=r_earth["r_y"].si, r_z=r_earth["r_z"].si,
    v_x=sol["v_dep_x"].si, v_y=sol["v_dep_y"].si, v_z=sol["v_dep_z"].si,
    dt=200 * 86400, mu=MU_SUN,
)
err_m = math.sqrt(
    (r_check["r_x_f"].si - r_mars["r_x"].si)**2 +
    (r_check["r_y_f"].si - r_mars["r_y"].si)**2 +
    (r_check["r_z_f"].si - r_mars["r_z"].si)**2
)
print(f"  Arc closure   : {err_m/1e3:.3f} km  (should be ~0)")


# ── 4. Delta-v budget System: planet state + Lambert ─────────────────────────
print(f"\n[4] Delta-v budget System (planet state + Lambert in one solve)")

register()  # push pykep adapters to global registry

traj = anvil.system("earth_mars_transfer")
traj.add("epoch_dep", 0.0,        desc="Departure epoch (MJD2000 days)")
traj.add("tof",       200.0 * 86400, "s", desc="Time of flight")

# Earth departure state
def earth_state(epoch_dep):
    return pykep_planet_state(planet="earth", epoch_mjd2000=epoch_dep)
traj.use(earth_state)

# Mars arrival state (epoch_dep + tof)
def mars_arrival(epoch_dep, tof):
    return pykep_planet_state(
        planet="mars",
        epoch_mjd2000=epoch_dep + tof / 86400
    )
traj.use(mars_arrival, outputs={"r_x": "r_x_m", "r_y": "r_y_m", "r_z": "r_z_m",
                                  "v_x": "v_x_m", "v_y": "v_y_m", "v_z": "v_z_m",
                                  "r_mag": "r_mag_m", "v_mag": "v_mag_m"})

# Lambert
def lambert_transfer(r_x, r_y, r_z, r_x_m, r_y_m, r_z_m, tof):
    return pykep_lambert(
        r0_x=r_x, r0_y=r_y, r0_z=r_z,
        r1_x=r_x_m, r1_y=r_y_m, r1_z=r_z_m,
        tof=tof,
    )
traj.use(lambert_transfer)

res = traj.solve_forward()
print(f"  Departure  : epoch {res['epoch_dep']}")
print(f"  TOF        : {res['tof'].si / 86400:.1f} days")
print(f"  dv_dep     : {res['dv_dep'].value/1e3:.3f} km/s")
print(f"  dv_arr     : {res['dv_arr'].value/1e3:.3f} km/s")
print(f"  dv_total   : {res['dv_total'].value/1e3:.3f} km/s")


# ── 5. Sweep: tof scan (mini porkchop column) ─────────────────────────────────
print(f"\n[5] TOF sweep (Earth->Mars, 100-350 days) -- departure at J2000")

r_earth = pykep_planet_state(planet="earth", epoch_mjd2000=0.0)
tofs_days = np.linspace(100, 350, 6)

print(f"  {'TOF (days)':>10s}  {'dv_dep (km/s)':>14s}  "
      f"{'dv_arr (km/s)':>14s}  {'dv_total (km/s)':>16s}")
print(f"  {'-'*10}  {'-'*14}  {'-'*14}  {'-'*16}")
for tof_d in tofs_days:
    r_mars = pykep_planet_state(planet="mars", epoch_mjd2000=tof_d)
    try:
        s = pykep_lambert(
            r0_x=r_earth["r_x"].si, r0_y=r_earth["r_y"].si,
            r0_z=r_earth["r_z"].si,
            r1_x=r_mars["r_x"].si,  r1_y=r_mars["r_y"].si,
            r1_z=r_mars["r_z"].si,
            tof=tof_d * 86400,
        )
        print(f"  {tof_d:10.0f}  {s['dv_dep'].value/1e3:14.3f}  "
              f"{s['dv_arr'].value/1e3:14.3f}  {s['dv_total'].value/1e3:16.3f}")
    except Exception as e:
        print(f"  {tof_d:10.0f}  {'error: '+str(e)[:40]}")


# ── 6. Combined: LEO departure + interplanetary arc ───────────────────────────
print(f"\n[6] Complete mission: LEO parking + escape + interplanetary arc")
print(f"    (patched-conic escape burn + pykep Lambert arc)")

R_E = 6371e3
MU_E = 3.986004418e14
V_EARTH_HELIO = r_earth["v_mag"].si   # Earth's heliocentric speed

# Lambert departure velocity vector magnitude at Earth
r_earth = pykep_planet_state(planet="earth", epoch_mjd2000=0.0)
r_mars_200 = pykep_planet_state(planet="mars", epoch_mjd2000=200.0)
sol = pykep_lambert(
    r0_x=r_earth["r_x"].si, r0_y=r_earth["r_y"].si, r0_z=r_earth["r_z"].si,
    r1_x=r_mars_200["r_x"].si, r1_y=r_mars_200["r_y"].si,
    r1_z=r_mars_200["r_z"].si,
    tof=200 * 86400,
)

# Hyperbolic excess velocity at Earth departure
v_dep = sol["dv_dep"].si      # heliocentric departure speed (m/s)
v_inf = abs(v_dep - V_EARTH_HELIO)   # rough estimate (assumes co-linear)

# Escape dv from 200 km LEO: v_esc^2 = v_circ^2 + v_inf^2
# v_park = sqrt(mu/r), v_hyp = sqrt(v_park^2 + v_inf^2)
r_park = R_E + 200e3
v_park = math.sqrt(MU_E / r_park)
v_hyp  = math.sqrt(v_park**2 + v_inf**2)
dv_escape = v_hyp - v_park

print(f"  Earth heliocentric speed  : {V_EARTH_HELIO/1e3:.3f} km/s")
print(f"  Lambert departure speed   : {v_dep/1e3:.3f} km/s")
print(f"  Hyperbolic excess v_inf   : {v_inf/1e3:.3f} km/s  (co-linear approx)")
print(f"  LEO circular speed (200km): {v_park/1e3:.3f} km/s")
print(f"  Escape burn dv            : {dv_escape/1e3:.3f} km/s")
print(f"  Lambert arc total dv      : {sol['dv_total'].value/1e3:.3f} km/s")
print(f"  Dominant cost: escape burn from LEO to interplanetary")

print(f"\n{'='*W}")
print("  Done.")
print(f"{'='*W}")

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: 2D Euler CFD, Subsonic flow over a Gaussian bump¶

examples/ex_cfd_subsonic_bump.py: M = 0.5 air flow through a channel with a Gaussian bump on the lower wall.

import sys, os

import numpy as np
import anvil
from anvil.cfd import CFDSolver, Mesh, MeshPatch, viz as cfd_viz
from anvil.cfd.bc import SubsonicInlet, SubsonicOutlet, SlipWall
from anvil.seed import seed; seed(force=True)
from anvil.registry import _rebuild_namespaces; _rebuild_namespaces()

# ─────────────────────────────────────────────────────────────────
# Parameters
# ─────────────────────────────────────────────────────────────────
M_inlet  = 0.5
gamma    = 1.4
R_gas    = 287.058
p0_inlet = 110_000.0   # Pa  (total pressure)
T0_inlet = 310.0       # K   (total temperature)

# Isentropic static conditions at inlet Mach 0.5
fac       = 1.0 + 0.5*(gamma-1)*M_inlet**2
p_inlet   = p0_inlet / fac**(gamma/(gamma-1))
T_inlet   = T0_inlet / fac
rho_inlet = p_inlet / (R_gas * T_inlet)
p_back    = p_inlet   # outlet back pressure = inlet static (isentropic channel, no net loss)

print("=" * 60)
print("  Subsonic Gaussian-bump channel  (M = 0.5)")
print("=" * 60)
print(f"\n  Inlet:  p0={p0_inlet:.0f} Pa  T0={T0_inlet:.1f} K  M={M_inlet}")
print(f"  Static: p={p_inlet:.1f} Pa  T={T_inlet:.2f} K  rho={rho_inlet:.4f} kg/m³")

# ─────────────────────────────────────────────────────────────────
# PART 1, Build mesh and write .amesh file
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 1: Mesh generation and .amesh file I/O")
print("=" * 60)

out_dir = os.path.dirname(os.path.abspath(__file__))

mesh = Mesh.bump(
    length      = 2.0,
    height      = 0.5,
    nx          = 80,
    ny          = 30,
    bump_height = 0.10,
    bump_x0     = 1.0,
    bump_sigma  = 0.20,
    title       = "subsonic_bump",
    patches     = {
        "inlet":   MeshPatch("left",  0, 30),
        "outlet":  MeshPatch("right",  0, 30),
        "wall":    MeshPatch("bottom", 0, 80),
        "ceiling": MeshPatch("top", 0, 80),
    }
)

# Write then re-read the mesh (demonstrates file I/O)
amesh_path = os.path.join(out_dir, "bump.amesh")
mesh.to_file(amesh_path)
print(f"\n  Mesh written to: {amesh_path}")

mesh = Mesh.from_file(amesh_path)
mesh.info()

# Visualise mesh (saved to PNG, not shown interactively)
mesh_png = os.path.join(out_dir, "bump_mesh.png")
mesh.plot(show=False, save_path=mesh_png, show_patches=True, show_mesh=True)
print(f"  Mesh plot saved: {mesh_png}")

# ─────────────────────────────────────────────────────────────────
# PART 2, Solver setup
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 2: Solver setup and run")
print("=" * 60)

bcs = {
    "inlet":   SubsonicInlet(M=M_inlet, p0=p0_inlet, T0=T0_inlet, gamma=gamma, R_gas=R_gas),
    "outlet":  SubsonicOutlet(p_back=p_back, gamma=gamma),
    "wall":    SlipWall(),
    "ceiling": SlipWall(),
}

solver = CFDSolver(
    mesh        = mesh,
    bcs         = bcs,
    gamma       = gamma,
    R_gas       = R_gas,
    flux_scheme = "roe",
    order       = 2,
    time_scheme = "rk4",
    cfl         = 0.3,
    transient   = False,
)
solver.initialize(M=M_inlet, p=p_inlet, T=T_inlet)

snap_dir = os.path.join(out_dir, "bump_snapshots")
print(f"\n  Running ({mesh.nx}×{mesh.ny} cells, M={M_inlet}, 2nd-order Roe)...")
print(f"  Saving Mach contour snapshots to: {snap_dir}/")

result = solver.run(
    max_iter    = 3000,
    tol         = 1e-3,    # subsonic fixed-ghost inlet converges slowly
    monitor     = True,
    verbose     = True,
    print_every = 300,
    save_every  = 600,      # save PNG every 600 iterations
    save_field  = "M",
    save_dir    = snap_dir,
    save_vmin   = 0.3,      # fixed scale -- all frames comparable
    save_vmax   = 1.0,
)

result.summary()

# ─────────────────────────────────────────────────────────────────
# PART 3, Post-processing
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 3: Post-processing and comparison")
print("=" * 60)

# Centreline Mach number
j_mid   = mesh.ny // 2
M_up    = result.M[:mesh.nx // 2, j_mid].mean()
M_crest = result.M[mesh.nx // 2 - 5 : mesh.nx // 2 + 5, 0].mean()  # near wall at crest
p_crest = result.p[mesh.nx // 2 - 5 : mesh.nx // 2 + 5, 0].mean()
p_down  = result.p[mesh.nx // 2:, j_mid].mean()

print(f"\n  Upstream M (avg):     {M_up:.4f}  (inlet target: {M_inlet})")
print(f"  M near bump crest:    {M_crest:.4f}  (expected > inlet M)")
print(f"  p near bump crest:    {p_crest:.1f} Pa  (expected < {p_inlet:.1f} Pa)")
print(f"  p downstream (avg):   {p_down:.1f} Pa  (expected ~ {p_back:.1f} Pa)")
print(f"  p/p_back at outlet:   {p_down/p_back:.4f}  (ideal: 1.0000)")

# Multi-field contour panel
panel_png = os.path.join(out_dir, "bump_fields.png")
cfd_viz.multi_field(result, fields=["M", "p", "T", "rho"],
                    show=False, save_path=panel_png)
print(f"\n  Multi-field plot saved: {panel_png}")

# Residual convergence
conv_png = os.path.join(out_dir, "bump_convergence.png")
cfd_viz.convergence_png(result.history, conv_png, title="Bump: residual convergence")
print(f"  Convergence plot saved: {conv_png}")

# VTK for ParaView
vtk_path = os.path.join(out_dir, "bump_flow.vtk")
result.to_vtk(vtk_path)
print(f"  VTK output saved: {vtk_path}")

# ─────────────────────────────────────────────────────────────────
# PART 4, Mach sweep via as_relation() + parallel
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 4: Mach sweep (parallel=2) using solver as Anvil Relation")
print("=" * 60)

sweep_mesh = Mesh.bump(
    length=2.0, height=0.5, nx=40, ny=15,
    bump_height=0.10, bump_x0=1.0, bump_sigma=0.20,
    patches={
        "inlet":   MeshPatch("left",  0, 15),
        "outlet":  MeshPatch("right",  0, 15),
        "wall":    MeshPatch("bottom", 0, 40),
        "ceiling": MeshPatch("top", 0, 40),
    }
)

def bump_bcs(M, p0, T0, alpha=0.0):
    g = gamma; R = R_gas
    fac = 1.0 + 0.5*(g-1)*M**2
    p_s = p0 / fac**(g/(g-1))   # isentropic static at inlet M
    return {
        "inlet":   SubsonicInlet(M=M, p0=p0, T0=T0, gamma=g, R_gas=R),
        "outlet":  SubsonicOutlet(p_back=p_s, gamma=g),  # matched back pressure
        "wall":    SlipWall(),
        "ceiling": SlipWall(),
    }

sweep_solver = CFDSolver(
    mesh=sweep_mesh, bcs=bump_bcs(0.5, p0_inlet, T0_inlet),
    gamma=gamma, R_gas=R_gas, flux_scheme="roe", order=2, cfl=0.3
)

cfd_rel = sweep_solver.as_relation(
    inputs     = ["M_inf", "p_inf", "T_inf"],
    outputs    = ["M_max", "p_wall"],
    name       = "bump_euler",
    bc_factory = bump_bcs,
    run_kwargs = {"max_iter": 1000, "tol": 1e-3, "verbose": False},
)

sweep_sys = anvil.system("bump_mach_sweep")
sweep_sys.add("M_inf", 0.5)
sweep_sys.add("p_inf",  p0_inlet)
sweep_sys.add("T_inf",  T0_inlet)
sweep_sys.use(cfd_rel)

mach_vals = np.array([0.3, 0.4, 0.5, 0.6])
print(f"\n  Sweeping M = {mach_vals}  (parallel=2)...")
sweep = sweep_sys.sweep("M_inf", mach_vals, parallel=2, skip_errors=True)
sweep.summary(outputs=["M_max", "p_wall"])

# ─────────────────────────────────────────────────────────────────
# Cleanup
# ─────────────────────────────────────────────────────────────────
for f in ["bump.amesh", "bump_flow.vtk"]:
    fp = os.path.join(out_dir, f)
    if os.path.exists(fp):
        os.remove(fp)

print("\n" + "=" * 60)
print("  Done. Output files:")
print(f"    {mesh_png}      , mesh + patch labels")
print(f"    {panel_png}     , M/p/T/rho contours")
print(f"    {conv_png}      , residual convergence")
print(f"    {snap_dir}/     , Mach snapshots every 500 iters")
print("=" * 60)

Runs a full solve that takes a while; run the script locally to see its output.

Example: 2D Euler CFD, Supersonic flow over a compression ramp¶

examples/ex_cfd_supersonic_ramp.py: M = 2.5 flow over a compression ramp (lower wall turns up 12deg).

import sys, os

import numpy as np
import anvil
from anvil.cfd import CFDSolver, Mesh, MeshPatch, viz as cfd_viz
from anvil.cfd.bc import SupersonicInlet, SupersonicOutlet, SlipWall
from anvil.seed import seed; seed(force=True)
from anvil.registry import _rebuild_namespaces; _rebuild_namespaces()

# ─────────────────────────────────────────────────────────────────
# Parameters
# ─────────────────────────────────────────────────────────────────
M_inf       = 2.5
theta_deg   = 12.0      # ramp angle
gamma       = 1.4
R_gas       = 287.058
p_inf       = 101_325.0
T_inf       = 300.0
length      = 2.0
height      = 0.6
ramp_x0     = 0.6       # ramp starts here

print("=" * 60)
print(f"  Supersonic compression ramp  (M={M_inf}, theta={theta_deg}deg)")
print("=" * 60)

# ─────────────────────────────────────────────────────────────────
# Analytical check (oblique shock at ramp foot)
# ─────────────────────────────────────────────────────────────────
r = anvil.R.oblique_shock(M1=M_inf, theta_deg=theta_deg, gamma=gamma)
print(f"\n  Analytical oblique shock (ramp foot):")
print(f"  Shock angle beta   = {r['beta_deg']:.3f} deg")
print(f"  Downstream M2   = {r['M2']:.4f}")
print(f"  p2/p1           = {r['p2_p1']:.4f}")
print(f"  Attached:         {r['attached']}")

if r['attached']:
    # Second shock: reflected off upper wall (same theta, M2 incoming)
    r2 = anvil.R.oblique_shock(M1=r['M2'], theta_deg=theta_deg, gamma=gamma)
    print(f"\n  Reflected shock (upper wall):")
    print(f"  M3 = {r2['M2']:.4f}   p3/p1 = {r['p2_p1']*r2['p2_p1']:.4f}")

# ─────────────────────────────────────────────────────────────────
# PART 1, Mesh
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 1: Mesh with named patches (flat_wall + ramp)")
print("=" * 60)

# nx cells; ramp starts at cell i_ramp
nx, ny = 80, 30
i_ramp = int(nx * ramp_x0 / length)   # first ramp cell

patches = {
    "inlet":     MeshPatch("left",  0, ny),
    "outlet":    MeshPatch("right",  0, ny),
    "flat_wall": MeshPatch("bottom", 0, i_ramp),
    "ramp":      MeshPatch("bottom", i_ramp, nx),
    "ceiling":   MeshPatch("top", 0, nx),
}

mesh = Mesh.compression_ramp(
    length          = length,
    height          = height,
    ramp_x0         = ramp_x0,
    ramp_angle_deg  = theta_deg,
    nx              = nx,
    ny              = ny,
    title           = "supersonic_ramp",
    patches         = patches,
)

out_dir = os.path.dirname(os.path.abspath(__file__))
amesh_path = os.path.join(out_dir, "ramp.amesh")
mesh.to_file(amesh_path)
mesh = Mesh.from_file(amesh_path)   # round-trip test
mesh.info()

mesh_png = os.path.join(out_dir, "ramp_mesh.png")
mesh.plot(show=False, save_path=mesh_png)
print(f"  Mesh plot saved: {mesh_png}")

# ─────────────────────────────────────────────────────────────────
# PART 2, Solve
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 2: Solver run")
print("=" * 60)

bcs = {
    "inlet":     SupersonicInlet(M=M_inf, p=p_inf, T=T_inf, gamma=gamma, R_gas=R_gas),
    "outlet":    SupersonicOutlet(),
    "flat_wall": SlipWall(),
    "ramp":      SlipWall(),
    "ceiling":   SlipWall(),
}

solver = CFDSolver(
    mesh        = mesh,
    bcs         = bcs,
    gamma       = gamma,
    R_gas       = R_gas,
    flux_scheme = "roe",
    order       = 2,
    time_scheme = "rk4",
    cfl         = 0.3,
    transient   = False,
)
solver.initialize(M=M_inf, p=p_inf, T=T_inf)

snap_dir = os.path.join(out_dir, "ramp_snapshots")
print(f"\n  Running ({mesh.nx}×{mesh.ny} cells, Roe, 2nd order)...")
result = solver.run(
    max_iter    = 2000,
    tol         = 1e-4,
    monitor     = True,
    verbose     = True,
    print_every = 250,
    save_every  = 500,
    save_field  = "p",
    save_dir    = snap_dir,
    save_vmin   = 100_000,   # fixed scale for all frames
    save_vmax   = 430_000,
)

result.summary()

# ─────────────────────────────────────────────────────────────────
# PART 3, Comparison
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 3: Analytical vs Numerical comparison")
print("=" * 60)

# Sample downstream region (right half, lower half, behind first shock)
ds_i = slice(nx // 2, nx)
ds_j = slice(0, ny // 4)

M_ds  = result.M[ds_i, ds_j].mean()
p_ds  = result.p[ds_i, ds_j].mean()

if r['attached']:
    print(f"\n  {'':25s}  {'Analytical':>12s}  {'Numerical':>12s}  {'Error':>8s}")
    M_an = r['M2']; p_an = p_inf * r['p2_p1']
    print(f"  {'p2 [Pa]':25s}  {p_an:>12.1f}  {p_ds:>12.1f}  "
          f"{abs(p_ds-p_an)/p_an*100:>7.2f}%")
    print(f"  {'M2':25s}  {M_an:>12.4f}  {M_ds:>12.4f}  "
          f"{abs(M_ds-M_an)/M_an*100:>7.2f}%")
    print("  (Numerical value is area-averaged; boundary-layer and mesh effects)")

# ─────────────────────────────────────────────────────────────────
# PART 4, Save output files
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 4: Output files")
print("=" * 60)

panel_png = os.path.join(out_dir, "ramp_fields.png")
cfd_viz.multi_field(result, fields=["M", "p", "T", "rho"],
                    show=False, save_path=panel_png)
print(f"  Multi-field plot: {panel_png}")

conv_png = os.path.join(out_dir, "ramp_convergence.png")
cfd_viz.convergence_png(result.history, conv_png, "Ramp: residual convergence")
print(f"  Convergence plot: {conv_png}")

vtk_path = os.path.join(out_dir, "ramp_flow.vtk")
result.to_vtk(vtk_path)
print(f"  VTK for ParaView: {vtk_path}")

# ─────────────────────────────────────────────────────────────────
# PART 5, Parallel ramp-angle sweep
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 5: Ramp-angle sweep (parallel=2)")
print("=" * 60)

def make_ramp_mesh(theta):
    i_r = int(40 * ramp_x0 / length)
    return Mesh.compression_ramp(
        length=length, height=height, ramp_x0=ramp_x0,
        ramp_angle_deg=theta, nx=40, ny=15,
        patches={
            "inlet":     MeshPatch("left",  0, 15),
            "outlet":    MeshPatch("right",  0, 15),
            "flat_wall": MeshPatch("bottom", 0, i_r),
            "ramp":      MeshPatch("bottom", i_r, 40),
            "ceiling":   MeshPatch("top", 0, 40),
        }
    )

# For the sweep we vary M_inf; theta is fixed at theta_deg
# (angle sweep would need a different relation wrapper)
_sweep_mesh = make_ramp_mesh(theta_deg)
_sweep_bcs  = {k: v for k, v in bcs.items()}   # reuse same BC types

def ramp_bcs(M, p, T, alpha=0.0):
    return {
        "inlet":     SupersonicInlet(M=M, p=p, T=T, gamma=gamma, R_gas=R_gas),
        "outlet":    SupersonicOutlet(),
        "flat_wall": SlipWall(),
        "ramp":      SlipWall(),
        "ceiling":   SlipWall(),
    }

sweep_solver = CFDSolver(
    mesh=_sweep_mesh, bcs=ramp_bcs(M_inf, p_inf, T_inf),
    gamma=gamma, R_gas=R_gas, flux_scheme="roe", order=2, cfl=0.3,
)
cfd_rel = sweep_solver.as_relation(
    inputs     = ["M_inf", "p_inf", "T_inf"],
    outputs    = ["M_max", "p_wall"],
    name       = "ramp_euler",
    bc_factory = ramp_bcs,
    run_kwargs = {"max_iter": 1000, "tol": 1e-3, "verbose": False},
)

sweep_sys = anvil.system("ramp_mach_sweep")
sweep_sys.add("M_inf", M_inf)
sweep_sys.add("p_inf", p_inf)
sweep_sys.add("T_inf", T_inf)
sweep_sys.use(cfd_rel)

mach_vals = np.array([2.0, 2.5, 3.0, 3.5])
print(f"\n  Sweeping M = {mach_vals}  (parallel=2) for theta = {theta_deg}deg...")
sweep = sweep_sys.sweep("M_inf", mach_vals, parallel=2, skip_errors=True)
sweep.summary(outputs=["M_max", "p_wall"])

# Analytical comparison
print("\n  Analytical comparison:")
print(f"  {'M_inf':>8s}  {'beta [deg]':>10s}  {'M2':>8s}  {'p2/p1':>8s}")
for M in mach_vals:
    ra = anvil.R.oblique_shock(M1=float(M), theta_deg=theta_deg, gamma=gamma)
    if ra["attached"]:
        print(f"  {M:>8.1f}  {ra['beta_deg']:>10.3f}  {ra['M2']:>8.4f}  {ra['p2_p1']:>8.4f}")
    else:
        print(f"  {M:>8.1f}  {'(detached)':>10s}  {'---':>8s}  {'---':>8s}")

# Cleanup
for f in ["ramp.amesh", "ramp_flow.vtk"]:
    fp = os.path.join(out_dir, f)
    if os.path.exists(fp): os.remove(fp)

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Runs a full solve that takes a while; run the script locally to see its output.

Example: 2D Euler CFD -- Supersonic flow over a wedge¶

examples/ex_cfd_wedge.py: Inviscid supersonic flow (M=2) over a 10-degree wedge.

import os


import numpy as np
import anvil
from anvil.cfd import CFDSolver, Mesh, MeshPatch
from anvil.cfd.bc import SupersonicInlet, SupersonicOutlet, SlipWall, Farfield
from anvil.seed import seed

seed(force=True)
from anvil.registry import _rebuild_namespaces

_rebuild_namespaces()

# Quick-reference: uncomment to see all CFDSolver parameters and outputs
# anvil.lookup("CFDSolver")
# anvil.lookup("bc")
# anvil.lookup("mesh")

# ─────────────────────────────────────────────────────────────────
# PART 1, Analytical oblique shock (exact solution)
# ─────────────────────────────────────────────────────────────────
print("=" * 60)
print("  PART 1: Analytical oblique shock solution")
print("=" * 60)

M_inf = 2.0
theta_deg = 10.0  # wedge half-angle
gamma = 1.4
p_inf = 101325.0  # Pa
T_inf = 300.0  # K
R_gas = 287.058  # J/kg/K

r = anvil.R.oblique_shock(M1=M_inf, theta_deg=theta_deg, gamma=gamma)
print(f"\n  Freestream M = {M_inf},  wedge half-angle = {theta_deg} deg")
print(f"  Shock attached: {r['attached']}")
print(
    f"  Shock angle beta = {r['beta_deg']:.3f} deg  (Mach angle = {np.degrees(np.arcsin(1 / M_inf)):.3f} deg)"
)
print(f"  Downstream M2   = {r['M2']:.4f}")
print(f"  p2/p1           = {r['p2_p1']:.4f}")
print(f"  T2/T1           = {r['T2_T1']:.4f}")
print(f"  rho2/rho1       = {r['rho2_rho1']:.4f}")

# Downstream conditions
p2 = p_inf * r["p2_p1"]
T2 = T_inf * r["T2_T1"]
rho2 = (p_inf / (R_gas * T_inf)) * r["rho2_rho1"]
print(f"\n  Downstream: p = {p2:.1f} Pa,  T = {T2:.1f} K,  M = {r['M2']:.4f}")

# ─────────────────────────────────────────────────────────────────
# PART 2, Numerical solution: anvil.cfd 2D Euler solver
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 2: Numerical 2D Euler solution")
print("=" * 60)

# Build body-fitted wedge mesh
# Domain: x in [0, 1] m,  y from wedge surface up to 0.6 m above
nx_main, ny_main = 80, 40
mesh = Mesh.wedge(
    half_angle_deg=theta_deg,
    chord=1.0,  # m
    height=0.6,  # m above wedge surface
    nx=nx_main,
    ny=ny_main,
    title="wedge_10deg",
    patches={
        "inlet":    MeshPatch("left",   0, ny_main),
        "outlet":   MeshPatch("right",  0, ny_main),
        "wall":     MeshPatch("bottom", 0, nx_main),
        "farfield": MeshPatch("top",    0, nx_main),
    },
)
mesh.info()

# Boundary conditions, use descriptive edge names (left/right/top/bottom)
bcs = {
    "inlet":   SupersonicInlet(M=M_inf, p=p_inf, T=T_inf, gamma=gamma, R_gas=R_gas),
    "outlet":  SupersonicOutlet(),
    "wall":    SlipWall(),
    "farfield": Farfield(M=M_inf, p=p_inf, T=T_inf, gamma=gamma, R_gas=R_gas),
}

# Solver: 2nd-order Roe with MUSCL, local time stepping, RK4
solver = CFDSolver(
    mesh=mesh,
    bcs=bcs,
    gamma=gamma,
    R_gas=R_gas,
    flux_scheme="roe",  # Roe approximate Riemann solver
    order=2,  # MUSCL + van Leer limiter
    time_scheme="rk4",  # 4-stage Runge-Kutta
    cfl=0.3,  # CFL number (0.3 recommended for 2nd order)
    transient=False,  # local time stepping for steady-state
)
solver.initialize(M=M_inf, p=p_inf, T=T_inf, alpha_deg=0.0)

# Run solver, watch residuals converge
print(f"\n  Running Euler solver ({mesh.nx}x{mesh.ny} cells, Roe flux, 2nd order)...")
out_dir = os.path.dirname(os.path.abspath(__file__))
result = solver.run(
    max_iter=3000,
    tol=1e-4,       # shock residual stagnates at ~1e-4 (truncation error)
    monitor=True,
    verbose=True,
    print_every=200,
    save_every=500,              # save PNG every 500 iters
    save_field="M",
    save_dir=os.path.join(out_dir, "wedge_snapshots"),
    save_vmin=1.4,               # fixed scale, all frames comparable
    save_vmax=2.1,
)

result.summary()

# ─────────────────────────────────────────────────────────────────
# PART 3, Comparison: analytical vs numerical
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 3: Analytical vs Numerical comparison")
print("=" * 60)

# Sample downstream region (right half of domain, above wall)
nx, ny = mesh.nx, mesh.ny
ds_i = slice(nx // 2, nx)  # downstream half
ds_j = slice(ny // 4, ny // 2)  # mid-domain height

M_ds = result.M[ds_i, ds_j].mean()
p_ds = result.p[ds_i, ds_j].mean()
T_ds = result.T[ds_i, ds_j].mean()

print(f"\n  Downstream region (x=[0.5,1.0], mid-height):")
print(f"  {'':20s}  {'Analytical':>12s}  {'Numerical':>12s}  {'Error %':>9s}")
print(
    f"  {'p2 [Pa]':20s}  {p2:>12.2f}  {p_ds:>12.2f}  {abs(p_ds - p2) / p2 * 100:>8.2f}%"
)
print(
    f"  {'T2 [K]':20s}  {T2:>12.3f}  {T_ds:>12.3f}  {abs(T_ds - T2) / T2 * 100:>8.2f}%"
)
print(
    f"  {'M2':20s}  {r['M2']:>12.4f}  {M_ds:>12.4f}  {abs(M_ds - r['M2']) / r['M2'] * 100:>8.2f}%"
)
print(f"\n  (numerical error expected ~1-5% at this resolution)")

# ─────────────────────────────────────────────────────────────────
# PART 4, Write output files for ParaView and Tecplot
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 4: Writing output files")
print("=" * 60)

out_dir = os.path.dirname(__file__)
result.to_vtk(os.path.join(out_dir, "wedge_flow.vtk"))
result.to_tecplot(os.path.join(out_dir, "wedge_flow.dat"))
result.to_restart(os.path.join(out_dir, "wedge_restart.npz"))
print("  -> Open wedge_flow.vtk in ParaView (File -> Open, then Apply)")
print("  -> Open wedge_flow.dat in Tecplot (Data -> Load Data File)")

# ─────────────────────────────────────────────────────────────────
# PART 5, Mach sweep using solver as Anvil Relation
#           Runs each Mach number in parallel threads
# ─────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("  PART 5: Mach number sweep (solver as Anvil Relation)")
print("=" * 60)

# BC factory, rebuilds BCs for each M_inf so sweep is physically correct
nx_sw, ny_sw = 40, 20
sweep_mesh = Mesh.wedge(
    half_angle_deg=theta_deg,
    chord=1.0,
    height=0.6,
    nx=nx_sw,
    ny=ny_sw,
    title="wedge_sweep",
    patches={
        "inlet":    MeshPatch("left",   0, ny_sw),
        "outlet":   MeshPatch("right",  0, ny_sw),
        "wall":     MeshPatch("bottom", 0, nx_sw),
        "farfield": MeshPatch("top",    0, nx_sw),
    },
)


def wedge_bcs(M, p, T, alpha=0.0):
    return {
        "inlet":    SupersonicInlet(M=M, p=p, T=T, gamma=gamma, R_gas=R_gas),
        "outlet":   SupersonicOutlet(),
        "wall":     SlipWall(),
        "farfield": Farfield(M=M, p=p, T=T, gamma=gamma, R_gas=R_gas),
    }


sweep_solver = CFDSolver(
    mesh=sweep_mesh,
    bcs=wedge_bcs(M_inf, p_inf, T_inf),  # placeholder; factory overrides
    gamma=gamma,
    R_gas=R_gas,
    flux_scheme="roe",
    order=2,
    cfl=0.3,
    transient=False,
)

# Wrap solver as a Relation: M_inf -> M_max, p_wall
cfd_rel = sweep_solver.as_relation(
    inputs=["M_inf", "p_inf", "T_inf"],
    outputs=["M_max", "p_wall"],
    name="wedge_euler",
    bc_factory=wedge_bcs,
    run_kwargs={"max_iter": 1500, "tol": 1e-4, "verbose": False},
)

# Build a simple System around it
sweep_sys = anvil.system("wedge_mach_sweep")
sweep_sys.add("M_inf", 2.0)
sweep_sys.add("p_inf", p_inf)
sweep_sys.add("T_inf", T_inf)
sweep_sys.use(cfd_rel)

print("\n  Sweeping M = 1.5, 2.0, 2.5, 3.0  (parallel=2)...")
mach_vals = np.array([1.5, 2.0, 2.5, 3.0])
sweep = sweep_sys.sweep("M_inf", mach_vals, parallel=2, skip_errors=True)
sweep.summary(outputs=["M_max", "p_wall"])

# Compare with analytical
print("\n  Analytical comparison:")
print(f"  {'M_inf':>8s}  {'beta [deg]':>12s}  {'p_wall/p_inf':>14s}")
for M in mach_vals:
    r_a = anvil.R.oblique_shock(M1=float(M), theta_deg=theta_deg, gamma=gamma)
    if r_a["attached"]:
        print(f"  {M:>8.1f}  {r_a['beta_deg']:>12.3f}  {r_a['p2_p1']:>14.4f}")
    else:
        print(f"  {M:>8.1f}  {'(detached)':>12s}  {'---':>14s}")

# Cleanup output files
# for f in ["wedge_flow.vtk", "wedge_flow.dat", "wedge_restart.npz"]:
#    fpath = os.path.join(out_dir, f)
#    if os.path.exists(fpath):
#        os.remove(fpath)

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)
print("""
  CFD solver architecture summary:
    Scheme:    2D cell-centred finite volume (ghost cells)
    Flux:      Roe approximate Riemann solver (with Harten entropy fix)
    Order:     2nd order MUSCL + van Leer limiter
    Time:      4-stage Runge-Kutta, local time stepping
    BCs:       SupersonicInlet, SupersonicOutlet, SlipWall, Farfield
    Output:    VTK legacy (.vtk, ParaView), Tecplot ASCII (.dat)

  Extensible to:
    - Viscous flows: add viscous_flux_2d() in flux.py
    - 3D:           add k-index in mesh and w-velocity in state
    - CLI adapter:  wrap solver in Adapter("su2_cfd", backend="cli", ...)
""")

Runs a full solve that takes a while; run the script locally to see its output.

Chemistry RSQs¶

examples/ex_chemistry.py: A tour of the chemistry relation pack: stoichiometry, gas laws, solutions,

import anvil

print("=" * 60)
print("  Stoichiometry")
print("=" * 60)
print(f"  Moles in 18 g of water (M=18 g/mol): {anvil.R.moles_from_mass(m=0.018, M=0.018)['n']}")
print(f"  Percent yield (8.2 of 10)          : {anvil.R.percent_yield(actual=8.2, theoretical=10)['percent_yield']:.1f} %")
mol = anvil.R.molarity(n=0.5, V=0.002)
print(f"  Molarity (0.5 mol in 2 L)          : {mol['c']} ({mol['c_molar']:.3f} mol/L)")

print("\n" + "=" * 60)
print("  Gas laws")
print("=" * 60)
print(f"  Moles of gas (1 atm, 22.4 L, 273 K): {anvil.R.moles_ideal_gas(P=101325, V=0.0224140, T=273.15)['n']}")
print(f"  Combined gas law (double pressure) : "
      f"{anvil.R.combined_gas_law(P1=1e5, V1=1e-3, T1=300, P2=2e5, T2=300)['V2']}")

print("\n" + "=" * 60)
print("  Solutions and colligative properties")
print("=" * 60)
print(f"  Dilution (2 M, 10 mL to 0.5 M)     : {anvil.R.dilution(M1=2, V1=0.010, M2=0.5)['V2']}")
print(f"  Beer-Lambert absorbance            : {anvil.R.beer_lambert_absorbance(eps=100, l=0.01, c=1.0)['A']:.3f}")
print(f"  Freezing-point drop (0.5 m NaCl)   : {anvil.R.freezing_point_depression(i=2, Kf=1.86, m=0.5)['dTf']}")
print(f"  Osmotic pressure (1000 mol/m^3)    : {anvil.R.osmotic_pressure(i=1, M=1000, T=298.15)['Pi']}")

print("\n" + "=" * 60)
print("  Thermodynamics, kinetics, equilibrium")
print("=" * 60)
print(f"  Gibbs free energy (dH-TdS)         : {anvil.R.gibbs_free_energy(dH=-1e5, T=298.15, dS=-100)['dG']}")
print(f"  Gibbs from K=100                   : {anvil.R.gibbs_from_equilibrium_constant(K=100, T=298.15)['dG']}")
print(f"  Arrhenius k (Ea=100 kJ/mol, 300 K) : {anvil.R.arrhenius_rate_constant(A=1e13, Ea=1e5, T=300)['k']}")
print(f"  First-order half-life (k=6.93e-3)  : {anvil.R.first_order_half_life(k=0.00693)['t_half']}")

print("\n" + "=" * 60)
print("  Electrochemistry and acid-base")
print("=" * 60)
print(f"  Nernst potential (Q=1e-3, n=2)     : {anvil.R.nernst_cell_potential(E0=1.10, n=2, T=298.15, Q_rxn=1e-3)['E']}")
print(f"  pH of neutral water (1e-7 M)       : {anvil.R.ph_from_concentration(H_conc=1e-7)['pH']:.2f}")
print(f"  Buffer pH (pKa 4.76, 10:1 base)    : {anvil.R.henderson_hasselbalch(pKa=4.76, conc_base=1.0, conc_acid=0.1)['pH']:.2f}")

Output:

============================================================
  Stoichiometry
============================================================
  Moles in 18 g of water (M=18 g/mol): 1.0000 mol
  Percent yield (8.2 of 10)          : 82.0 %
  Molarity (0.5 mol in 2 L)          : 250.00 mol/m^3 (0.250 mol/L)

============================================================
  Gas laws
============================================================
  Moles of gas (1 atm, 22.4 L, 273 K): 1.0000 mol
  Combined gas law (double pressure) : 5.0000e-04 m^3

============================================================
  Solutions and colligative properties
============================================================
  Dilution (2 M, 10 mL to 0.5 M)     : 0.040000 m^3
  Beer-Lambert absorbance            : 1.000
  Freezing-point drop (0.5 m NaCl)   : 1.8600 K
  Osmotic pressure (1000 mol/m^3)    : 2.4790e+06 Pa

============================================================
  Thermodynamics, kinetics, equilibrium
============================================================
  Gibbs free energy (dH-TdS)         : -70185.00 J/mol
  Gibbs from K=100                   : -11416.02 J/mol
  Arrhenius k (Ea=100 kJ/mol, 300 K) : 3.8797e-05 s^-1
  First-order half-life (k=6.93e-3)  : 100.02 s

============================================================
  Electrochemistry and acid-base
============================================================
  Nernst potential (Q=1e-3, n=2)     : 1.1887 V
  pH of neutral water (1e-7 M)       : 7.00
... (1 more lines)

Example: CoolProp Adapter -- Real-Fluid Properties in Anvil (real-only)¶

examples/ex_coolprop_adapter.py: the CoolProp adapter (rho, h, cp, mu, a) for several fluids and

import anvil
from anvil import Q
from anvil.adapters.coolprop_props import coolprop_props, register

W = 64
print("=" * W)
print("  CoolProp Adapter Example (real-only, no mock)")
print("=" * W)

# register() never needs CoolProp; importing the module is always safe.
register()

try:
    # ── 1. Direct adapter calls over several fluids ──────────────────────────
    print("\n[1] Properties at T=300 K, P=101325 Pa")
    print(f"  {'fluid':8s}  {'rho':>10s}  {'cp':>10s}  {'a (m/s)':>9s}  {'source':>9s}")
    print(f"  {'-'*8}  {'-'*10}  {'-'*10}  {'-'*9}  {'-'*9}")
    for fluid in ("Air", "N2", "O2", "CO2", "CH4"):
        r = coolprop_props(fluid=fluid, T=300.0, P=101325.0)
        print(f"  {fluid:8s}  {r['rho'].value:10.4f}  {r['cp'].value:10.1f}  "
              f"{r['a'].value:9.1f}  {str(r['source']):>9s}")

    # ── 2. Pipeline: adapter properties feed a Reynolds-number System ────────
    print("\n[2] Pipe-flow Reynolds number using adapter rho & mu")
    props = coolprop_props(fluid="Air", T=300.0, P=5e5)
    print(f"  rho    = {props['rho']}")
    print(f"  mu     = {props['mu']}  (source: {props['source']})")

    pipe = anvil.system("pipe_flow")
    pipe.add("rho", props["rho"].value, "kg/m^3")
    pipe.add("mu", props["mu"].value, "Pa*s")
    pipe.add("D_pipe", 0.05, "m")
    pipe.add("V_flow", 10.0, "m/s")

    def reynolds(rho, mu, D_pipe, V_flow):
        Re = rho * V_flow * D_pipe / mu
        return {"Re": Q(Re, "1")}
    pipe.use(reynolds)

    res = pipe.solve_forward()
    print(f"  Re     = {res['Re'].value:.0f}")

except ImportError as e:
    print("\n  CoolProp is not installed -- cannot run this example.")
    print(f"  {e}")
    print("\n  Install CoolProp to run this example: pip install CoolProp")

print("\n" + "=" * W)
print("  Done.")
print("=" * W)

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Curve Fitting and Data Tables¶

examples/ex_curve_fitting.py: the data-fitting RSQs that take x_data / y_data arrays and

import numpy as np

import anvil

rng = np.random.default_rng(0)

print("=" * 60)
print("  Linear regression: y = 2 x + 1 (+ noise)")
print("=" * 60)

x = np.linspace(0, 10, 25)
y = 2.0 * x + 1.0 + rng.normal(0, 0.15, x.size)
lin = anvil.R.linear_regression(x_data=x, y_data=y)
print(f"  slope     = {lin['slope']:.4f}  (true 2.0)")
print(f"  intercept = {lin['intercept']:.4f}  (true 1.0)")
print(f"  R-squared = {lin['r_squared']:.5f},  RMSE = {lin['rmse']:.4f}")

print("\n" + "=" * 60)
print("  Polynomial fit: y = x^2 + 1 (exact)")
print("=" * 60)

xp = np.linspace(-3, 3, 21)
yp = xp**2 + 1.0
poly = anvil.R.poly_fit(x_data=xp, y_data=yp, degree=2)
coeffs = poly["coeffs"]
print(f"  coeffs (high->low) = "
      f"[{coeffs[0]:.4f}, {coeffs[1]:.4f}, {coeffs[2]:.4f}]  (true [1, 0, 1])")
print(f"  R-squared = {poly['r_squared']:.5f}")

print("\n" + "=" * 60)
print("  Power fit: y = 2 x^1.5")
print("=" * 60)

xw = np.linspace(1, 20, 20)
yw = 2.0 * xw**1.5
powr = anvil.R.power_fit(x_data=xw, y_data=yw)
print(f"  a = {powr['a']:.4f}  (true 2.0)")
print(f"  b = {powr['b']:.4f}  (true 1.5)")
print(f"  R-squared = {powr['r_squared']:.5f}")

print("\n" + "=" * 60)
print("  Exponential fit: y = 5 e^(0.3 x)")
print("=" * 60)

xe = np.linspace(0, 8, 20)
ye = 5.0 * np.exp(0.3 * xe)
expf = anvil.R.exp_fit(x_data=xe, y_data=ye)
print(f"  a = {expf['a']:.4f}  (true 5.0)")
print(f"  b = {expf['b']:.4f}  (true 0.3)")
print(f"  R-squared = {expf['r_squared']:.5f}")

print("\n  Each fit recovers the generating coefficients, confirming the")
print("  data-fitting RSQs are ready to drop into the workbench data tables.")

Output:

============================================================
  Linear regression: y = 2 x + 1 (+ noise)
============================================================
  slope     = 2.0028  (true 2.0)
  intercept = 0.9751  (true 1.0)
  R-squared = 0.99955,  RMSE = 0.1280

============================================================
  Polynomial fit: y = x^2 + 1 (exact)
============================================================
  coeffs (high->low) = [1.0000, 0.0000, 1.0000]  (true [1, 0, 1])
  R-squared = 1.00000

============================================================
  Power fit: y = 2 x^1.5
============================================================
  a = 2.0000  (true 2.0)
  b = 1.5000  (true 1.5)
  R-squared = 1.00000

============================================================
  Exponential fit: y = 5 e^(0.3 x)
============================================================
  a = 5.0000  (true 5.0)
  b = 0.3000  (true 0.3)
  R-squared = 1.00000

  Each fit recovers the generating coefficients, confirming the
  data-fitting RSQs are ready to drop into the workbench data tables.

Example: Design of Experiments (DOE) + parallel sweeps on a built-in System.¶

examples/ex_doe.py

import warnings

import numpy as np
import anvil
from anvil import doe


def main():
    warnings.simplefilter("ignore")  # quiet DOF/backend-fallback warnings for the demo

    # --- A built-in System ----------------------------------------------------
    nozzle = anvil.S.rocket_nozzle.copy()
    print("Inputs:", list(nozzle._quantities.keys()))

    # --- 1. Latin Hypercube DOE over two inputs -------------------------------
    bounds = {
        "P0": (3.0e6, 10.0e6),    # chamber pressure [Pa]
        "A_exit": (0.05, 0.30),   # exit area [m^2]
    }
    samples = doe.latin_hypercube(bounds, n=20, seed=7)
    print(f"\nGenerated {len(samples)} Latin Hypercube samples.")
    print("First sample:", {k: round(v, 4) for k, v in samples[0].items()})

    # Evaluate the design (parallel=4 -> process pool, auto-falls back to threads
    # for this registry-loaded System).
    design = doe.run_doe(
        nozzle, samples, outputs=["thrust", "Isp", "M_exit"], parallel=4
    )
    design.summary()

    thrust = design["thrust"]
    best = int(np.nanargmax(thrust))
    print(f"\nBest design (max thrust): sample #{best}")
    print(f"  P0     = {samples[best]['P0']/1e6:.3f} MPa")
    print(f"  A_exit = {samples[best]['A_exit']:.4f} m^2")
    print(f"  thrust = {thrust[best]:.1f} N")

    # --- 2. Sobol sampling (low-discrepancy) ----------------------------------
    sob = doe.sobol(bounds, n=8, seed=1)
    print(f"\nSobol design: {len(sob)} samples (power-of-two recommended).")

    # --- 3. Full-factorial grid -----------------------------------------------
    grid = doe.full_factorial({
        "P0": [4e6, 7e6, 10e6],
        "A_exit": [0.08, 0.16, 0.24],
    })
    print(f"Full-factorial design: {len(grid)} grid points (3 x 3).")

    # --- 4. Parallel parameter sweep ------------------------------------------
    print("\nParallel sweep of chamber pressure P0:")
    sweep = nozzle.sweep("P0", np.linspace(3e6, 10e6, 8), parallel=4)
    sweep.summary(outputs=["thrust", "Isp"])


if __name__ == "__main__":
    main()

Output:

Inputs: ['P0', 'T0', 'gamma', 'R_gas', 'A_throat', 'A_exit', 'P_amb']

Generated 20 Latin Hypercube samples.
First sample: {'P0': 4181216.5867, 'A_exit': 0.1138}

-------------------------------------------------------------------
  rocket_nozzle -- DOE  (20/20 succeeded)
-------------------------------------------------------------------
             P0       A_exit       thrust          Isp       M_exit
  -----------------------------------------------------------------
      4.181e+06       0.1138    6.072e+04        238.1        3.528
      4.829e+06       0.1472    6.976e+04        236.9        3.731
      7.795e+06      0.08908    1.236e+05          260        3.335
      8.248e+06       0.1647    1.288e+05        256.1         3.82
      3.071e+06      0.05665    4.477e+04        239.1        2.977
      3.944e+06        0.259    4.485e+04        186.5        4.182
      9.911e+06       0.2444    1.534e+05        253.9        4.136
      7.373e+06       0.1056    1.161e+05        258.3        3.469
      8.252e+06       0.2151    1.257e+05        249.8        4.033
      5.582e+06       0.1501    8.278e+04        243.2        3.747
      6.425e+06       0.1355    9.842e+04        251.2        3.666
      9.086e+06       0.2995    1.345e+05        242.7          4.3
  ... (8 more rows)
-------------------------------------------------------------------

Best design (max thrust): sample #6
  P0     = 9.911 MPa
  A_exit = 0.2444 m^2
  thrust = 153431.5 N

Sobol design: 8 samples (power-of-two recommended).
Full-factorial design: 9 grid points (3 x 3).

Parallel sweep of chamber pressure P0:
... (16 more lines)

Extended Engineering RSQs¶

examples/ex_extended_rsqs.py: the extended relation pack that fills common gaps across

import anvil

print("=" * 60)
print("  Compressible duct flow")
print("=" * 60)

fanno = anvil.R.fanno_flow(M=2.0, gamma=1.4)
print(f"  Fanno   M=2.0 : fL*/D_max = {fanno['fLD_max']:.4f}, "
      f"T/T* = {fanno['T_Tstar']:.4f}")

rayleigh = anvil.R.rayleigh_flow(M=0.5, gamma=1.4)
print(f"  Rayleigh M=0.5: T0/T0* = {rayleigh['T0_T0star']:.4f}, "
      f"P/P* = {rayleigh['P_Pstar']:.4f}")

mu = anvil.R.mach_angle(M=2.0)
print(f"  Mach angle M=2.0 : mu = {mu['mu_deg']:.3f} deg")

print("\n" + "=" * 60)
print("  Internal and external flow")
print("=" * 60)

cole = anvil.R.colebrook_friction(Re=1e5, rel_roughness=0.001)
haal = anvil.R.haaland_friction(Re=1e5, rel_roughness=0.001)
print(f"  Darcy friction (Re=1e5, e/D=0.001):")
print(f"    Colebrook (implicit) = {cole['f_darcy']:.5f}  [{cole['regime']}]")
print(f"    Haaland   (explicit) = {haal['f_darcy']:.5f}")

dp = anvil.R.pipe_pressure_drop(f_darcy=cole["f_darcy"], L=50, D=0.1,
                                rho=998, V=2.0)
print(f"  Pipe drop (50 m, D=0.1 m, water @ 2 m/s): "
      f"dP = {dp['dP']}, head = {dp['head_loss']}")

cf = anvil.R.skin_friction_flat_plate(Re_L=1e6, regime="auto")
print(f"  Flat-plate skin friction (Re_L=1e6): Cf = {cf['Cf']:.5f} "
      f"[{cf['regime']}]")

print("\n" + "=" * 60)
print("  Heat transfer")
print("=" * 60)

db = anvil.R.dittus_boelter(Re=1e5, Pr=0.7, k_fluid=0.026, D=0.05,
                            heating=True)
print(f"  Dittus-Boelter (air in tube): Nu = {db['Nu']:.1f}, "
      f"h = {db['h_conv']}")

hx = anvil.R.lmtd(T_hot_in=150, T_hot_out=90, T_cold_in=30, T_cold_out=70,
                  flow="counter")
print(f"  LMTD (counterflow): {hx['LMTD']}")

bi = anvil.R.biot_number(h_conv=50, L_char=0.01, k_solid=200)
print(f"  Biot number: Bi = {bi['Bi']:.4f} "
      f"(lumped valid = {bi['lumped_valid']})")

lc = anvil.R.lumped_capacitance(T0=200, T_inf=25, t=60, h_conv=50,
                                A_surf=0.02, rho=2700, V_vol=1e-4, cp=900)
print(f"  Lumped cooling after 60 s: T = {lc['T_t']} (tau = {lc['tau']})")

print("\n" + "=" * 60)
print("  Structures")
print("=" * 60)

tor = anvil.R.torsion_circular_shaft(torque=500, d_outer=0.04, L=1.0,
                                     G=79e9, d_inner=0.0)
print(f"  Solid shaft torsion (T=500 N.m, d=40 mm): "
      f"tau_max = {tor['tau_max']}, twist = {tor['twist_deg']:.3f} deg")

ps = anvil.R.principal_stresses_2d(sigma_x=80e6, sigma_y=20e6, tau_xy=30e6)
print(f"  Principal stresses: s1 = {ps['sigma_1']}, s2 = {ps['sigma_2']}, "
      f"tau_max = {ps['tau_max']}")

vm = anvil.R.von_mises_stress(sigma_x=80e6, sigma_y=20e6, sigma_z=0,
                              tau_xy=30e6, tau_yz=0, tau_zx=0)
print(f"  Von Mises equivalent stress: {vm['sigma_vm']}")

print("\n" + "=" * 60)
print("  Ideal cycles")
print("=" * 60)

carnot = anvil.R.carnot_efficiency(T_hot=800, T_cold=300)
print(f"  Carnot (800 K / 300 K): eta = {carnot['eta_carnot']:.4f}, "
      f"COP_hp = {carnot['COP_heat_pump']:.3f}")

brayton = anvil.R.brayton_ideal(pressure_ratio=15, gamma=1.4,
                                T_min=300, T_max=1600)
print(f"  Ideal Brayton (rp=15): eta = {brayton['eta_thermal']:.4f}, "
      f"back-work ratio = {brayton['back_work_ratio']:.4f}")

Output:

============================================================
  Compressible duct flow
============================================================
  Fanno   M=2.0 : fL*/D_max = 0.3050, T/T* = 0.6667
  Rayleigh M=0.5: T0/T0* = 0.6914, P/P* = 1.7778
  Mach angle M=2.0 : mu = 30.000 deg

============================================================
  Internal and external flow
============================================================
  Darcy friction (Re=1e5, e/D=0.001):
    Colebrook (implicit) = 0.02217  [turbulent]
    Haaland   (explicit) = 0.02197
  Pipe drop (50 m, D=0.1 m, water @ 2 m/s): dP = 22130.19 Pa, head = 2.2612 m
  Flat-plate skin friction (Re_L=1e6): Cf = 0.00467 [turbulent]

============================================================
  Heat transfer
============================================================
  Dittus-Boelter (air in tube): Nu = 199.4, h = 103.70 W/m^2/K
  LMTD (counterflow): 69.5212 K
  Biot number: Bi = 0.0025 (lumped valid = True)
  Lumped cooling after 60 s: T = 161.71 K (tau = 243.00 s)

============================================================
  Structures
============================================================
  Solid shaft torsion (T=500 N.m, d=40 mm): tau_max = 3.9789e+07 Pa, twist = 1.443 deg
  Principal stresses: s1 = 9.2426e+07 Pa, s2 = 7.5736e+06 Pa, tau_max = 4.2426e+07 Pa
  Von Mises equivalent stress: 8.8882e+07 Pa

============================================================
  Ideal cycles
============================================================
... (2 more lines)

Example: FEniCSx FEM Adapter (real only -- requires dolfinx)¶

examples/ex_fenics_adapter.py: fenics_linear_elasticity and fenics_heat_conduction.

import sys, os

import numpy as np
import anvil
from anvil.adapters import fenics_fem
from anvil.adapters.fenics_fem import (
    fenics_linear_elasticity, fenics_heat_conduction, register
)

if not fenics_fem.is_available():
    print("FEniCSx (dolfinx) not installed -- skipping example.")
    print("Install: conda install -c conda-forge fenics-dolfinx mpich")
    raise SystemExit(0)

# ── Linear elasticity: cantilever box ────────────────────────────────────────
print("=== FEniCSx linear elasticity: cantilever box ===")
r = fenics_linear_elasticity(
    E=200e9, nu=0.3,
    Lx=1.0, Ly=0.05, Lz=0.05,
    F_distributed=1e4,    # N/m^2 on top face
    nx=20, ny=4, nz=4,
)
print(f"  Max displacement = {r['max_displacement']}")
print(f"  Max von Mises    = {r['max_von_mises']}")
print(f"  source: {r['source']}")
# Analytical check: δ = wL⁴/(8EI)
import math
w = 1e4 * 0.05
I = 0.05 * 0.05**3 / 12
delta_analytical = w * 1.0**4 / (8 * 200e9 * I)
print(f"  Analytical δ     = {delta_analytical*1000:.4f} mm  (Euler-Bernoulli check)")

# ── Geometry sensitivity: deflection vs length ───────────────────────────────
print("\n=== Deflection vs beam length (E=200GPa, Ly=Lz=5cm, F=10kPa) ===")
sys_ = anvil.system("fenics_length_sweep")
sys_.add("E",            200e9)
sys_.add("nu",           0.3)
sys_.add("Lx",           1.0)
sys_.add("Ly",           0.05)
sys_.add("Lz",           0.05)
sys_.add("F_distributed", 1e4)
sys_.add("nx",           20)
sys_.add("ny",           4)
sys_.add("nz",           4)
sys_.use(fenics_linear_elasticity)

Lx_vals = np.linspace(0.5, 2.0, 6)
sweep   = sys_.sweep("Lx", Lx_vals)
print(f"  {'Lx [m]':>7}  {'δ_max [mm]':>12}  {'σ_vm [MPa]':>12}")
for i in range(len(Lx_vals)):
    row  = sweep.table.iloc[i]
    d = row["max_displacement"]
    s = row["max_von_mises"]
    d_mm  = float(d.si) * 1000 if hasattr(d, "si") else float(d) * 1000
    s_mpa = float(s.si) / 1e6  if hasattr(s, "si") else float(s) / 1e6
    print(f"  {Lx_vals[i]:7.2f}  {d_mm:12.3f}  {s_mpa:12.2f}")
print("  (δ ∝ L⁴, σ ∝ L²: doubling length → 16× more deflection, 4× more stress)")

# ── Heat conduction ────────────────────────────────────────────────────────────
print("\n=== FEniCSx heat conduction: aluminium rod ===")
r2 = fenics_heat_conduction(
    k=205.0,       # W/m/K  (aluminium)
    Lx=0.5,        # m
    Ly=0.02, Lz=0.02,
    T_left=600.0,  # K
    T_right=300.0, # K
    Q_vol=0.0,
    nx=20, ny=5, nz=5,
)
print(f"  T_max     = {r2['T_max']}")
print(f"  Heat flux = {r2['heat_flux']}")
print(f"  source: {r2['source']}")
# 1D Fourier check: Q = k·A·ΔT/L
A = 0.02 * 0.02
Q_analytical = 205.0 * A * (600.0 - 300.0) / 0.5
print(f"  Analytical Q = {Q_analytical:.2f} W")

# ── Thermal sensitivity: conductivity sweep ────────────────────────────────────
print("\n=== Heat flux vs thermal conductivity (ΔT=300K, L=0.5m) ===")
sys2 = anvil.system("fenics_k_sweep")
sys2.add("k",       205.0)
sys2.add("Lx",      0.5)
sys2.add("Ly",      0.02)
sys2.add("Lz",      0.02)
sys2.add("T_left",  600.0)
sys2.add("T_right", 300.0)
sys2.add("Q_vol",   0.0)
sys2.add("nx",      20)
sys2.add("ny",      5)
sys2.add("nz",      5)
sys2.use(fenics_heat_conduction)

k_vals = [15.0, 45.0, 100.0, 205.0, 385.0]   # steel, Ti, Al alloy, Al, Cu
labels = ["Steel", "Ti alloy", "Al alloy", "Aluminium", "Copper"]
sweep2 = sys2.sweep("k", k_vals)
print(f"  {'Material':>12}  {'k [W/mK]':>10}  {'Q [W]':>8}")
for i, (mat, k) in enumerate(zip(labels, k_vals)):
    row = sweep2.table.iloc[i]
    q   = row["heat_flux"]
    q_w = float(q.si) if hasattr(q, "si") else float(q)
    print(f"  {mat:>12}  {k:10.1f}  {q_w:8.3f}")
print("  (Q ∝ k: linear as expected from Fourier's law)")

# ── Register ──────────────────────────────────────────────────────────────────
print("\n=== Register adapters ===")
register()
print("  Global: fenics_linear_elasticity, fenics_heat_conduction → domain fem.fenics")

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Jet Engine Cycle Analysis (GasTurb style)¶

examples/ex_jet_engine_cycle.py

from anvil import propulsion as jet

print("=" * 60)
print("  Turbojet -- cruise design point")
print("=" * 60)

tj = jet.build_turbojet()
tj.set(M0=0.85, pi_c=12, T04=1500, mdot=25)
res = tj.solve()
res.summary(keys=["specific_thrust", "thrust", "TSFC", "far",
                  "thermal_eff", "propulsive_eff", "overall_eff", "M9"])

print("\n  Station stagnation conditions:")
print(jet.station_table(res))

print("\n" + "=" * 60)
print("  Two-spool turbofan (bypass ratio 5)")
print("=" * 60)

tf = jet.build_turbofan()
tf.set(M0=0.80, pi_f=1.6, pi_c=22, bypass=5, T04=1550, mdot=30)
rtf = tf.solve()
rtf.summary(keys=["specific_thrust", "thrust", "TSFC",
                  "thermal_eff", "propulsive_eff", "overall_eff"])
print("\n  The high-bypass fan trades specific thrust for a much lower TSFC")
print("  and higher propulsive efficiency than the bare turbojet above.")

print("\n" + "=" * 60)
print("  Sweep: compressor pressure ratio (turbojet)")
print("=" * 60)

tj2 = jet.build_turbojet()
tj2.set(M0=0.80, T04=1500)
sweep = tj2.sweep("pi_c", [6, 10, 15, 20, 25, 30, 40])
sweep.summary(outputs=["specific_thrust", "TSFC", "thermal_eff"])
print("\n  Higher pressure ratio lowers TSFC (better fuel economy), while")
print("  specific thrust peaks at a moderate pressure ratio.")

print("\n" + "=" * 60)
print("  Optimize: pressure ratio for maximum specific thrust")
print("=" * 60)

opt = jet.build_turbojet()
opt.set(M0=0.80, T04=1500)
best = opt.optimize("specific_thrust", {"pi_c": (4, 45)},
                    minimize=False, seed=1)
print(f"  Best compressor pressure ratio : {best.x['pi_c']:.2f}")
print(f"  Maximum specific thrust        : {best.fun:.1f} N per kg/s")

print("\n" + "=" * 60)
print("  Afterburning turbojet (reheat to 2000 K)")
print("=" * 60)

ab = jet.build_turbojet_ab()
ab.set(M0=0.90, pi_c=10, T04=1500, T07=2000, mdot=25)
rab = ab.solve()
rab.summary(keys=["specific_thrust", "thrust", "TSFC", "far_total",
                 "thermal_eff", "M9"])
print("\n  The afterburner adds fuel downstream of the turbine, raising")
print("  specific thrust sharply at the cost of a much higher TSFC.")

print("\n" + "=" * 60)
print("  Turboprop / turboshaft (shaft-power output)")
print("=" * 60)

tp = jet.build_turboprop()
tp.set(M0=0.50, pi_c=10, T04=1400, mdot=15)
rtp = tp.solve()
rtp.summary(keys=["shaft_power", "specific_power", "PSFC", "thermal_eff"])
print("\n  A turboprop extracts nearly all the exhaust energy as shaft power")
print("  instead of jet thrust, reported here as power-specific fuel burn.")

print("\n" + "=" * 60)
print("  Cycle diagrams (T-s and h-s / Mollier)")
print("=" * 60)

diag = jet.build_turbojet()
diag.set(M0=0.85, pi_c=12, T04=1500, mdot=25)
rdiag = diag.solve()
for kind, fname in [("Ts", "jet_cycle_Ts.png"), ("hs", "jet_cycle_hs.png")]:
    try:
        fig = jet.cycle_diagram(rdiag, kind=kind)
        fig.savefig(f"examples/{fname}", dpi=90, bbox_inches="tight")
        print(f"  Saved {kind} diagram to examples/{fname}")
    except Exception as exc:  # headless / no display: skip gracefully
        print(f"  Skipped {kind} diagram ({exc})")

Output:

============================================================
  Turbojet -- cruise design point
============================================================

--------------------------------------------------------
  turbojet -- results
--------------------------------------------------------
  specific_thrust           663.09 N
  thrust                    16577.23 N
  TSFC                      3.6810e-05
  far                       0.024408
  thermal_eff               0.235274
  propulsive_eff            0.776715
  overall_eff               0.182741
  M9                        1.0000
--------------------------------------------------------

  Station stagnation conditions:
   st  location              T0 [K]    P0 [kPa]
  ---------------------------------------------
    0  ambient                288.1       101.3
    2  comp face              329.8       161.1
    3  comp exit              721.7      1932.9
    4  turbine inlet         1500.0      1855.6
    5  turbine exit          1161.9       576.4
    9  core nozzle            996.0       311.1

============================================================
  Two-spool turbofan (bypass ratio 5)
============================================================

--------------------------------------------------------
  turbofan -- results
--------------------------------------------------------
... (78 more lines)

Example: Meshing Adapter -- Parametric Geometry in Anvil (real-only)¶

examples/ex_meshing_adapter.py: the mesh_box and mesh_cylinder adapters: node/element counts

import anvil
from anvil import Q
from anvil.adapters.meshing_geom import mesh_box, mesh_cylinder, register

W = 64
print("=" * W)
print("  Meshing Adapter Example (real-only, no mock)")
print("=" * W)

# register() never needs gmsh; importing the module is always safe.
register()

try:
    # ── 1. Box mesh refinement study ─────────────────────────────────────────
    print("\n[1] Box (1.0 x 0.5 x 0.2 m) mesh vs element size")
    print(f"  {'elem_size':>10s}  {'n_nodes':>9s}  {'n_elem':>9s}  {'src':>6s}")
    print(f"  {'-'*10}  {'-'*9}  {'-'*9}  {'-'*6}")
    for h in (0.1, 0.05, 0.025):
        r = mesh_box(Lx=1.0, Ly=0.5, Lz=0.2, elem_size=h)
        print(f"  {h:10.3f}  {int(r['n_nodes']):9d}  "
              f"{int(r['n_elements']):9d}  {str(r['source']):>6s}")

    # ── 2. Cylinder mesh ─────────────────────────────────────────────────────
    print("\n[2] Cylinder (r=0.5 m, h=1.0 m) mesh")
    c = mesh_cylinder(radius=0.5, height=1.0, elem_size=0.1)
    print(f"  n_nodes   = {int(c['n_nodes'])}")
    print(f"  n_elements= {int(c['n_elements'])}")
    print(f"  bbox_vol  = {c['bbox_vol']}  (source: {c['source']})")

    # ── 3. Pipeline: estimate solver memory from element count ───────────────
    print("\n[3] System: solver memory estimate from box mesh")
    mesh = mesh_box(Lx=1.0, Ly=0.5, Lz=0.2, elem_size=0.05)
    job = anvil.system("mesh_job")
    job.add("n_elements", int(mesh["n_elements"]), "1")
    job.add("bytes_per_elem", 2000.0, "1")

    def memory(n_elements, bytes_per_elem):
        mem_mb = n_elements * bytes_per_elem / 1e6
        return {"mem_MB": Q(mem_mb, "1")}
    job.use(memory)
    res = job.solve_forward()
    print(f"  mem_MB     = {res['mem_MB'].value:.1f} MB")

except ImportError as e:
    print("\n  gmsh is not installed -- cannot run this example.")
    print(f"  {e}")
    print("\n  Install gmsh to run this example: pip install gmsh")

print("\n" + "=" * W)
print("  Done.")
print("=" * W)

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: Differential Equations in Anvil¶

examples/ex_ode_patterns.py: Shows three patterns for using differential equations within the framework

import sys, os

import numpy as np
import anvil
from anvil import Q, solvers

print("=" * 60)
print("  Pattern A -- Direct ODE (no System)")
print("=" * 60)

# ─────────────────────────────────────────────────────────────────
# PATTERN A: call solve_ode directly.
#
# Problem: two-species radioactive decay chain
#   dN1/dt = -λ1 * N1
#   dN2/dt =  λ1 * N1  -  λ2 * N2
#
# Write the RHS as a plain function f(t, y) -> dy/dt.
# Call solvers.solve_ode() with the method named explicitly.
# ─────────────────────────────────────────────────────────────────

lambda1 = 0.05    # 1/s  (parent decay constant)
lambda2 = 0.10    # 1/s  (daughter decay constant)
N0      = [1000.0, 0.0]  # initial populations

def decay_rhs(t, y):
    N1, N2 = y
    return [
        -lambda1 * N1,
         lambda1 * N1 - lambda2 * N2,
    ]

result = solvers.solve_ode(
    decay_rhs,
    t_span=(0, 60),
    y0=N0,
    method="RK45",         # explicit -- good for non-stiff problems
    t_eval=np.linspace(0, 60, 300),
    rtol=1e-8,
    atol=1e-10,
    verbose=True,
)

t   = result["t"]
N1  = result["y"][0]
N2  = result["y"][1]

print(f"\n  At t=60 s:")
print(f"    N1 = {N1[-1]:.2f}  (expect {N0[0]*np.exp(-lambda1*60):.2f})")
print(f"    N2 = {N2[-1]:.2f}")
print(f"    Peak N2 at t = {t[np.argmax(N2)]:.2f} s")

# result["sol"] is the scipy OdeResult; .sol is the dense callable
print(f"    N1 at t=10 s (dense): {result['sol'].sol(10)[0]:.2f}")


print("\n" + "=" * 60)
print("  Pattern B -- ODE inside a Relation (System integration)")
print("=" * 60)

# ─────────────────────────────────────────────────────────────────
# PATTERN B: wrap the ODE call inside a Relation.
#
# Problem: vertical rocket burn.
#   dv/dt = F_thrust/m - g - (0.5*Cd*A*rho*v^2)/m
#   dm/dt = -mdot
#   dh/dt = v
#
# The Relation takes design parameters, integrates the ODE,
# returns summary scalars (burnout velocity, peak altitude).
# These scalars can then feed other Relations in the same System.
# ─────────────────────────────────────────────────────────────────

def rocket_burn(F_thrust, mdot, Cd, A_ref, m_dry, m_prop, rho_air=1.225, g=9.81):
    """
    Integrate vertical rocket burn ODE from liftoff to burnout.
    Returns peak velocity, burnout altitude, burnout time.
    """
    m0       = m_dry + m_prop
    t_burn   = m_prop / mdot           # burnout time

    def rhs(t, state):
        v, h, m = state
        if m <= m_dry:
            # coast phase -- no thrust
            F = 0.0
            dm = 0.0
        else:
            F  = F_thrust
            dm = -mdot

        drag  = 0.5 * Cd * A_ref * rho_air * v * abs(v)
        dvdt  = (F - drag) / m - g
        dhdt  = v
        dmdt  = dm
        return [dvdt, dhdt, dmdt]

    # Event: burnout (mass reaches m_dry)
    def burnout(t, state):
        return state[2] - m_dry
    burnout.terminal  = True
    burnout.direction = -1

    sol = solvers.solve_ode(
        rhs,
        t_span=(0, t_burn * 1.5),    # generous span; event stops it
        y0=[0.0, 0.0, m0],
        method="RK45",
        events=burnout,
        rtol=1e-7,
        atol=1e-9,
    )

    v_burnout = float(sol["y"][0, -1])
    h_burnout = float(sol["y"][1, -1])
    t_burnout = float(sol["t"][-1])

    return {
        "v_burnout":  Q(v_burnout, "m/s"),
        "h_burnout":  Q(h_burnout, "m"),
        "t_burnout":  Q(t_burnout, "s"),
        "delta_v":    Q(v_burnout, "m/s"),   # no gravity / drag: Tsiolkovsky would give more
    }


def coast_to_apogee(v_burnout, h_burnout, g=9.81):
    """After burnout, coast phase: v^2 = v0^2 - 2*g*dh."""
    dh_coast = v_burnout**2 / (2 * g)
    h_apogee = h_burnout + dh_coast
    return {
        "dh_coast":  Q(dh_coast, "m"),
        "h_apogee":  Q(h_apogee, "m"),
    }


# Build the System -- ODE Relation sits alongside algebraic Relation
rocket = anvil.system("sounding_rocket")
rocket.add("F_thrust",  10000, "N",    desc="Thrust")
rocket.add("mdot",        5.0, "kg/s", desc="Mass flow rate")
rocket.add("Cd",          0.3,         desc="Drag coefficient")
rocket.add("A_ref",      0.02, "m^2",  desc="Reference area")
rocket.add("m_dry",      20.0, "kg",   desc="Dry mass")
rocket.add("m_prop",     30.0, "kg",   desc="Propellant mass")

rocket.use(rocket_burn)       # ODE inside -- solves internally, returns scalars
rocket.use(coast_to_apogee)   # algebraic -- uses burnout scalars from ODE above

result = rocket.solve_forward()
result.summary(keys=["F_thrust", "m_dry", "m_prop",
                      "v_burnout", "h_burnout", "t_burnout", "h_apogee"])

print(f"\n  Apogee: {result['h_apogee'].to('km')}")
print(f"  Burnout velocity: {result['v_burnout']}")


print("\n" + "=" * 60)
print("  Pattern B -- Sweep over propellant mass")
print("=" * 60)

# Sweep works normally -- the ODE re-integrates for each point
sweep = rocket.sweep("m_prop", np.linspace(10, 60, 8), parallel=4)
sweep.summary(outputs=["v_burnout", "h_burnout", "h_apogee"])


print("\n" + "=" * 60)
print("  Pattern C -- Stiff ODE (combustion kinetics stub)")
print("=" * 60)

# ─────────────────────────────────────────────────────────────────
# PATTERN C: use solve_ode_stiff for stiff problems.
#
# Problem: simplified A -> B -> C kinetics (two-step first-order)
#   The second step is 1000× faster -> stiff system.
#   RK45 would need tiny steps; BDF handles it efficiently.
# ─────────────────────────────────────────────────────────────────

k1 = 1.0      # slow step A -> B
k2 = 1000.0   # fast step B -> C  (stiffness ratio = 1000)

def kinetics_rhs(t, y):
    A, B, C = y
    return [
        -k1 * A,
         k1 * A - k2 * B,
         k2 * B,
    ]

sol_stiff = solvers.solve_ode_stiff(
    kinetics_rhs,
    t_span=(0, 5),
    y0=[1.0, 0.0, 0.0],
    method="BDF",          # implicit -- handles stiff systems efficiently
    t_eval=np.linspace(0, 5, 200),
    rtol=1e-6,
    atol=1e-10,
    verbose=True,
)

A_final = sol_stiff["y"][0, -1]
C_final = sol_stiff["y"][2, -1]
print(f"\n  At t=5 s:")
print(f"    A = {A_final:.6f}  (expect {np.exp(-k1*5):.6f})")
print(f"    C = {C_final:.6f}  (expect ~{1 - np.exp(-k1*5):.6f})")
print(f"    Steps taken: {sol_stiff['nfev']} RHS evaluations")
print(f"    (RK45 would need ~{int(k2*5/1e-4):,} steps to stay stable)")


print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)
print("""
  Summary of patterns:

  Pattern A -- Direct solve_ode / solve_ode_stiff / solve_bvp call.
    When: self-contained ODE, fixed parameters, time-history output.
    How:  write f(t, y), call solvers.solve_ode(..., method="RK45").

  Pattern B -- ODE wrapped inside a Relation.
    When: ODE parameters come from inputs or other equations in a System.
    How:  Relation takes scalar inputs -> calls solve_ode inside -> returns
          scalar summary outputs -> feeds rest of System normally.
          Sweep, sensitivity analysis, and composition all work.

  Pattern C -- Stiff ODE.
    Same as A or B, but use solve_ode_stiff(..., method="BDF").
    BDF/Radau handle k_fast/k_slow >> 1 without tiny step sizes.

  Why not put RHS in the registry?
    The RHS function (f(t,y)) is problem-specific physics -- it CAN be
    registered as an RSQ if you want to reuse it:
      anvil.push(decay_rhs, domain="nuclear", tags=["decay", "ODE"])
    But solve_ode itself is math machinery, not physics -- it stays in
    anvil.solvers, callable anywhere.
""")

Output:

============================================================
  Pattern A -- Direct ODE (no System)
============================================================
  ODE t = 6.0701e+00  (y[0] = 7.3820e+02)
  ODE t = 1.2185e+01  (y[0] = 5.4373e+02)
  ODE t = 1.8226e+01  (y[0] = 4.0199e+02)
  ODE t = 2.4521e+01  (y[0] = 2.9345e+02)
  ODE t = 3.0702e+01  (y[0] = 2.1543e+02)
  ODE t = 3.6762e+01  (y[0] = 1.5912e+02)
  ODE t = 4.3352e+01  (y[0] = 1.1445e+02)
  ODE t = 4.9727e+01  (y[0] = 8.3210e+01)
  ODE t = 5.5762e+01  (y[0] = 6.1537e+01)
  ODE converged: 338 function evaluations, t_final = 6.0000e+01

  At t=60 s:
    N1 = 49.79  (expect 49.79)
    N2 = 47.31
    Peak N2 at t = 13.85 s
    N1 at t=10 s (dense): 606.53

============================================================
  Pattern B -- ODE inside a Relation (System integration)
============================================================

--------------------------------------------------------
  sounding_rocket -- results
--------------------------------------------------------
  F_thrust                  10000.00 N
  m_dry                     20.0000 kg
  m_prop                    30.0000 kg
                            ---
  v_burnout                 1297.48 m/s
  h_burnout                 3889.54 m
  t_burnout                 6.0000 s
... (577 more lines)

Example: OpenFOAM CFD Adapter (real only -- requires OpenFOAM on PATH)¶

examples/ex_openfoam_adapter.py: openfoam_incompressible on a real, prepared OpenFOAM case.

import sys, os

import anvil
from anvil.adapters import openfoam_cfd
from anvil.adapters.openfoam_cfd import (
    openfoam_incompressible, openfoam_compressible, register
)

if not openfoam_cfd.is_available():
    print("OpenFOAM (simpleFoam) not found on PATH -- skipping example.")
    print("Install: https://openfoam.org/download/")
    print("(on Windows, run inside WSL with OpenFOAM sourced)")
    raise SystemExit(0)

if len(sys.argv) < 2:
    print("Usage: python ex_openfoam_adapter.py <case_dir>")
    print("Point it at a prepared case, e.g. a copy of")
    print("  $FOAM_TUTORIALS/incompressible/simpleFoam/airFoil2D")
    print("with a forceCoeffs function object in system/controlDict.")
    raise SystemExit(0)

case_dir = sys.argv[1]
if not os.path.isdir(case_dir):
    raise SystemExit(f"Case directory not found: {case_dir}")

# ── Incompressible: low-speed airfoil ────────────────────────────────────────
print("=== simpleFoam: incompressible airfoil (AoA=5 deg, U=50 m/s) ===")
r = openfoam_incompressible(
    case_path=case_dir,
    U_inf=50.0,
    alpha_deg=5.0,
    rho=1.225, nu=1.5e-5,
    L_ref=1.0, A_ref=1.0,
)
print(f"  CL     = {r['CL']:.4f}")
print(f"  CD     = {r['CD']:.5f}")
print(f"  F_lift = {r['F_lift']}")
print(f"  F_drag = {r['F_drag']}")
print(f"  Re     = {float(r['Re']):.2e}")

# ── Polar sweep: CL/CD vs angle of attack ────────────────────────────────────
print("\n=== Polar: CL/CD vs AoA (U=30 m/s) ===")
sys_ = anvil.system("foam_polar")
sys_.add("case_path", case_dir)
sys_.add("U_inf",     30.0)
sys_.add("alpha_deg", 0.0)   # placeholder; swept below
sys_.use(openfoam_incompressible)

sweep = sys_.sweep("alpha_deg", [-4.0, 0.0, 4.0, 8.0, 12.0])
print(f"  {'AoA':>6}  {'CL':>7}  {'CD':>8}  {'L/D':>7}")
for i in range(len(sweep.table)):
    row = sweep.table.iloc[i]
    ld  = row["CL"] / row["CD"] if row["CD"] != 0 else float("inf")
    print(f"  {row['alpha_deg']:6.1f}  {row['CL']:7.4f}  {row['CD']:8.5f}  {ld:7.1f}")

# ── Compressible (rhoSimpleFoam) -- needs a compressible case ────────────────
# r2 = openfoam_compressible(case_path="<compressible_case>", U_inf=272.0,
#                            alpha_deg=3.0, p_inf=101325.0, T_inf=288.15)
# print(f"  CL = {r2['CL']:.4f}   Mach = {float(r2['Mach']):.3f}")

# ── Register ─────────────────────────────────────────────────────────────────
print("\n=== Register adapters ===")
register()

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: OpenMDAO MDO Adapter (real only -- requires openmdao)¶

examples/ex_openmdo_adapter.py: openmdo_sellar, openmdo_beam, and the make_openmdo_adapter

import sys, os

import numpy as np
import anvil
from anvil.adapters import openmdo_wrap
from anvil.adapters.openmdo_wrap import (
    openmdo_sellar, openmdo_beam, make_openmdo_adapter, register
)

if not openmdo_wrap.is_available():
    print("OpenMDAO not installed -- skipping example.")
    print("Install: pip install openmdao")
    raise SystemExit(0)

# ── Sellar benchmark ──────────────────────────────────────────────────────────
print("=== Sellar coupled MDO benchmark ===")
r = openmdo_sellar(x1=1.0, z1=5.0, z2=2.0)
print(f"  f  = {r['f']:.4f}  (objective, minimize)")
print(f"  g1 = {r['g1']:.4f}  (constraint, feasible if <= 0)")
print(f"  g2 = {r['g2']:.4f}  (constraint, feasible if <= 0)")
print(f"  y1 = {r['y1']:.4f}  (coupling variable, discipline 1)")
print(f"  y2 = {r['y2']:.4f}  (coupling variable, discipline 2)")

# ── Sweep: Sellar objective vs z1 ─────────────────────────────────────────────
print("\n=== Sellar objective vs z1 (x1=1, z2=2) ===")
sys_ = anvil.system("sellar_z1_sweep")
sys_.add("x1", 1.0)
sys_.add("z1", 5.0)
sys_.add("z2", 2.0)
sys_.use(openmdo_sellar)

z1_vals = np.linspace(1.0, 8.0, 8)
sweep   = sys_.sweep("z1", z1_vals)

print(f"  {'z1':>5}  {'f':>8}  {'g1':>7}  {'g2':>7}  feasible?")
for i in range(len(z1_vals)):
    row  = sweep.table.iloc[i]
    feas = "YES" if row["g1"] <= 0 and row["g2"] <= 0 else " no"
    print(f"  {row['z1']:5.2f}  {row['f']:8.4f}  {row['g1']:7.4f}  {row['g2']:7.4f}  {feas}")
print("  (g2 = y2 - 24; tends infeasible at high z1 due to y2 growth)")

# ── Cantilever beam ───────────────────────────────────────────────────────────
print("\n=== OpenMDAO cantilever beam ===")
r2 = openmdo_beam(
    F_tip=5000.0,    # N
    L_beam=2.0,      # m
    E=70e9,          # Pa (aluminium)
    b=0.05,          # m
    h=0.10,          # m
)
print(f"  Deflection = {r2['deflection']}")
print(f"  Max stress = {r2['max_stress']}")
print(f"  I_moment   = {r2['I_moment']}")

# ── Beam sensitivity: deflection vs cross-section height ─────────────────────
print("\n=== Beam deflection vs height h (F=5kN, L=2m, E=70GPa, b=0.05) ===")
sys2 = anvil.system("beam_height_sweep")
sys2.add("F_tip",  5000.0)
sys2.add("L_beam", 2.0)
sys2.add("E",      70e9)
sys2.add("b",      0.05)
sys2.add("h",      0.1)
sys2.use(openmdo_beam)

h_vals = np.linspace(0.04, 0.20, 8)
sweep2 = sys2.sweep("h", h_vals)
print(f"  {'h [m]':>7}  {'d [mm]':>8}  {'s_max [MPa]':>12}")
for i in range(len(h_vals)):
    row  = sweep2.table.iloc[i]
    defl = row["deflection"]
    sig  = row["max_stress"]
    defl_mm    = (float(defl.si) if hasattr(defl, "si") else float(defl)) * 1000
    stress_mpa = (float(sig.si)  if hasattr(sig,  "si") else float(sig))  / 1e6
    print(f"  {h_vals[i]:7.3f}  {defl_mm:8.2f}  {stress_mpa:12.1f}")
print("  (deflection ~ 1/h^3: doubling height cuts deflection 8x)")

# ── Custom OpenMDAO problem via factory ───────────────────────────────────────
print("\n=== Custom OpenMDAO problem via make_openmdo_adapter ===")
import openmdao.api as om

def build_paraboloid():
    class Paraboloid(om.ExplicitComponent):
        def setup(self):
            self.add_input("x", val=0.0)
            self.add_input("y", val=0.0)
            self.add_output("f_xy", val=0.0)
            self.declare_partials("*", "*", method="fd")
        def compute(self, inputs, outputs):
            x = inputs["x"]; y = inputs["y"]
            outputs["f_xy"] = (x - 3.0)**2 + x*y + (y + 4.0)**2 - 3.0
    p = om.Problem()
    p.model.add_subsystem("comp", Paraboloid(), promotes=["*"])
    p.setup()
    return p

paraboloid = make_openmdo_adapter(
    prob_factory=build_paraboloid,
    input_vars={"x": {"unit": "1", "desc": "x variable", "default": 0.0},
                "y": {"unit": "1", "desc": "y variable", "default": 0.0}},
    output_vars={"f_xy": {"unit": "1", "desc": "Paraboloid value"}},
    name="paraboloid_mdo",
    desc="Paraboloid function via OpenMDAO",
)
r3 = paraboloid(x=6.6, y=-7.3)
print(f"  f_xy at (6.6, -7.3) = {r3['f_xy']:.4f}  (expected ~ -15.94)")

# ── Register ──────────────────────────────────────────────────────────────────
print("\n=== Register adapters ===")
register()
print("  Global: openmdo_sellar, openmdo_beam -> domain mdo.openmdao")

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: Orifice Pressure Solver¶

examples/ex_orifice_pressure_solver.py: Given phi, gas names, orifice size range, and per-line P0 bounds,

import os
import math
import re
import numpy as np


import anvil
from anvil import Q
from anvil.db import fluids


# =============================================================================
# Stoichiometry helper  (not an RSQ -- takes string inputs)
# =============================================================================

def _stoich_FO(fuel_formula, oxidizer="O2"):
    """Stoichiometric (F/O) mass ratio for CxHy fuel."""
    _AW = {"H": 1.008, "C": 12.011, "O": 15.999, "N": 14.007}
    _OX = {"o2": (31.999, 1.00), "air": (28.970, 0.21)}
    a = {e: int(n or 1)
         for e, n in re.findall(r"([A-Z][a-z]?)(\d*)", fuel_formula) if e}
    n_O2 = a.get("C", 0) + a.get("H", 0) / 4 - a.get("O", 0) / 2
    mw_f = sum(_AW.get(e, 0) * n for e, n in a.items())
    MW_ox, x_O2 = _OX.get(oxidizer.lower(), (mw_f, 1.0))
    return mw_f / ((n_O2 / x_O2) * MW_ox)


# =============================================================================
# RSQ definitions  -- each function is self-contained so it serialises safely
# into the project registry (registry stores source only, not module context)
# =============================================================================

def ox_choked_flow(P0_O2, T0_O2, d_O2, gamma_O2, R_O2, Cd_O2=0.61):
    """Choked flow through the oxidizer orifice (d_O2 in mm)."""
    import math
    from anvil import Q
    si = lambda v: float(getattr(v, "si", v))
    g  = si(gamma_O2)
    Gm = math.sqrt(g) * (2 / (g + 1)) ** ((g + 1) / (2 * (g - 1)))
    K  = si(Cd_O2) * math.pi * (si(d_O2) * 5e-4) ** 2 * Gm / math.sqrt(si(R_O2) * si(T0_O2))
    return {"mdot_O2": Q(K * si(P0_O2), "kg/s"), "K_O2": K}


def phi_to_fuel_mdot(mdot_O2, phi, stoich_FO_val):
    """Fuel mdot for target equivalence ratio."""
    from anvil import Q
    si = lambda v: float(getattr(v, "si", v))
    return {"mdot_fuel": Q(si(phi) * si(stoich_FO_val) * si(mdot_O2), "kg/s")}


def fuel_P0_required(mdot_fuel, T0_fuel, d_fuel, gamma_fuel, R_fuel, Cd_fuel=0.61):
    """P0 required to deliver mdot_fuel through fuel orifice (d_fuel in mm)."""
    import math
    from anvil import Q
    si = lambda v: float(getattr(v, "si", v))
    g  = si(gamma_fuel)
    Gm = math.sqrt(g) * (2 / (g + 1)) ** ((g + 1) / (2 * (g - 1)))
    K  = si(Cd_fuel) * math.pi * (si(d_fuel) * 5e-4) ** 2 * Gm / math.sqrt(si(R_fuel) * si(T0_fuel))
    return {"P0_fuel": Q(si(mdot_fuel) / K, "Pa"), "K_fuel": K}


def pressure_margin(P0_O2, P0_fuel, P0_O2_min, P0_O2_max, P0_fuel_min, P0_fuel_max):
    """
    Minimum pressure margin across both lines with per-line bounds (Pa).
    Positive = both lines inside bounds.  Maximised by the optimizer.
    """
    from anvil import Q
    si = lambda v: float(getattr(v, "si", v))
    return {"min_slack": Q(min(
        si(P0_O2)   - si(P0_O2_min),  si(P0_O2_max)   - si(P0_O2),
        si(P0_fuel) - si(P0_fuel_min), si(P0_fuel_max) - si(P0_fuel),
    ), "Pa")}


# =============================================================================
# Project registry
# =============================================================================

proj = anvil.project("orifice_phi_study", path="./orifice_phi_work")

for fn, desc in [
    (ox_choked_flow,   "Choked flow through the oxidizer orifice"),
    (phi_to_fuel_mdot, "Required fuel mdot for target equivalence ratio"),
    (fuel_P0_required, "Required P0 for fuel line at target mass flow"),
    (pressure_margin,  "Min pressure margin across both lines (optimizer objective)"),
]:
    proj.push(fn, domain="flow.orifice.phi", description=desc,
              tags=["orifice", "choked", "phi"])


# =============================================================================
# System builder  (gas properties fetched from the Anvil fluids DB)
# =============================================================================

def _build_system(fuel_gas, ox_gas, T0=300.0, Cd=0.61):
    gf = fluids.get(fuel_gas, T=T0)
    go = fluids.get(ox_gas,   T=T0)
    s  = anvil.system("phi_solver")
    s.add("P0_O2",       1e6,                    "Pa")
    s.add("T0_O2",       T0,                     "K")
    s.add("d_O2",        1.0)
    s.add("gamma_O2",    float(go["gamma"]))
    s.add("R_O2",        float(go["R_gas"].si),  "J/kg/K")
    s.add("Cd_O2",       Cd)
    s.add("phi",         1.0)
    s.add("stoich_FO_val", 0.1)
    s.add("T0_fuel",     T0,                     "K")
    s.add("d_fuel",      1.0)
    s.add("gamma_fuel",  float(gf["gamma"]))
    s.add("R_fuel",      float(gf["R_gas"].si),  "J/kg/K")
    s.add("Cd_fuel",     Cd)
    s.add("P0_O2_min",   3e5,   "Pa")
    s.add("P0_O2_max",   20e5,  "Pa")
    s.add("P0_fuel_min", 3e5,   "Pa")
    s.add("P0_fuel_max", 20e5,  "Pa")
    s.use(proj.R.ox_choked_flow)
    s.use(proj.R.phi_to_fuel_mdot)
    s.use(proj.R.fuel_P0_required)
    s.use(proj.R.pressure_margin)
    return s


# =============================================================================
# Solver  (single sys.optimize() over P0_O2, d_fuel, d_O2 jointly)
# =============================================================================

def find_pressure_settings(phi, fuel_gas, ox_gas, fuel_formula,
                            orifice_range, P0_bounds,
                            oxidizer_formula="O2", T0=300.0, Cd=0.61):
    """
    Find optimal orifice sizes and P0 setpoints for a target phi.

    Parameters
    ----------
    phi : float
        Target equivalence ratio.
    fuel_gas, ox_gas : str
        Anvil fluids DB keys, e.g. "hydrogen", "oxygen".
    fuel_formula : str
        Chemical formula for stoichiometry, e.g. "H2", "CH4".
    orifice_range : (float, float)
        (d_min_mm, d_max_mm) -- continuous search window for both orifices.
    P0_bounds : dict
        {"fuel": (lo_Pa, hi_Pa), "ox": (lo_Pa, hi_Pa)} -- per-line limits.
    oxidizer_formula : str
        "O2" (default) or "air".

    Returns
    -------
    dict with keys d_fuel, d_ox, P0_fuel, P0_ox, mdot_fuel, mdot_ox, margin
    (pressures in bar, flows in g/s, diameters in mm).
    None if no feasible solution exists within the given bounds.
    """
    s = _build_system(fuel_gas, ox_gas, T0, Cd)
    s.set(
        phi=phi,
        stoich_FO_val=_stoich_FO(fuel_formula, oxidizer_formula),
        P0_O2_min=P0_bounds["ox"][0],    P0_O2_max=P0_bounds["ox"][1],
        P0_fuel_min=P0_bounds["fuel"][0], P0_fuel_max=P0_bounds["fuel"][1],
    )

    opt = s.optimize(
        objective="min_slack",
        design_vars={
            "P0_O2":  P0_bounds["ox"],
            "d_O2":   orifice_range,
            "d_fuel": orifice_range,
        },
        minimize=False,
        method="differential_evolution",
        seed=0, maxiter=500, tol=1e-4,
    )

    # opt.success may be False if convergence tolerance not met, but the
    # optimizer still records the best feasible point found. Check opt.fun
    # (the actual min_slack at best point) rather than convergence status.
    if not math.isfinite(opt.fun) or opt.fun <= 0:
        return None

    return {
        "d_fuel":    Q(opt.x["d_fuel"] * 1e-3, "m"),
        "d_ox":      Q(opt.x["d_O2"]   * 1e-3, "m"),
        "P0_fuel":   opt["P0_fuel"],
        "P0_ox":     Q(opt.x["P0_O2"],          "Pa"),
        "mdot_fuel": opt["mdot_fuel"],
        "mdot_ox":   opt["mdot_O2"],
        "margin":    Q(opt.fun,                  "Pa"),
    }


# =============================================================================
# Usage
# =============================================================================

W = 64
print("=" * W)
print("  Orifice Pressure Solver")
print("=" * W)

def _show(r):
    if r:
        print(f"  d_fuel  = {r['d_fuel'].to('mm')}   d_ox    = {r['d_ox'].to('mm')}")
        print(f"  P0_fuel = {r['P0_fuel'].to('bar')}   P0_ox   = {r['P0_ox'].to('bar')}")
        print(f"  mdot_fuel = {r['mdot_fuel']}   mdot_ox = {r['mdot_ox']}")
        print(f"  margin  = {r['margin'].to('bar')}")


# ── Single solve ──────────────────────────────────────────────────────────────
print("\n[1] H2/O2  phi=1.0  |  orifice 0.5-2.0 mm  |  both lines 3-20 bar")
_show(find_pressure_settings(
    phi=1.0, fuel_gas="hydrogen", ox_gas="oxygen", fuel_formula="H2",
    orifice_range=(0.5, 2.0), P0_bounds={"fuel": (3e5, 20e5), "ox": (3e5, 20e5)},
))

# ── phi sweep ─────────────────────────────────────────────────────────────────
print(f"\n[2] phi sweep  (0.5 to 2.5)")
print(f"  {'phi':>6}  {'d_fuel':>12}  {'d_ox':>12}  {'P0_fuel':>16}  {'P0_ox':>16}")
print(f"  {'-'*6}  {'-'*12}  {'-'*12}  {'-'*16}  {'-'*16}")
for phi_val in np.linspace(0.5, 2.5, 9):
    r = find_pressure_settings(
        phi=phi_val, fuel_gas="hydrogen", ox_gas="oxygen", fuel_formula="H2",
        orifice_range=(0.5, 2.0), P0_bounds={"fuel": (3e5, 20e5), "ox": (3e5, 20e5)},
    )
    if r:
        print(f"  {phi_val:>6.2f}  {r['d_fuel'].to('mm')!s:>12}  {r['d_ox'].to('mm')!s:>12}  "
              f"{r['P0_fuel'].to('bar')!s:>16}  {r['P0_ox'].to('bar')!s:>16}")
    else:
        print(f"  {phi_val:>6.2f}  -- no feasible solution --")

# ── asymmetric bounds ─────────────────────────────────────────────────────────
print(f"\n[3] Asymmetric bounds: H2 3-10 bar,  O2 3-20 bar")
_show(find_pressure_settings(
    phi=1.0, fuel_gas="hydrogen", ox_gas="oxygen", fuel_formula="H2",
    orifice_range=(0.5, 2.0), P0_bounds={"fuel": (3e5, 10e5), "ox": (3e5, 20e5)},
))

# ── CH4 / O2 ─────────────────────────────────────────────────────────────────
print(f"\n[4] CH4/O2  phi=0.8  |  orifice 0.5-3.0 mm  |  5-25 bar")
_show(find_pressure_settings(
    phi=0.8, fuel_gas="methane", ox_gas="oxygen", fuel_formula="CH4",
    orifice_range=(0.5, 3.0), P0_bounds={"fuel": (5e5, 25e5), "ox": (5e5, 25e5)},
))

print(f"\n{'='*W}")
print("  Done.")
print(f"{'='*W}")

Output:

  Project 'orifice_phi_study' opened  (orifice_phi_work\.anvil\project_orifice_phi_study.db)
  [orifice_phi_study] Registered 'ox_choked_flow' (R) in domain 'flow.orifice.phi'.
  [orifice_phi_study] Registered 'phi_to_fuel_mdot' (R) in domain 'flow.orifice.phi'.
  [orifice_phi_study] Registered 'fuel_P0_required' (R) in domain 'flow.orifice.phi'.
  [orifice_phi_study] Registered 'pressure_margin' (R) in domain 'flow.orifice.phi'.
================================================================
  Orifice Pressure Solver
================================================================

[1] H2/O2  phi=1.0  |  orifice 0.5-2.0 mm  |  both lines 3-20 bar
  d_fuel  = 1.0660 mm   d_ox    = 1.5063 mm
  P0_fuel = 11.4999 bar (P0_fuel)   P0_ox   = 11.5001 bar
  mdot_fuel = 3.8635e-04 kg/s (mdot_fuel)   mdot_ox = 3.0661e-03 kg/s (mdot_O2)
  margin  = 8.4999 bar

[2] phi sweep  (0.5 to 2.5)
     phi        d_fuel          d_ox           P0_fuel             P0_ox
  ------  ------------  ------------  ----------------  ----------------
    0.50   0.891958 mm     1.7825 mm  11.5000 bar (P0_fuel)       11.5000 bar
    0.75   0.707712 mm     1.1548 mm  11.4999 bar (P0_fuel)       11.5001 bar
    1.00     1.0660 mm     1.5063 mm  11.4999 bar (P0_fuel)       11.5001 bar
    1.25     1.3725 mm     1.7347 mm  11.5000 bar (P0_fuel)       11.5001 bar
    1.50     1.6084 mm     1.8557 mm  11.5000 bar (P0_fuel)       11.5001 bar
    1.75     1.7808 mm     1.9022 mm  11.5000 bar (P0_fuel)       11.5001 bar
    2.00     1.9805 mm     1.9790 mm  11.5002 bar (P0_fuel)       11.4998 bar
    2.25   0.880314 mm   0.829315 mm  11.5000 bar (P0_fuel)       11.4999 bar
    2.50     1.7051 mm     1.5239 mm  11.5000 bar (P0_fuel)       11.5000 bar

[3] Asymmetric bounds: H2 3-10 bar,  O2 3-20 bar
  d_fuel  = 1.9495 mm   d_ox    = 1.8361 mm
  P0_fuel = 6.5000 bar (P0_fuel)   P0_ox   = 14.6333 bar
  mdot_fuel = 7.3041e-04 kg/s (mdot_fuel)   mdot_ox = 5.7967e-03 kg/s (mdot_O2)
  margin  = 3.5000 bar

... (9 more lines)

Fundamental Physics RSQs¶

examples/ex_physics.py: A tour of the physics relation pack: mechanics, electromagnetism, optics,

import anvil

print("=" * 60)
print("  Mechanics")
print("=" * 60)
print(f"  Kinetic energy (2 kg at 3 m/s) : {anvil.R.kinetic_energy(m=2, v=3)['KE']}")
print(f"  Gravity (Earth on 1 kg at surface): "
      f"{anvil.R.newton_gravitation(m1=5.972e24, m2=1, r=6.371e6)['F_grav']}")
print(f"  Projectile range (10 m/s, 45 deg): "
      f"{anvil.R.projectile_range(v0=10, angle_deg=45)['range']}")
print(f"  Pendulum period (1 m)          : {anvil.R.pendulum_period(L=1)['period']}")

print("\n" + "=" * 60)
print("  Electromagnetism")
print("=" * 60)
print(f"  Coulomb force (2x 1 uC at 0.1 m): "
      f"{anvil.R.coulomb_force(q1=1e-6, q2=1e-6, r=0.1)['F_coulomb']}")
print(f"  Capacitor energy (1 m^2, 1 mm, 100 V): "
      f"{anvil.R.parallel_plate_capacitor_energy(A=1, d=1e-3, V=100)['U_stored']}")
print(f"  Lorentz force (proton, 1e6 m/s, 0.5 T): "
      f"{anvil.R.lorentz_force_magnitude(q=1.602176634e-19, v=1e6, B=0.5)['F_lorentz']}")

print("\n" + "=" * 60)
print("  Optics")
print("=" * 60)
snell = anvil.R.snell_refraction_angle(n1=1, n2=1.5, theta1_deg=30)
print(f"  Snell refraction (30 deg into glass): {snell['theta2_deg']:.3f} deg")
print(f"  Thin lens image (f=0.1, d_o=0.3): "
      f"{anvil.R.thin_lens_image_distance(f=0.1, d_o=0.3)['d_i']}")
print(f"  Photon energy (green, 5.5e14 Hz): "
      f"{anvil.R.photon_energy_frequency(f=5.5e14)['E_photon']}")

print("\n" + "=" * 60)
print("  Waves and relativity")
print("=" * 60)
print(f"  Wave speed (100 Hz, 3.4 m)     : {anvil.R.wave_speed(frequency=100, wavelength=3.4)['speed']}")
dop = anvil.R.relativistic_doppler_shift(f_src=1e9, v_radial=2.99792458e7)
print(f"  Relativistic Doppler (approach 0.1c): {dop['f_obs']} (x{dop['shift_factor']:.4f})")
print(f"  Lorentz factor (0.6c)          : {anvil.R.lorentz_factor(v=0.6 * 2.99792458e8)['gamma']:.4f}")
print(f"  Rest energy of 1 kg            : {anvil.R.mass_energy_equivalence(m=1)['E_rest']}")

print("\n" + "=" * 60)
print("  Quantum")
print("=" * 60)
print(f"  de Broglie wavelength (p=1e-24): "
      f"{anvil.R.de_broglie_wavelength(p=1e-24)['wavelength']}")
print(f"  Wien peak (Sun, 5778 K)        : "
      f"{anvil.R.wien_peak_wavelength(T=5778)['lambda_peak']}")

Output:

============================================================
  Mechanics
============================================================
  Kinetic energy (2 kg at 3 m/s) : 9.0000 J
  Gravity (Earth on 1 kg at surface): 9.8195 N
  Projectile range (10 m/s, 45 deg): 10.1937 m
  Pendulum period (1 m)          : 2.0061 s

============================================================
  Electromagnetism
============================================================
  Coulomb force (2x 1 uC at 0.1 m): 0.898755 N
  Capacitor energy (1 m^2, 1 mm, 100 V): 4.4271e-05 J
  Lorentz force (proton, 1e6 m/s, 0.5 T): 8.0109e-14 N

============================================================
  Optics
============================================================
  Snell refraction (30 deg into glass): 19.471 deg
  Thin lens image (f=0.1, d_o=0.3): 0.150000 m
  Photon energy (green, 5.5e14 Hz): 3.6443e-19 J

============================================================
  Waves and relativity
============================================================
  Wave speed (100 Hz, 3.4 m)     : 340.00 m/s
  Relativistic Doppler (approach 0.1c): 1.1055e+09 Hz (x1.1055)
  Lorentz factor (0.6c)          : 1.2500
  Rest energy of 1 kg            : 8.9876e+16 J

============================================================
  Quantum
============================================================
  de Broglie wavelength (p=1e-24): 6.6261e-10 m
... (1 more lines)

Example: Custom Relations + Project Registry¶

examples/ex_project_workflow.py: Scenario: pipe flow design, friction factor, pressure drop, pump power.

import sys, os

import numpy as np
import anvil
from anvil import Q, system

# ─────────────────────────────────────────────────────────────────
# PART 1, Define your own Relations (plain Python functions)
#
# Rules:
#   - Accept inputs as keyword arguments
#   - Return a dict
#   - Wrap dimensional outputs in Q(value, "unit") so units propagate
# ─────────────────────────────────────────────────────────────────

def friction_factor(Re, roughness_ratio=0.0):
    """
    Darcy-Weisbach friction factor.
    Laminar Re < 2300: f = 64/Re
    Turbulent Re >= 2300: Swamee-Jain explicit approximation
    roughness_ratio = epsilon/D (dimensionless)
    """
    if Re < 2300:
        f = 64.0 / Re
    else:
        # Swamee-Jain (explicit approx to Colebrook)
        numerator   = roughness_ratio / 3.7
        denominator = 5.74 / Re**0.9
        f = 0.25 / (np.log10(numerator + denominator))**2
    return {"f_darcy": f}


def pressure_drop(f_darcy, rho, V, D_pipe, L_pipe):
    """
    Darcy-Weisbach pressure drop: dP = f * (L/D) * 0.5 * rho * V^2
    """
    dP = f_darcy * (L_pipe / D_pipe) * 0.5 * rho * V**2
    return {"dP": Q(dP, "Pa")}


def pump_power(dP, V, D_pipe, eta_pump=0.75):
    """
    Hydraulic pump power: W = Q_vol * dP / eta
    Q_vol = V * pi/4 * D^2
    """
    A     = np.pi / 4 * D_pipe**2
    Q_vol = V * A
    W_hyd = Q_vol * dP          # watts if dP in Pa, Q_vol in m^3/s
    W_shaft = W_hyd / eta_pump
    return {
        "W_hydraulic": Q(W_hyd,   "W"),
        "W_shaft":     Q(W_shaft, "W"),
        "Q_vol":       Q(Q_vol,   "m^3/s"),
    }


# ─────────────────────────────────────────────────────────────────
# PART 2, Open a project registry
#
# Creates (or opens) a local .db file in the given directory.
# Nothing goes to the global registry until you explicitly promote it.
# ─────────────────────────────────────────────────────────────────

print("=" * 60)
print("  PART 2: Project Registry")
print("=" * 60)

project_dir = os.path.join(os.path.dirname(__file__), "pipe_project")
os.makedirs(project_dir, exist_ok=True)

proj = anvil.project("pipe_flow", path=project_dir)
# → Creates: pipe_project/.anvil/project_pipe_flow.db

# Register your three relations to the project
proj.push(friction_factor,
    domain="fluid.pipe",
    description="Darcy-Weisbach friction factor (laminar + Swamee-Jain turbulent)",
    tags=["pipe", "friction", "darcy"])

proj.push(pressure_drop,
    domain="fluid.pipe",
    description="Darcy-Weisbach pressure drop along a pipe segment",
    tags=["pipe", "pressure_drop"])

proj.push(pump_power,
    domain="fluid.pipe",
    description="Hydraulic and shaft pump power from flow and pressure drop",
    tags=["pipe", "pump", "power"])

# List what's in the project
proj.list()


# ─────────────────────────────────────────────────────────────────
# PART 3, Use project RSQs directly (no System needed)
#
# Quick sanity check on each relation before wiring them together.
# ─────────────────────────────────────────────────────────────────

print("\n" + "=" * 60)
print("  PART 3: Direct calls to project RSQs")
print("=" * 60)

# proj.R.<name> gives you the callable Relation
r_lam = proj.R.friction_factor(Re=1000)
r_tur = proj.R.friction_factor(Re=50000, roughness_ratio=0.001)

print(f"\n  Laminar  Re=1000  : f = {r_lam['f_darcy']:.5f}  (expect 0.064)")
print(f"  Turbulent Re=50000 : f = {r_tur['f_darcy']:.5f}")

r_dp = proj.R.pressure_drop(
    f_darcy=r_tur["f_darcy"],
    rho=998.2, V=2.0, D_pipe=0.05, L_pipe=10.0
)
print(f"  Pressure drop: {r_dp['dP']}")


# ─────────────────────────────────────────────────────────────────
# PART 4, Build a System using project RSQs
#
# Use proj.R.<name> to pull a relation from the project into a System.
# Alternatively use the string name: sys.use("friction_factor")
# works the same once the RSQ is in the project registry.
# ─────────────────────────────────────────────────────────────────

print("\n" + "=" * 60)
print("  PART 4: System built from own Relations")
print("=" * 60)

# --- Define inputs with units ---
pipe_sys = system("water_pipe")

pipe_sys.add("rho",            998.2,  "kg/m^3",  desc="Water density")
pipe_sys.add("V",                2.0,  "m/s",     desc="Mean flow velocity")
pipe_sys.add("D_pipe",          0.05,  "m",       desc="Pipe inner diameter")
pipe_sys.add("L_pipe",          10.0,  "m",       desc="Pipe length")
pipe_sys.add("roughness_ratio", 1e-4,             desc="Relative roughness eps/D")
pipe_sys.add("eta_pump",        0.75,             desc="Pump efficiency")

# Add Reynolds number from the built-in registry RSQ
pipe_sys.add("mu",            1.002e-3, "Pa*s",   desc="Dynamic viscosity (water)")
pipe_sys.use("reynolds_number",
    map={"L_char": "D_pipe"})    # map the 'L_char' input to our 'D_pipe'

# Add our own relations from the project.
#
# IMPORTANT: sys.use("name") only searches the GLOBAL registry.
# Project RSQs must be passed as objects via proj.R.<name>.
pipe_sys.use(proj.R.friction_factor)
pipe_sys.use(proj.R.pressure_drop)
pipe_sys.use(proj.R.pump_power)

# Solve, acyclic (feed-forward), so forward pass
result = pipe_sys.solve_forward()
result.summary()

# --- Unit conversions on results ---
print("\n  Key results:")
print(f"    Re         = {result['Re']}  (turbulent: {'yes' if result['Re'].si > 2300 else 'no'})")
print(f"    f_darcy    = {result['f_darcy']}")
print(f"    dP         = {result['dP'].to('kPa')}")
print(f"    W_shaft    = {result['W_shaft'].to('kW')}")
print(f"    Q_vol      = {result['Q_vol']}"
      f"  ({result['Q_vol'].si * 1000:.2f} L/s)")


# ─────────────────────────────────────────────────────────────────
# PART 5, Parametric sweep using the system
#
# Vary flow velocity; observe friction factor, pressure drop, pump power.
# ─────────────────────────────────────────────────────────────────

print("\n" + "=" * 60)
print("  PART 5: Velocity sweep")
print("=" * 60)

sweep = pipe_sys.sweep("V", np.linspace(0.5, 4.0, 8), parallel=2)
sweep.summary(outputs=["Re", "f_darcy", "dP", "W_shaft"])


# ─────────────────────────────────────────────────────────────────
# PART 6, Promote to global registry
#
# Once your relations are validated and producing correct results,
# promote them from the project store to the global registry.
# They then become available to any script via anvil.R.<name>.
# ─────────────────────────────────────────────────────────────────

print("\n" + "=" * 60)
print("  PART 6: Promote to global registry")
print("=" * 60)

proj.promote("friction_factor", overwrite=True)
proj.promote("pressure_drop",   overwrite=True)
proj.promote("pump_power",      overwrite=True)

# Verify they're now in global registry
print("\n  Searching global registry for 'pipe'...")
anvil.registry.search("pipe")

# Access via global namespace, exactly like any built-in RSQ
r_check = anvil.R.friction_factor(Re=1000)
print(f"\n  anvil.R.friction_factor(Re=1000) -> f = {r_check['f_darcy']:.4f}")


# ─────────────────────────────────────────────────────────────────
# CLEANUP (optional, remove promoted RSQs from global for clean demo)
# ─────────────────────────────────────────────────────────────────

for name in ("friction_factor", "pressure_drop", "pump_power"):
    anvil.registry.remove(name)

import shutil
shutil.rmtree(project_dir, ignore_errors=True)

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  PART 2: Project Registry
============================================================
  Project 'pipe_flow' opened  (C:\Users\rc\OneDrive - University of Maryland\Documents\Personal\the-anvil-framework-main\the-anvil-framework-main\examples\pipe_project\.anvil\project_pipe_flow.db)
  [pipe_flow] Registered 'friction_factor' (R) in domain 'fluid.pipe'.
  [pipe_flow] Registered 'pressure_drop' (R) in domain 'fluid.pipe'.
  [pipe_flow] Registered 'pump_power' (R) in domain 'fluid.pipe'.

  Project: pipe_flow  (C:\Users\rc\OneDrive - University of Maryland\Documents\Personal\the-anvil-framework-main\the-anvil-framework-main\examples\pipe_project)

  Relations (3):
    friction_factor                 [fluid.pipe]
      Darcy-Weisbach friction factor (laminar + Swamee-Jain turbulent)
    pressure_drop                   [fluid.pipe]
      Darcy-Weisbach pressure drop along a pipe segment
    pump_power                      [fluid.pipe]
      Hydraulic and shaft pump power from flow and pressure drop

  Total: 3 RSQs

============================================================
  PART 3: Direct calls to project RSQs
============================================================

  Laminar  Re=1000  : f = 0.06400  (expect 0.064)
  Turbulent Re=50000 : f = 0.02418
  Pressure drop: 9654.95 Pa

============================================================
  PART 4: System built from own Relations
============================================================

--------------------------------------------------------
  water_pipe -- results
... (77 more lines)

Example: pyNastran / NASTRAN FEM Adapter (real only)¶

examples/ex_pynastran_adapter.py: nastran_linear_static and nastran_normal_modes against a real

import sys, os

import anvil
from anvil.adapters import pynastran_fem
from anvil.adapters.pynastran_fem import (
    nastran_linear_static, nastran_normal_modes, register
)

if not pynastran_fem.is_available():
    print("pyNastran and/or a NASTRAN solver binary not found -- skipping example.")
    print("  pip install pyNastran")
    print("  MYSTRAN (free solver): https://github.com/dr-bill-c/MYSTRAN")
    raise SystemExit(0)

if len(sys.argv) < 2:
    print("Usage: python ex_pynastran_adapter.py <static.bdf> [modes.bdf]")
    print("Provide a SOL 101 deck (and optionally a SOL 103 deck).")
    print("MYSTRAN ships test decks; pyNastran also bundles models under")
    print("  pyNastran/../models/ in its source tree.")
    raise SystemExit(0)

static_bdf = sys.argv[1]
modes_bdf  = sys.argv[2] if len(sys.argv) > 2 else None
if not os.path.exists(static_bdf):
    raise SystemExit(f"BDF file not found: {static_bdf}")

# ── Linear static (SOL 101) ──────────────────────────────────────────────────
print(f"=== NASTRAN SOL 101: linear static ({os.path.basename(static_bdf)}) ===")
r = nastran_linear_static(bdf_path=static_bdf, load_case_id=1)
print(f"  Max displacement = {r['max_displacement']}")
print(f"  Max stress       = {r['max_stress']}")

# ── Normal modes (SOL 103) ───────────────────────────────────────────────────
if modes_bdf and os.path.exists(modes_bdf):
    print(f"\n=== NASTRAN SOL 103: normal modes ({os.path.basename(modes_bdf)}) ===")
    r2 = nastran_normal_modes(bdf_path=modes_bdf, n_modes=6)
    print(f"  n_modes = {r2['n_modes']}")
    for i, f in enumerate(r2["frequencies"], 1):
        fq = float(f.si) if hasattr(f, "si") else float(f)
        print(f"    Mode {i}: {fq:.2f} Hz")
else:
    print("\n(no SOL 103 deck given -- skipping normal-modes demo)")

# ── Anvil System integration ─────────────────────────────────────────────────
# The adapter plugs into a System like any native relation, so you can sweep
# any input the deck exposes (e.g. load case id across subcases):
print("\n=== System integration ===")
sys_ = anvil.system("nastran_static")
sys_.add("bdf_path", static_bdf)
sys_.add("load_case_id", 1)
sys_.use(nastran_linear_static)
res = sys_.solve()
print(f"  Solved via System: max_displacement = {res['max_displacement']}")

# ── Register ─────────────────────────────────────────────────────────────────
print("\n=== Register adapters ===")
register()

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: RocketCEA + RocketPy Adapter -- Engine & Flight (real-only)¶

examples/ex_rocketcea_adapter.py: the rocket_cea combustion adapter (Tc, c*, Isp, gamma) and the

import anvil
from anvil import Q
from anvil.adapters.rocket_cea import rocket_cea, rocketpy_flight, register

W = 64
print("=" * W)
print("  RocketCEA / RocketPy Adapter Example (real-only, no mock)")
print("=" * W)

# register() never needs the external packages; importing is always safe.
register()

# ── 1. Combustion performance over propellant combos ─────────────────────────
print("\n[1] Combustion performance at Pc = 7 MPa, eps = 40 (rocket_cea)")
try:
    print(f"  {'ox/fuel':12s}  {'OF':>5s}  {'Tc (K)':>8s}  {'c* (m/s)':>9s}  {'Isp (s)':>8s}")
    print(f"  {'-'*12}  {'-'*5}  {'-'*8}  {'-'*9}  {'-'*8}")
    for ox, fuel, of in (("LOX", "RP1", 2.27), ("LOX", "LH2", 6.0), ("LOX", "CH4", 3.5)):
        r = rocket_cea(oxidizer=ox, fuel=fuel, OF=of, Pc=7e6, eps=40.0)
        print(f"  {ox+'/'+fuel:12s}  {of:5.2f}  {r['Tc'].value:8.0f}  "
              f"{r['cstar'].value:9.1f}  {r['Isp'].value:8.1f}")

    # ── 3. Pipeline: chamber c* drives required throat-area mass flow ────────
    print("\n  System: mass flow from c* and throat area")
    cea = rocket_cea(oxidizer="LOX", fuel="RP1", OF=2.27, Pc=7e6, eps=40.0)
    eng = anvil.system("engine_point")
    eng.add("Pc", 7e6, "Pa")
    eng.add("cstar", cea["cstar"].value, "m/s")
    eng.add("A_throat", 0.01, "m^2")

    def mass_flow(Pc, A_throat, cstar):
        mdot = Pc * A_throat / cstar
        return {"mdot": Q(mdot, "kg/s")}
    eng.use(mass_flow)
    res = eng.solve_forward()
    print(f"  mdot   = {res['mdot']}")
except ImportError as e:
    print("\n  RocketCEA is not installed -- cannot run the combustion part.")
    print(f"  {e}")
    print("  Install RocketCEA to run this part: pip install rocketcea")

# ── 2. Flight: apogee of a simple sounding rocket ────────────────────────────
print("\n[2] Flight estimate (rocketpy_flight)")
try:
    f = rocketpy_flight(thrust=2000.0, burn_time=4.0, dry_mass=8.0,
                        prop_mass=4.0, Cd=0.5, area=0.008)
    print(f"  apogee    = {f['apogee']}")
    print(f"  v_max     = {f['v_max']}")
    print(f"  v_burnout = {f['v_burnout']}  (source: {f['source']})")
except ImportError as e:
    print("  RocketPy is not installed -- cannot run the flight part.")
    print(f"  {e}")
    print("  Install RocketPy to run this part: pip install rocketpy")

print("\n" + "=" * W)
print("  Done.")
print("=" * W)

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Anvil Framework -- Complete Showcase¶

examples/ex_showcase_v2.py: every major feature in one runnable file.

#!/usr/bin/env python3

import os

import numpy as np

# --- path setup ----------------------------------------------------------

import anvil
from anvil import (
    BTU,
    MJ,
    Adapter,
    GPa,
    J,
    K,
    MPa,
    N,
    Pa,
    Q,
    Quantity,
    Relation,
    System,
    W,
    atm,
    bar,
    cm,
    ft,
    g_mol,
    kg,
    kg_mol,
    kJ,
    km,
    kN,
    kPa,
    kW,
    lb,
    lbf,
    m,
    mm,
    mol,
    monitor,
    ms,
    s,
    solvers,
    viz,
)

OUT_DIR = os.path.dirname(__file__)  # save PNGs next to this file


def section(title):
    print(f"\n{'=' * 65}")
    print(f"  {title}")
    print(f"{'=' * 65}")


# =========================================================================
# 1. UNIT ENGINE
# =========================================================================
section("1. UNIT ENGINE")

# --- 1a. Classic Q() syntax ---
T_chamber = Q(3500, "K", name="T_chamber")
P_chamber = Q(6.9e6, "Pa", name="P_chamber")
mdot = Q(12.5, "kg/s", name="mdot")
area = Q(0.1, "m^2", name="area")

print(f"\nClassic Q():")
print(f"  T_chamber = {T_chamber}")
print(f"  P_chamber = {P_chamber}")
print(f"  mdot      = {mdot}")

# --- 1b. UnitStub syntax: value * unit ---
T_amb = 298.15 * K  # -> Q(298.15, "K")
P_atm = 101325 * Pa  # -> Q(101325, "Pa")
v_sound = 340.0 * (m / s)  # -> Q(340.0, "m/s")
g_earth = 9.80665 * m / s**2  # -> Q(9.80665, "m/s^2")  -- no parens needed
rho_air = 1.225 * kg / m**3  # -> Q(1.225, "kg/m^3")   -- no parens needed
cp_air = 1005.0 * J / kg / K  # -> Q(1005.0, "J/kg/K")
mu_air = 1.789e-5 * Pa * s  # -> Q(1.789e-5, "Pa*s")

print(f"\nUnitStub syntax:")
print(f"  T_amb    = {T_amb}")
print(f"  v_sound  = {v_sound}")
print(f"  g_earth  = {g_earth}")
print(f"  rho_air  = {rho_air}")
print(f"  cp_air   = {cp_air}")
print(f"  mu_air   = {mu_air}")

# Imperial units
V_jet = 550.0 * (ft / s)  # -> Q(550, "ft/s")  -- SI internally
F_drag = 150.0 * lbf  # -> Q(150 lbf in N)
L_wing = 12.5 * ft
print(f"\n  V_jet  = {V_jet}  ->  {V_jet.to('m/s')}")
print(f"  F_drag = {F_drag} ->  {F_drag.to('N')}")

# --- 1c. Quantity arithmetic ---
KE = 0.5 * rho_air * v_sound**2  # dynamic pressure
Re = rho_air * v_sound * (1.0 * m) / mu_air
print(f"\nArithmetic:")
print(f"  q_dyn = 12rhoV2 = {KE}")
print(f"  Re    = rhoVL/mu = {Re}")

# --- 1d. Unit conversion ---
T_K = Q(1000, "K")
T_R = T_K.to("R")  # Kelvin -> Rankine (same dimension, different scale)
P_Pa = Q(10e6, "Pa")
P_bar = P_Pa.to("bar")
P_psi = P_Pa.to("psi")
P_MPa = P_Pa.to("MPa")

print(f"\nUnit conversions:")
print(f"  {T_K}  ->  {T_R}")
print(f"  {P_Pa} ->  {P_bar}  =  {P_psi}  =  {P_MPa}")

# --- 1e. SI access ---
print(f"\n  cp_air.si    = {cp_air}  (always in SI: J/kg/K)")
print(f"  cp_air.value = {cp_air} {cp_air.unit}")


# =========================================================================
# 2. DEFINING RELATIONS
# =========================================================================
section("2. DEFINING RELATIONS")


# --- 2a. Decorator syntax -- auto-registers in the registry ---
@anvil.relation(domain="thermo", tags=["ideal_gas"])
def ideal_gas_rho(P, R_gas, T):
    """Ideal gas: rho = P / (R * T)"""
    return {"rho": Q(P / (R_gas * T), "kg/m^3")}


@anvil.relation(domain="thermo", tags=["acoustics"])
def speed_of_sound_gas(gamma, R_gas, T):
    """Speed of sound in ideal gas: a = sqrt(gamma * R * T)"""
    return {"a_sound": Q((gamma * R_gas * T) ** 0.5, "m/s")}


@anvil.relation(domain="aero", tags=["reynolds"])
def reynolds_num(rho, V, L_char, mu):
    """Reynolds number: Re = rho V L / mu"""
    return {"Re": rho * V * L_char / mu}


print(f"\n@relation auto-registered: {ideal_gas_rho}")
print(f"  inputs:  {ideal_gas_rho.inputs}")
print(f"  outputs: {ideal_gas_rho.outputs}")


# --- 2b. Relation() explicit wrap ---
def nusselt_dittus_boelter(Re, Pr, heating=True):
    """Dittus-Boelter: Nu = 0.023 * Re^0.8 * Pr^n"""
    n = 0.4 if heating else 0.3
    Nu = 0.023 * Re**0.8 * Pr**n
    return {"Nu": Nu}


nu_rel = Relation(nusselt_dittus_boelter, tags=["convection", "heat_transfer"])
print(f"\nRelation() wrap: {nu_rel}")


# --- 2c. Relation.block() -- chain multiple functions ---
def sutherland(T, T_ref=288.15, mu_ref=1.789e-5, S=110.4):
    mu = mu_ref * (T / T_ref) ** 1.5 * (T_ref + S) / (T + S)
    return {"mu": mu}


def prandtl_air(mu, cp, k_cond=0.0257):
    Pr = mu * cp / k_cond
    return {"Pr": Pr}


air_props = Relation.block(
    "air_transport_props",
    steps=[sutherland, prandtl_air],
    desc="Sutherland viscosity + Prandtl number for air",
)
print(f"\nRelation.block(): {air_props}")
print(f"  inputs:  {air_props.inputs}")
print(f"  outputs: {air_props.outputs}")


# =========================================================================
# 3. ONE-SHOT SOLVE
# =========================================================================
section("3. ONE-SHOT SOLVE -- anvil.solve()")

# No System object needed; inputs passed as keyword arguments
r = anvil.solve(ideal_gas_rho, P=101325.0, R_gas=287.0, T=298.15)
print(f"\nOne-shot solve -- ideal gas density:")
r.summary()

r2 = anvil.solve(speed_of_sound_gas, gamma=1.4, R_gas=287.0, T=298.15)
print(f"Speed of sound: {r2['a_sound']}")

# Works with registry names too
r3 = anvil.solve("isentropic_ratios", M=2.0, gamma=1.4)
print(f"\nisentropic_ratios at M=2: T0/T={r3['T0_T']:.4f}  P0/P={r3['P0_P']:.4f}")


# =========================================================================
# 4. SYSTEM API
# =========================================================================
section("4. SYSTEM API -- system(), add(kwargs), use(), solve()")

# --- 4a. Build and solve a compressible nozzle flow system ---
nozzle = anvil.system("de_laval_nozzle")

# New kwargs-style add -- name inferred from keyword
nozzle.add(
    P0=8.0e6 * Pa,  # chamber total pressure
    T0=3300.0 * K,  # chamber total temperature
    gamma=Q(1.22),  # ratio of specific heats (dimensionless)
    R_gas=380.0 * J / kg / K,
    A_throat=0.001 * m**2,
    A_exit=Q(0.07, "m^2"),
    P_amb=P_atm,
)

# Can also mix old style
nozzle._add_single("P_amb", P_atm)  # (overwrite with same value, fine)

nozzle.use("nozzle_area_ratio")
nozzle.use("area_mach_supersonic")
nozzle.use("isentropic_ratios", map={"M": "M_exit"})
nozzle.use("exit_conditions")
nozzle.use("exit_velocity")
nozzle.use("choked_mass_flow")
nozzle.use("rocket_thrust")
nozzle.use("specific_impulse")

result = nozzle.solve(verbose=True)
result.summary()

# --- 4b. Result access ---
thrust = result["thrust"]
Isp = result["Isp"]
mdot_r = result["mdot"]
V_exit = result["V_exit"]

print(f"\nKey outputs:")
print(f"  Thrust = {thrust.to('kN')}")
print(f"  Isp    = {Isp}")
print(f"  mdot   = {mdot_r}")
print(f"  V_exit = {V_exit.to('km/s')}")

# --- 4c. Export results ---
result.to_csv(os.path.join(OUT_DIR, "nozzle_result.csv"))
print(f"\n  Saved: nozzle_result.csv")

json_str = result.to_json()
print(f"  JSON (first 150 chars): {json_str[:150]}...")


# =========================================================================
# 5. BUILT-IN RSQs
# =========================================================================
section("5. BUILT-IN RSQs (R and S namespaces)")

# --- 5a. Direct relation calls (no System needed) ---
print("\nIsentropic ratios at M=3, gamma=1.4:")
r_isen = anvil.R.isentropic_ratios(M=3.0, gamma=1.4)
print(f"  T0/T   = {r_isen['T0_T']:.4f}")
print(f"  P0/P   = {r_isen['P0_P']:.4f}")
print(f"  rho0/rho   = {r_isen['rho0_rho']:.4f}")

print("\nNormal shock at M1=2.5:")
r_shock = anvil.R.normal_shock(M1=2.5, gamma=1.4)
print(f"  M2     = {r_shock['M2']:.4f}")
print(f"  P2/P1  = {r_shock['P2_P1']:.4f}")
print(f"  T2/T1  = {r_shock['T2_T1']:.4f}")
print(f"  P02/P01= {r_shock['P02_P01']:.5f}  (stagnation pressure loss)")

print("\nHohmann transfer: LEO (400 km) -> GEO (35 786 km):")
R_earth = 6.371e6  # m
mu_earth = 3.986e14  # m^3/s^2
r1 = R_earth + 400e3
r2 = R_earth + 35786e3
r_hohmann = anvil.R.hohmann_transfer(mu=mu_earth, r1=r1, r2=r2)
print(f"  DV1    = {r_hohmann['dv1'].to('km/s')}")
print(f"  DV2    = {r_hohmann['dv2'].to('km/s')}")
print(f"  DV_tot = {r_hohmann['dv_total'].to('km/s')}")
print(f"  TOF    = {Q(float(r_hohmann['tof']._si_value) / 3600, 'hr')}")

# --- 5b. Pre-built rocket nozzle System ---
print("\nPre-built rocket_nozzle System:")
rn = anvil.S.rocket_nozzle.copy()
rn.set(P0=10e6, T0=3600, gamma=1.2, R_gas=400.0, A_throat=0.015, A_exit=0.12)
rn.solve().summary()


# =========================================================================
# 6. COUPLED SOLVE -- GAUSS-SEIDEL + MONITOR
# =========================================================================
section("6. COUPLED SOLVE -- Gauss-Seidel + Convergence History")

# Coupled fixed-point system (Gauss-Seidel converges by design):
#   y1 = sqrt(y2 + 4.0)     (y1 depends on y2)
#   y2 = sqrt(y1)            (y2 depends on y1)
#
# Solution satisfies y1 = sqrt(sqrt(y1) + 4)
# Numerically: y1 ~ 2.4353, y2 ~ 1.5606


@anvil.relation(domain="math", register=False)
def fp_eq1(y2):
    return {"y1": (y2 + 4.0) ** 0.5}


@anvil.relation(domain="math", register=False)
def fp_eq2(y1):
    return {"y2": y1**0.5}


coupled_sys = anvil.system("fixed_point_demo")
coupled_sys.add("y2", 1.0)  # initial guess for y2
coupled_sys.use(fp_eq1)
coupled_sys.use(fp_eq2)

result_coupled = coupled_sys.solve(
    method="gauss_seidel",
    max_iter=200,
    rtol=1e-8,
    monitor=True,
    verbose=True,
)
result_coupled.summary()

# Verify fixed-point consistency
y1_val = float(result_coupled["y1"]._si_value)
y2_val = float(result_coupled["y2"]._si_value)
print(f"\n  Check: y1 == sqrt(y2+4): {y1_val:.6f} vs {(y2_val + 4) ** 0.5:.6f}")
print(f"  Check: y2 == sqrt(y1):   {y2_val:.6f} vs {y1_val**0.5:.6f}")

# Convergence history plot
monitor.plot_convergence(
    coupled_sys, save=os.path.join(OUT_DIR, "convergence.png"), show=False
)
print(f"\n  Saved: convergence.png")

monitor.plot_variables(
    coupled_sys,
    variables=["y1", "y2"],
    save=os.path.join(OUT_DIR, "variable_trace.png"),
    show=False,
)
print(f"  Saved: variable_trace.png")


# =========================================================================
# 7. PARAMETRIC SWEEP
# =========================================================================
section("7. PARAMETRIC SWEEP")

sweep_nozzle = anvil.S.rocket_nozzle.copy()

# Sweep chamber pressure from 2 MPa to 12 MPa
P0_values = np.linspace(2e6, 12e6, 12)  # Pa
sweep = sweep_nozzle.sweep("P0", P0_values, skip_errors=True)
sweep.summary(outputs=["thrust", "Isp", "mdot", "V_exit"])

# Export sweep data
sweep.to_csv(os.path.join(OUT_DIR, "sweep_pressure.csv"))
print(f"\n  Saved: sweep_pressure.csv")

# Plot sweep
monitor.plot_sweep(
    sweep,
    y=["thrust", "Isp", "mdot", "V_exit"],
    save=os.path.join(OUT_DIR, "sweep_plot.png"),
    show=False,
)
print(f"  Saved: sweep_plot.png")

# Sweep over area ratio (exit/throat)
sweep_nozzle2 = anvil.S.rocket_nozzle.copy()
A_exit_values = np.linspace(0.02, 0.16, 10)  # m^2
sweep2 = sweep_nozzle2.sweep("A_exit", A_exit_values, skip_errors=True)
sweep2.summary(outputs=["M_exit", "thrust", "Isp"])


# =========================================================================
# 8. SENSITIVITY ANALYSIS
# =========================================================================
section("8. SENSITIVITY ANALYSIS")

sens_sys = anvil.S.rocket_nozzle.copy()
sens = sens_sys.sensitivity(
    outputs=["thrust", "Isp", "mdot"],
    step=0.01,
)
sens.summary()

print(f"\nTop 3 drivers of Isp:")
for inp, val in sens.top("Isp", n=3):
    print(f"  {inp:20s}  {val:+.4f}")


# =========================================================================
# 9. DEPENDENCY GRAPH
# =========================================================================
section("9. DEPENDENCY GRAPH")

dep_sys = anvil.S.rocket_nozzle.copy()
try:
    dep_sys.validate()
except Exception:
    pass

monitor.plot_system(
    dep_sys, save=os.path.join(OUT_DIR, "dependency_graph.png"), show=False
)
print(f"  Saved: dependency_graph.png")


# =========================================================================
# 10. ODE SOLVERS
# =========================================================================
section("10. ODE SOLVERS")

# --- 10a. Explicit RK45: radioactive decay chain ---
print("\n--- Explicit RK45: two-species decay  A -> B -> products ---")
# dA/dt = -k1 * A
# dB/dt = +k1 * A - k2 * B
k1, k2 = 0.1, 0.3  # 1/s


def decay_chain(t, y):
    A, B = y
    return [-k1 * A, k1 * A - k2 * B]


t_span = (0.0, 30.0)
y0 = [1.0, 0.0]
t_eval = np.linspace(0, 30, 300)

sol = solvers.solve_ode(decay_chain, t_span, y0, t_eval=t_eval, rtol=1e-8, verbose=True)
A_final = sol["y"][0, -1]
B_max = sol["y"][1].max()
t_Bmax = t_eval[np.argmax(sol["y"][1])]
print(f"  A(30 s)  = {A_final:.6f}  (exact: {np.exp(-k1 * 30):.6f})")
print(f"  B_max    = {B_max:.4f}  at t ~ {t_Bmax:.2f} s")
print(f"  nfev     = {sol['nfev']}")

# Plot ODE result
try:
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    fig, ax = plt.subplots(figsize=(9, 4))
    ax.plot(sol["t"], sol["y"][0], label="A(t)", color="steelblue")
    ax.plot(sol["t"], sol["y"][1], label="B(t)", color="tomato")
    ax.set_xlabel("Time [s]")
    ax.set_ylabel("Concentration")
    ax.set_title("Decay chain A -> B -> products  (RK45)")
    ax.legend()
    ax.grid(True, alpha=0.3)
    fig.tight_layout()
    fig.savefig(os.path.join(OUT_DIR, "ode_decay_chain.png"), dpi=150)
    plt.close(fig)
    print(f"  Saved: ode_decay_chain.png")
except Exception as e:
    print(f"  Plot skipped: {e}")

# --- 10b. Stiff ODE: Robertson chemical kinetics ---
print("\n--- Stiff BDF: Robertson kinetics (classic benchmark) ---")


def robertson(t, y):
    k1, k2, k3 = 0.04, 3e7, 1e4
    return [
        -k1 * y[0] + k2 * y[1] * y[2],
        k1 * y[0] - k2 * y[1] * y[2] - k3 * y[1] ** 2,
        k3 * y[1] ** 2,
    ]


sol_stiff = solvers.solve_ode_stiff(
    robertson, (0, 1e11), [1.0, 0.0, 0.0], method="BDF", rtol=1e-8, verbose=True
)
y_end = sol_stiff["y"][:, -1]
print(f"  y(t=1e11): A={y_end[0]:.6f}  B={y_end[1]:.2e}  C={y_end[2]:.6f}")
print(f"  A + B + C = {y_end.sum():.8f}  (should = 1.0, conservation check)")

# --- 10c. ODE with event: find when B peaks ---
print("\n--- ODE with event: stop when dB/dt = 0 ---")


def dB_dt_zero(t, y):
    """Event: B reaches its maximum (dB/dt = 0)"""
    k1, k2 = 0.1, 0.3
    A, B = y
    return k1 * A - k2 * B  # zero when B peaks


dB_dt_zero.terminal = True
dB_dt_zero.direction = -1  # peak: going from + to -

sol_event = solvers.solve_ode(
    decay_chain, (0, 30), [1.0, 0.0], events=dB_dt_zero, rtol=1e-10
)
if sol_event["sol"].t_events[0].size > 0:
    t_peak = sol_event["sol"].t_events[0][0]
    y_peak = sol_event["sol"].y_events[0][0]
    print(
        f"  B peaks at t = {t_peak:.4f} s  (analytic: {np.log(k2 / k1) / (k2 - k1):.4f} s)"
    )
    print(f"  B_max = {y_peak[1]:.6f}")


# =========================================================================
# 11. BVP SOLVER
# =========================================================================
section("11. BOUNDARY VALUE PROBLEM (BVP)")

# Solve the heat conduction BVP:
#   -k T'' = q_dot  (volumetric heat source)
#   T(0) = T_left   (Dirichlet)
#   T(L) = T_right  (Dirichlet)
#
# Transform to first-order: y = [T, T']
#   y[0]' = y[1]
#   y[1]' = -q_dot / k

T_left_val = 300.0  # K
T_right_val = 500.0  # K
k_cond = 50.0  # W/m/K  (steel-ish)
q_dot = 1e6  # W/m^3  (volumetric heat source)
L_slab = 0.1  # m


def heat_bvp_rhs(x, y):
    """dy/dx = [T', T''] -> [y[1], -q_dot/k]"""
    return np.vstack([y[1], np.full_like(x, -q_dot / k_cond)])


def heat_bvp_bc(ya, yb):
    """Boundary: T(0) = T_left, T(L) = T_right"""
    return np.array([ya[0] - T_left_val, yb[0] - T_right_val])


# Initial mesh and guess (linear profile as starting guess)
x_mesh = np.linspace(0, L_slab, 10)
T_guess = np.linspace(T_left_val, T_right_val, 10)
y_guess = np.zeros((2, 10))
y_guess[0] = T_guess
y_guess[1] = (T_right_val - T_left_val) / L_slab  # constant slope guess

bvp_result = solvers.solve_bvp(heat_bvp_rhs, heat_bvp_bc, x_mesh, y_guess, verbose=True)

# Evaluate on fine grid
x_fine = np.linspace(0, L_slab, 200)
T_fine = bvp_result["sol"](x_fine)[0]

# Analytic solution: T(x) = T_left + (T_right-T_left)*x/L - q_dot/(2k) * x*(L-x)
T_analytic = (
    T_left_val
    + (T_right_val - T_left_val) * x_fine / L_slab
    - q_dot / (2 * k_cond) * x_fine * (L_slab - x_fine)
)

max_err = np.max(np.abs(T_fine - T_analytic))
T_max_numerical = T_fine.max()
T_max_analytic = T_analytic.max()

print(f"\n  T_max numerical = {T_max_numerical:.4f} K")
print(f"  T_max analytic  = {T_max_analytic:.4f} K")
print(f"  Max error       = {max_err:.4e} K  (BVP success={bvp_result['success']})")

try:
    import matplotlib.pyplot as plt

    fig, ax = plt.subplots(figsize=(8, 4))
    ax.plot(x_fine * 100, T_fine, "-", label="BVP numerical", linewidth=2)
    ax.plot(x_fine * 100, T_analytic, "--", label="Analytic", linewidth=1.5)
    ax.set_xlabel("x [cm]")
    ax.set_ylabel("Temperature [K]")
    ax.set_title("Heat conduction with volumetric source -- BVP solution")
    ax.legend()
    ax.grid(True, alpha=0.3)
    fig.tight_layout()
    fig.savefig(os.path.join(OUT_DIR, "bvp_heat.png"), dpi=150)
    plt.close(fig)
    print(f"  Saved: bvp_heat.png")
except Exception as e:
    print(f"  Plot skipped: {e}")


# =========================================================================
# 12. 1D HEAT PDE -- CRANK-NICOLSON
# =========================================================================
section("12. 1D HEAT EQUATION -- Crank-Nicolson FD")

# Fin cooling: Gaussian initial temperature distribution decays to walls
# alpha = k / (rho cp)  for aluminium
rho_al = 2700.0  # kg/m^3
cp_al = 900.0  # J/kg/K
k_al = 205.0  # W/m/K
alpha = k_al / (rho_al * cp_al)  # ~ 8.46e-5 m^2/s

print(f"\n  Aluminium alpha = {alpha:.4e} m2/s")

pde_result = solvers.solve_pde_heat_1d(
    alpha=alpha,
    x_span=(0.0, 0.1),  # 10 cm slab
    t_span=(0.0, 60.0),  # 60 second transient
    u_init=lambda x: 300.0 + 200.0 * np.exp(-500 * (x - 0.05) ** 2),
    bc_left=300.0,  # constant 300 K wall
    bc_right=300.0,  # constant 300 K wall
    nx=60,
    nt=600,  # 600 steps (Crank-Nicolson is unconditionally stable)
    verbose=True,
)

x = pde_result["x"]
t = pde_result["t"]
u = pde_result["u"]

T_center_init = u[0, 40]  # near x = 0.05 m
T_center_final = u[-1, 40]  # after 60 s
print(f"\n  T(center, t=0)  = {T_center_init:.2f} K")
print(f"  T(center, t=60) = {T_center_final:.2f} K  (cooled toward 300 K)")
print(
    f"  Grid: {pde_result['dx'] * 100:.2f} cm spacing, {pde_result['dt']:.3f} s time step"
)

try:
    import matplotlib.pyplot as plt
    from matplotlib import cm as mpl_cm

    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    # Spatial profiles at several time snapshots
    ax1 = axes[0]
    t_indices = [0, len(t) // 6, len(t) // 3, len(t) // 2, len(t) - 1]
    colors = plt.cm.viridis(np.linspace(0, 1, len(t_indices)))
    for idx, col in zip(t_indices, colors):
        ax1.plot(x * 100, u[idx], color=col, label=f"t={t[idx]:.1f} s", linewidth=1.5)
    ax1.set_xlabel("x [cm]")
    ax1.set_ylabel("Temperature [K]")
    ax1.set_title("Temperature profiles at snapshots")
    ax1.legend(fontsize=8)
    ax1.grid(True, alpha=0.3)

    # Center temperature vs time
    ax2 = axes[1]
    ax2.plot(t, u[:, 40], color="steelblue", linewidth=2)
    ax2.axhline(300, color="gray", linestyle="--", label="Wall T = 300 K")
    ax2.set_xlabel("Time [s]")
    ax2.set_ylabel("Temperature [K]")
    ax2.set_title("Center temperature vs time")
    ax2.legend()
    ax2.grid(True, alpha=0.3)

    fig.suptitle(
        "1D Heat Equation -- Crank-Nicolson (aluminium slab)",
        fontsize=12,
        fontweight="bold",
    )
    fig.tight_layout()
    fig.savefig(os.path.join(OUT_DIR, "pde_heat_1d.png"), dpi=150)
    plt.close(fig)
    print(f"  Saved: pde_heat_1d.png")
except Exception as e:
    print(f"  Plot skipped: {e}")


# =========================================================================
# 13. NASA CEA DETONATION ADAPTER
# =========================================================================
section("13. NASA CEA -- Chapman-Jouguet Detonation")

from anvil.adapters import nasa_cea_detonation
from anvil.adapters.nasa_cea_detonation import cea_detonation

if not nasa_cea_detonation.is_available():
    print("\n  NASA CEA not installed (pip install cea) -- section skipped.")
    print("\n" + "=" * 70)
    print("  Done (CEA sections skipped).")
    raise SystemExit(0)

# Single call -- full output
print("\n--- H2/O2 stoichiometric at 1 atm, 300 K ---")
cea_r = cea_detonation.func(
    fuel="H2",
    oxidizer="O2",
    fuel_moles=2.0,
    ox_moles=1.0,
    T1=300.0,
    P1=101325.0,
)

print("\n  Core CJ state:")
core_keys = ["D_CJ", "T_CJ", "P_CJ", "P_ratio", "rho_CJ", "gamma_CJ", "a_CJ", "u_CJ"]
for k in core_keys:
    v = cea_r[k]
    if isinstance(v, Q):
        print(f"    {k:10s} = {v} {v.unit}")
    else:
        print(f"    {k:10s} = {v:.4f}")

print("\n  Thermochemical:")
for k in ["cp_CJ", "cv_CJ", "e_CJ", "h_CJ"]:
    v = cea_r[k]
    print(f"    {k:10s} = {v} {v.unit}")

print("\n  Transport:")
for k in ["mu_CJ", "k_CJ", "Pr_CJ"]:
    v = cea_r[k]
    if isinstance(v, Q):
        print(f"    {k:10s} = {v} {v.unit}")
    else:
        print(f"    {k:10s} = {v:.4f}")

print("\n  Product species (mole fractions):")
sp = cea_r.get("species_CJ", {})
if sp:
    for name, frac in sorted(sp.items(), key=lambda x: -x[1]):
        bar_str = "#" * int(frac * 30)
        print(f"    {name:8s}  {frac:.4f}  {bar_str}")
else:
    print("    (species not available in this CEA version)")

# Verify adapter info
print(f"\n{cea_detonation.info()}")

# Sweep over initial pressure: 0.5 -> 5 atm
print("\n--- Pressure sweep: 0.5 -> 5 atm ---")
P1_vals_Pa = np.linspace(0.5 * 101325, 5 * 101325, 8)
D_vals, T_vals, P_ratio_vals = [], [], []

for P1 in P1_vals_Pa:
    r_p = cea_detonation.func(
        fuel="H2", oxidizer="O2", fuel_moles=2.0, ox_moles=1.0, T1=300.0, P1=float(P1)
    )
    D_vals.append(float(r_p["D_CJ"]._si_value))
    T_vals.append(float(r_p["T_CJ"]._si_value))
    P_ratio_vals.append(r_p["P_ratio"])

print(f"\n  {'P1 [atm]':>10}  {'D_CJ [m/s]':>12}  {'T_CJ [K]':>10}  {'P_ratio':>8}")
print(f"  {'-' * 46}")
for P1, D, T, PR in zip(P1_vals_Pa, D_vals, T_vals, P_ratio_vals):
    print(f"  {P1 / 101325:>10.2f}  {D:>12.1f}  {T:>10.1f}  {PR:>8.2f}")

# Plot CEA pressure sweep
try:
    import matplotlib.pyplot as plt

    fig, axes = plt.subplots(1, 3, figsize=(13, 4))
    P_atm_arr = P1_vals_Pa / 101325
    labels_data = [
        ("D_CJ [m/s]", D_vals),
        ("T_CJ [K]", T_vals),
        ("P2/P1", P_ratio_vals),
    ]
    for ax, (ylabel, ydata) in zip(axes, labels_data):
        ax.plot(P_atm_arr, ydata, "o-", color="firebrick", linewidth=1.5, markersize=5)
        ax.set_xlabel("P1 [atm]")
        ax.set_ylabel(ylabel)
        ax.grid(True, alpha=0.3)
    fig.suptitle(
        "H2/O2 CJ Detonation -- Pressure Sweep", fontsize=12, fontweight="bold"
    )
    fig.tight_layout()
    fig.savefig(os.path.join(OUT_DIR, "cea_pressure_sweep.png"), dpi=150)
    plt.close(fig)
    print(f"\n  Saved: cea_pressure_sweep.png")
except Exception as e:
    print(f"  Plot skipped: {e}")

# Fuel comparison at 1 atm
print("\n--- Fuel comparison at 1 atm, T1=300 K ---")
fuels = [
    ("H2", "O2", 2.0, 1.0),
    ("CH4", "O2", 1.0, 2.0),
    ("C2H4", "O2", 1.0, 3.0),
    ("C3H8", "O2", 1.0, 5.0),
]
print(
    f"  {'Fuel':>6}  {'D_CJ [m/s]':>12}  {'T_CJ [K]':>10}  {'P_ratio':>8}  "
    f"{'gamma_CJ':>6}  {'a_CJ [m/s]':>12}"
)
print(f"  {'-' * 62}")
for fuel_name, ox_name, fm, om in fuels:
    rc = cea_detonation.func(
        fuel=fuel_name,
        oxidizer=ox_name,
        fuel_moles=fm,
        ox_moles=om,
        T1=300.0,
        P1=101325.0,
    )
    print(
        f"  {fuel_name:>6}  "
        f"{float(rc['D_CJ']._si_value):>12.1f}  "
        f"{float(rc['T_CJ']._si_value):>10.1f}  "
        f"{rc['P_ratio']:>8.2f}  "
        f"{rc['gamma_CJ']:>6.3f}  "
        f"{float(rc['a_CJ']._si_value):>12.1f}"
    )


# =========================================================================
# 14. REGISTRY OPERATIONS
# =========================================================================
section("14. REGISTRY OPERATIONS")


# --- 14a. Register a custom relation ---
@anvil.relation(domain="heat_transfer.fins", tags=["fin", "efficiency"])
def fin_effectiveness(h, P_fin, k_fin, A_c, A_total):
    """
    Fin effectiveness: ratio of heat transfer with fin to without fin.
    eps = Q_fin / Q_without_fin
    """
    import numpy as np

    m = (h * P_fin / (k_fin * A_c)) ** 0.5
    L = A_c / P_fin  # characteristic length
    Q_fin = (h * P_fin * k_fin * A_c) ** 0.5  # per unit DT
    Q_no_fin = h * A_c
    effectiveness = Q_fin / Q_no_fin
    return {"fin_eff": effectiveness}


print(f"\nRegistered: fin_effectiveness")
print(fin_effectiveness.info())  # use the object directly (namespace rebuild is async)

# --- 14b. Search registry ---
print("\nSearch 'compressible':")
hits = anvil.registry.search("compressible")
for h in hits[:4]:
    print(f"  [{h['type']}] {h['name']:30s}  {h['description'][:50]}")

print("\nSearch 'orbital':")
hits2 = anvil.registry.search("orbital")
for h in hits2:
    print(f"  [{h['type']}] {h['name']:30s}  {h['description'][:50]}")

# --- 14c. List by domain ---
print("\nList domain='aero':")
anvil.registry.list(domain="aero")

# --- 14d. Detailed info on a specific RSQ ---
print("\nInfo on 'normal_shock':")
anvil.registry.info("normal_shock")


# --- 14e. Update an existing RSQ ---
@anvil.relation(
    domain="heat_transfer.fins",
    tags=["fin", "efficiency", "v2"],
    name="fin_effectiveness",
    register=False,
)
def fin_effectiveness_v2(h, P_fin, k_fin, A_c, A_total):
    """Fin effectiveness -- improved (includes fin tip correction)."""
    m_val = (h * P_fin / (k_fin * A_c)) ** 0.5
    L_c = A_c / P_fin + A_c / P_fin * 0.05  # tip correction ~ 5%
    mL = m_val * L_c
    import numpy as np

    Q_fin = (h * P_fin * k_fin * A_c) ** 0.5 * np.tanh(mL) / mL
    Q_no_fin = h * A_c
    return {"fin_eff": Q_fin / Q_no_fin}


anvil.update(
    fin_effectiveness_v2,
    name="fin_effectiveness",
    domain="heat_transfer.fins",
    tags=["fin", "efficiency", "v2"],
)

# --- 14f. Export source ---
print("\nExport source of 'ideal_gas_rho':")
anvil.registry.export("ideal_gas_rho")


# =========================================================================
# FINAL SUMMARY
# =========================================================================
section("FILES SAVED")
saved = [
    "nozzle_result.csv",
    "sweep_pressure.csv",
    "convergence.png",
    "variable_trace.png",
    "sweep_plot.png",
    "dependency_graph.png",
    "ode_decay_chain.png",
    "bvp_heat.png",
    "pde_heat_1d.png",
    "cea_pressure_sweep.png",
]
for f in saved:
    path = os.path.join(OUT_DIR, f)
    exists = os.path.exists(path)
    print(f"  {'OK' if exists else 'MISSING':6s}  {f}")

print(f"\n{'=' * 65}")
print(f"  Anvil Framework showcase complete.")
print(f"{'=' * 65}\n")

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: Signal Processing RSQs¶

examples/ex_signal_processing.py: all 7 signal processing RSQs in the misc domain

import sys, os

import numpy as np
import anvil

rng = np.random.default_rng(42)

# ── Shared signals ────────────────────────────────────────────────────────────
fs   = 2048.0                              # sample rate [Hz]
dt   = 1.0 / fs
n    = 4096
t    = np.arange(n) * dt                   # 2 seconds

# Clean signal: 50 Hz fundamental + 3rd harmonic
sig_clean = np.sin(2*np.pi*50*t) + 0.3*np.sin(2*np.pi*150*t)

# Noisy version
noise     = 0.4 * rng.standard_normal(n)
sig_noisy = sig_clean + noise

# Chirp: frequency sweeps 20 -> 400 Hz over 2 s
chirp = np.sin(2*np.pi * (20 + 190*t) * t)

# AM signal: 500 Hz carrier, 8 Hz modulation
am = (1 + 0.7*np.sin(2*np.pi*8*t)) * np.sin(2*np.pi*500*t)

# Bearing fault: 2 kHz carrier, 120 Hz outer-race fault, noise
# Needs fs > 2*2500 = 5 kHz; use separate higher sample rate
fs_fault  = 8192.0
dt_fault  = 1.0 / fs_fault
n_fault   = 16384
t_fault   = np.arange(n_fault) * dt_fault
fault     = (1 + 0.6*np.sin(2*np.pi*120*t_fault)) * np.sin(2*np.pi*2000*t_fault) \
            + 0.3*rng.standard_normal(n_fault)


# ══════════════════════════════════════════════════════════════════════════════
# 1. fft_spectrum
# ══════════════════════════════════════════════════════════════════════════════
print("=" * 60)
print("1. fft_spectrum, power spectrum")
print("=" * 60)

r = anvil.R.fft_spectrum(signal=sig_clean, dt=dt, window="hann")
print(f"  Signal: 50 Hz + 0.3x150 Hz")
print(f"  dominant_freq  = {r['dominant_freq']:.1f} Hz")
print(f"  RMS            = {r['rms']:.4f}")
print(f"  THD            = {r['thd']:.4f}  (~= 0.30 = amplitude of 3rd harmonic)")
print(f"  f_resolution   = {r['f_resolution']:.3f} Hz  (= 1 / 2 s = 0.5 Hz)")
print(f"  spectrum shape : {r['power'].shape}  ({r['n_samples']} samples -> {len(r['freqs'])} bins)")

# Window comparison
print(f"\n  Window comparison (same signal):")
for win in ["none", "hann", "hamming", "blackman"]:
    rw = anvil.R.fft_spectrum(signal=sig_clean, dt=dt, window=win)
    print(f"    {win:10s}: dominant={rw['dominant_freq']:.1f} Hz  THD={rw['thd']:.4f}")
print("  (rectangular 'none' accurate for exact-integer-cycle signals)")


# ══════════════════════════════════════════════════════════════════════════════
# 2. welch_psd
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("2. welch_psd, averaged power spectral density")
print("=" * 60)

r_fft   = anvil.R.fft_spectrum(signal=sig_noisy, dt=dt)
r_welch = anvil.R.welch_psd(signal=sig_noisy, dt=dt, nperseg=512)

print(f"  Noisy signal (SNR ~= {20*np.log10(0.7/0.4):.1f} dB)")
print(f"  FFT  dominant_freq = {r_fft['dominant_freq']:.1f} Hz")
print(f"  Welch dominant_freq = {r_welch['dominant_freq']:.1f} Hz")
print(f"  Welch total_power   = {r_welch['total_power']:.4f}")
print(f"  Welch f_resolution  = {r_welch['f_resolution']:.3f} Hz  (nperseg=512)")
print(f"  PSD shape: {r_welch['psd'].shape}")

# nperseg tradeoff
print(f"\n  nperseg tradeoff (noise floor vs resolution):")
for nperseg in [128, 256, 512, 1024]:
    rw = anvil.R.welch_psd(signal=sig_noisy, dt=dt, nperseg=nperseg)
    print(f"    nperseg={nperseg:4d}: f_res={rw['f_resolution']:.2f} Hz  "
          f"dominant={rw['dominant_freq']:.1f} Hz")


# ══════════════════════════════════════════════════════════════════════════════
# 3. stft_spectrogram
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("3. stft_spectrogram, time-frequency power map")
print("=" * 60)

r = anvil.R.stft_spectrogram(signal=chirp, dt=dt, nperseg=256, window="hann")
print(f"  Chirp: 20 -> 400 Hz sweep over 2 s")
print(f"  S shape (n_freq x n_time): {r['S'].shape}")
print(f"  n_frames : {r['n_frames']}")
print(f"  t_peak   = {r['t_peak']:.3f} s   (energy peak near end, highest freq)")
print(f"  f_peak   = {r['f_peak']:.1f} Hz")

# Time-frequency slices: check instantaneous frequency tracks the chirp
t_centers = r['t']
f_inst_expected = 20 + 190 * t_centers   # f(t) = 20 + 2x95xt (chirp formula: d/dt[(20+190t)t])
f_inst_expected = np.clip(f_inst_expected, 0, fs/2)

# Find peak frequency per time frame
f_per_frame = r['freqs'][np.argmax(r['S'], axis=0)]
print(f"\n  Instantaneous frequency tracking (sample frames):")
step = max(1, len(t_centers)//8)
print(f"  {'t [s]':>7}  {'f_inst [Hz]':>12}  {'f_expected [Hz]':>16}")
for i in range(0, len(t_centers), step):
    print(f"  {t_centers[i]:7.3f}  {f_per_frame[i]:12.1f}  {f_inst_expected[i]:16.1f}")
print("  (STFT tracks swept frequency through time)")


# ══════════════════════════════════════════════════════════════════════════════
# 4. bandpass_filter
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("4. bandpass_filter, zero-phase Butterworth")
print("=" * 60)

# Lowpass: keep 50 Hz, suppress 150 Hz harmonic and noise
r_lp = anvil.R.bandpass_filter(signal=sig_noisy, dt=dt, f_high=80.0, order=5)
print(f"  Lowpass  (f_high=80 Hz, order=5):")
print(f"    RMS in  = {r_lp['rms_in']:.4f}")
print(f"    RMS out = {r_lp['rms_out']:.4f}  (noise + 150 Hz removed)")
print(f"    att.    = {r_lp['attenuation_dB']:.1f} dB")

# Bandpass: isolate 50 Hz ± 20 Hz band
r_bp = anvil.R.bandpass_filter(signal=sig_noisy, dt=dt, f_low=30.0, f_high=70.0, order=4)
print(f"\n  Bandpass (30-70 Hz, order=4):")
print(f"    RMS in  = {r_bp['rms_in']:.4f}")
print(f"    RMS out = {r_bp['rms_out']:.4f}  (only 50 Hz component passes)")
print(f"    att.    = {r_bp['attenuation_dB']:.1f} dB")

# Highpass: remove DC drift
drift = sig_clean + 2.5 + 0.3*t   # add DC + slow drift
r_hp = anvil.R.bandpass_filter(signal=drift, dt=dt, f_low=5.0, order=3)
print(f"\n  Highpass (f_low=5 Hz, order=3): removes DC/drift")
print(f"    mean before filter = {drift.mean():.3f}")
print(f"    mean after filter  = {r_hp['signal_filtered'].mean():.6f}  (~= 0)")

# Order comparison
print(f"\n  Filter order vs stopband attenuation (bandpass 30-70 Hz):")
for order in [2, 4, 6, 8]:
    rr = anvil.R.bandpass_filter(signal=sig_noisy, dt=dt, f_low=30, f_high=70, order=order)
    print(f"    order={order}: RMS_out={rr['rms_out']:.4f}  att={rr['attenuation_dB']:.1f} dB")


# ══════════════════════════════════════════════════════════════════════════════
# 5. envelope_detection
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("5. envelope_detection, Hilbert transform")
print("=" * 60)

r = anvil.R.envelope_detection(signal=am, dt=dt)
print(f"  AM signal: 500 Hz carrier, 8 Hz modulation depth=0.7")
print(f"  peak_envelope = {r['peak_envelope']:.4f}  (expected ~= 1.70)")
print(f"  mean_envelope = {r['mean_envelope']:.4f}  (expected ~= 1.00)")
print(f"  carrier freq  ~= {float(np.median(r['inst_freq'])):.1f} Hz  (median of inst_freq)")

# Envelope spectrum: FFT of envelope reveals modulation frequency
env_spec = anvil.R.fft_spectrum(signal=r['envelope'], dt=dt, window="hann")
# Find second peak (skip DC region)
mask = env_spec['freqs'] > 2
f_mod = env_spec['freqs'][mask][np.argmax(env_spec['power'][mask])]
print(f"  modulation freq from envelope spectrum = {f_mod:.1f} Hz  (expected 8 Hz)")

# Fault signal: bearing fault detection (uses higher sample rate signal)
print(f"\n  Bearing fault detection (2 kHz carrier, 120 Hz fault, fs={int(fs_fault)} Hz):")
# Step 1: bandpass around 2 kHz carrier
r_bp = anvil.R.bandpass_filter(signal=fault, dt=dt_fault, f_low=1500, f_high=2500, order=5)
# Step 2: envelope
r_env = anvil.R.envelope_detection(signal=r_bp['signal_filtered'], dt=dt_fault)
# Step 3: FFT of envelope -> fault frequency appears at 120 Hz
r_env_spec = anvil.R.fft_spectrum(signal=r_env['envelope'], dt=dt_fault, window="hann")
mask2 = r_env_spec['freqs'] > 10
f_fault_det = r_env_spec['freqs'][mask2][np.argmax(r_env_spec['power'][mask2])]
print(f"  Detected fault frequency = {f_fault_det:.1f} Hz  (expected 120 Hz)")


# ══════════════════════════════════════════════════════════════════════════════
# 6. cross_correlation
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("6. cross_correlation, time delay estimation")
print("=" * 60)

# Use broadband (noise) signal, xcorr on periodic sine has many equal peaks
# making argmax unreliable without restricting the lag search window.
delay_samples = 35
# Use broadband signal, xcorr on a pure periodic sine has many equal-height
# peaks separated by the signal period, making argmax unreliable.
# broadband noise has a unique peak at the true delay.
broadband = rng.standard_normal(n)
sig_ref    = broadband                          # reference (earlier sensor)
sig_del    = np.roll(broadband, delay_samples)  # delayed copy (later sensor)

# Correlate (delayed, reference) -> peak at +delay_samples
r = anvil.R.cross_correlation(signal_a=sig_del, signal_b=sig_ref, dt=dt)
print(f"  Broadband signal, delay = {delay_samples} samples = {delay_samples*dt*1000:.3f} ms")
print(f"  Detected lag    = {r['lag_peak']*1000:.3f} ms  ({round(r['lag_peak']/dt):.0f} samples)")
print(f"  corr_peak       = {r['corr_peak']:.6f}  (1.0 = perfect match)")

# Noisy: does xcorr still recover the delay?
print(f"\n  Noise robustness (broadband, 35-sample delay):")
for snr_db in [20, 10, 3, 0]:
    noise_amp = 10**(-snr_db/20)
    sig_del_noisy = sig_del + noise_amp * rng.standard_normal(n)
    rr = anvil.R.cross_correlation(signal_a=sig_del_noisy, signal_b=sig_ref, dt=dt)
    detected = round(rr['lag_peak']/dt)
    print(f"    SNR={snr_db:3d} dB: lag={detected:4.0f} samples  corr_peak={rr['corr_peak']:.4f}")

# Flow velocity measurement from two sensors
print(f"\n  Flow velocity (two probes, d=0.5 m apart):")
d_probe      = 0.5   # m
broadband2   = rng.standard_normal(n)
v_true       = 12.5  # m/s -> delay = d/v
delay_samp   = int(d_probe / v_true / dt)
sig_down     = np.roll(broadband2, delay_samp)
r_flow       = anvil.R.cross_correlation(signal_a=broadband2, signal_b=sig_down, dt=dt)
v_measured   = d_probe / abs(r_flow['lag_peak'])
print(f"    True velocity     = {v_true:.2f} m/s  (delay = {delay_samp} samples)")
print(f"    Measured velocity = {v_measured:.2f} m/s  (lag={r_flow['lag_peak']*1000:.2f} ms)")


# ══════════════════════════════════════════════════════════════════════════════
# 7. signal_statistics
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("7. signal_statistics, descriptive statistics")
print("=" * 60)

signals = {
    "sine 50 Hz":      (sig_clean,  dt),
    "sine + noise":    (sig_noisy,  dt),
    "Gaussian noise":  (rng.standard_normal(n), dt),
    "bearing fault":   (fault[:n],  dt_fault),   # use same-length slice
    "impulse train":   (np.where((np.arange(n) % 256) == 0, 5.0, 0.0) + 0.1*rng.standard_normal(n), dt),
}

print(f"  {'Signal':>16}  {'RMS':>6}  {'Crest':>6}  {'Kurtosis':>10}  {'Skew':>6}")
for name, (s, s_dt) in signals.items():
    r = anvil.R.signal_statistics(signal=s, dt=s_dt)
    print(f"  {name:>16}  {r['rms']:6.3f}  {r['crest_factor']:6.3f}  {r['kurtosis']:10.4f}  {r['skewness']:6.3f}")

print(f"\n  Notes:")
print(f"    Gaussian noise: kurtosis ~= 3.0 (mesokurtic)")
print(f"    Bearing fault:  kurtosis > 3, impulsive content from carrier modulation")
print(f"    Impulse train:  very high crest factor and kurtosis, sparse, large peaks")
print(f"    Sine: kurtosis ~= 1.5, crest factor = sqrt2 ~= 1.414")


# ══════════════════════════════════════════════════════════════════════════════
# 8. Sweep example: SNR effect on dominant frequency detection
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("8. Direct loop, nperseg vs Welch frequency resolution")
print("=" * 60)

# Note: sys.sweep() sweeps scalar parameters. Array inputs (signal) must be
# fixed; sweep over numeric parameters like nperseg directly.
print(f"  {'nperseg':>8}  {'f_res [Hz]':>12}  {'dominant [Hz]':>15}  {'dom_psd':>10}")
for nperseg in [64, 128, 256, 512, 1024, 2048]:
    rw = anvil.R.welch_psd(signal=sig_noisy, dt=dt, nperseg=nperseg)
    print(f"  {nperseg:>8}  {rw['f_resolution']:12.3f}  {rw['dominant_freq']:15.1f}  {rw['dominant_psd']:.4f}")
print("  (larger nperseg -> finer freq resolution; fewer averages -> higher variance)")


# ══════════════════════════════════════════════════════════════════════════════
# 9. Full pipeline: vibration health monitoring
# ══════════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("9. Full pipeline: vibration health monitoring")
print("=" * 60)

for fault_level in [0.0, 0.3, 0.6, 1.0]:
    # Use higher fs to accommodate 2 kHz carrier + 1500-2500 Hz bandpass
    vibration = (1 + fault_level*np.sin(2*np.pi*120*t_fault)) * np.sin(2*np.pi*2000*t_fault) \
                + 0.2*rng.standard_normal(n_fault)

    # Step 1: raw statistics
    stats = anvil.R.signal_statistics(signal=vibration, dt=dt_fault)

    # Step 2: bandpass around 2 kHz, extract envelope
    bp     = anvil.R.bandpass_filter(signal=vibration, dt=dt_fault, f_low=1500, f_high=2500, order=5)
    env    = anvil.R.envelope_detection(signal=bp['signal_filtered'], dt=dt_fault)
    espec  = anvil.R.fft_spectrum(signal=env['envelope'], dt=dt_fault, window="hann")

    mask3  = espec['freqs'] > 10
    f_detected = espec['freqs'][mask3][np.argmax(espec['power'][mask3])]
    p_fault    = float(espec['power'][mask3][np.argmax(espec['power'][mask3])])

    print(f"  fault_level={fault_level:.1f}:  kurtosis={stats['kurtosis']:.2f}  "
          f"crest={stats['crest_factor']:.2f}  "
          f"f_fault={f_detected:.0f} Hz  fault_power={p_fault:.4f}")

print("  (fault_level 0 -> kurtosis near Gaussian, fault_power ~noise floor)")
print("  (fault_level 1.0 -> elevated kurtosis, fault frequency at 120 Hz clearly detected)")

Output:

============================================================
1. fft_spectrum, power spectrum
============================================================
  Signal: 50 Hz + 0.3x150 Hz
  dominant_freq  = 50.0 Hz
  RMS            = 0.7382
  THD            = 0.3000  (~= 0.30 = amplitude of 3rd harmonic)
  f_resolution   = 0.500 Hz  (= 1 / 2 s = 0.5 Hz)
  spectrum shape : (2049,)  (4096 samples -> 2049 bins)

  Window comparison (same signal):
    none      : dominant=50.0 Hz  THD=0.3000
    hann      : dominant=50.0 Hz  THD=0.3000
    hamming   : dominant=50.0 Hz  THD=0.3000
    blackman  : dominant=50.0 Hz  THD=0.3000
  (rectangular 'none' accurate for exact-integer-cycle signals)

============================================================
2. welch_psd, averaged power spectral density
============================================================
  Noisy signal (SNR ~= 4.9 dB)
  FFT  dominant_freq = 50.0 Hz
  Welch dominant_freq = 48.0 Hz
  Welch total_power   = 0.6956
  Welch f_resolution  = 4.000 Hz  (nperseg=512)
  PSD shape: (257,)

  nperseg tradeoff (noise floor vs resolution):
    nperseg= 128: f_res=16.00 Hz  dominant=48.0 Hz
    nperseg= 256: f_res=8.00 Hz  dominant=48.0 Hz
    nperseg= 512: f_res=4.00 Hz  dominant=48.0 Hz
    nperseg=1024: f_res=2.00 Hz  dominant=50.0 Hz

============================================================
... (112 more lines)

Example: SU2 CFD Adapter (real only -- requires SU2_CFD on PATH)¶

examples/ex_su2_adapter.py: su2_euler and su2_rans against a real SU2 install.

import sys, os

import anvil
from anvil.adapters import su2_aero
from anvil.adapters.su2_aero import su2_euler, su2_rans, register

if not su2_aero.is_available():
    print("SU2_CFD binary not found on PATH -- skipping example.")
    print("Install: https://su2code.github.io/download.html")
    raise SystemExit(0)

if len(sys.argv) < 3:
    print("Usage: python ex_su2_adapter.py <case.cfg> <mesh.su2>")
    print("Grab a tutorial case (cfg + su2 mesh) from:")
    print("  https://su2code.github.io/tutorials/Inviscid_2D_Unconstrained_NACA0012/")
    raise SystemExit(0)

cfg_path, mesh_path = sys.argv[1], sys.argv[2]
for p in (cfg_path, mesh_path):
    if not os.path.exists(p):
        raise SystemExit(f"File not found: {p}")

# ── Euler (inviscid) ─────────────────────────────────────────────────────────
print("=== SU2 Euler: inviscid (M=0.5, AoA=2 deg) ===")
r = su2_euler(cfg_template=cfg_path, mesh=mesh_path, Mach=0.5, AoA_deg=2.0)
print(f"  CL = {r['CL']:.4f}   CD = {r['CD']:.5f}   CM = {r['CM']:.4f}")

# ── Mach sweep: wave drag onset ──────────────────────────────────────────────
print("\n=== Wave drag onset: CD vs Mach (AoA=2 deg, inviscid) ===")
sys_ = anvil.system("su2_mach_sweep")
sys_.add("cfg_template", cfg_path)
sys_.add("mesh",         mesh_path)
sys_.add("AoA_deg",      2.0)
sys_.add("Mach",         0.5)   # placeholder; swept below
sys_.use(su2_euler)

sweep = sys_.sweep("Mach", [0.5, 0.7, 0.8, 0.85, 0.9])
print(f"  {'Mach':>6}  {'CL':>7}  {'CD':>8}")
for i in range(len(sweep.table)):
    row = sweep.table.iloc[i]
    print(f"  {row['Mach']:6.2f}  {row['CL']:7.4f}  {row['CD']:8.5f}")
print("  (CD rises sharply past the drag-divergence Mach number)")

# ── RANS (viscous) -- needs a cfg with a turbulence model + wall BC mesh ─────
# Uncomment if your cfg/mesh pair is a RANS case:
# r2 = su2_rans(cfg_template=cfg_path, mesh=mesh_path,
#               Mach=0.3, AoA_deg=4.0, Reynolds=3e6)
# print(f"  CL = {r2['CL']:.4f}   CD = {r2['CD']:.5f} (pressure + friction)")

# ── Register ─────────────────────────────────────────────────────────────────
print("\n=== Register adapters ===")
register()

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: Surrogate Model Adapters¶

examples/ex_surrogate_adapter.py: make_poly_adapter, make_rbf_adapter (real methods on Anvil's

import sys, os

import numpy as np
import anvil
from anvil.adapters import surrogate_models
from anvil.adapters.surrogate_models import (
    make_gp_adapter, make_poly_adapter, make_rbf_adapter, gp_demo, register
)

rng = np.random.default_rng(42)
HAS_SKLEARN = surrogate_models.is_available()
if not HAS_SKLEARN:
    print("scikit-learn not installed -- GP sections skipped.")
    print("Install: pip install scikit-learn\n")

# ── Demo adapter: noisy sine (GP; needs sklearn) ─────────────────────────────
gp_cd = None
if HAS_SKLEARN:
    print("=== Demo GP surrogate: noisy sin(x) ===")
    for x in [0.5, 1.57, 3.14, 4.71]:
        r = gp_demo(x=x)
        print(f"  x={x:.2f}:  y_pred={r['y_pred']:.4f}  y_std={r['y_std']:.4f}"
              f"  exact={r['y_exact']:.4f}")

    # ── GP surrogate from custom data ────────────────────────────────────────
    print("\n=== Custom GP surrogate: drag coefficient vs AoA ===")
    # Synthetic drag polar training data
    aoa_train  = np.linspace(-4, 16, 15)
    cd_train   = 0.01 + 0.003 * aoa_train + 0.0015 * aoa_train**2 + 0.005 * rng.standard_normal(15)
    cd_train   = np.maximum(cd_train, 0.005)

    gp_cd = make_gp_adapter(
        X_train=aoa_train.reshape(-1, 1),
        y_train=cd_train,
        x_name="AoA_deg",
        y_name="CD_pred",
        x_unit="1",
        y_unit="1",
        name="drag_gp",
        desc="GP drag coefficient surrogate from wind tunnel data",
    )
    print("  AoA sweep prediction:")
    for aoa in [-2, 0, 4, 8, 12, 15]:
        r = gp_cd(AoA_deg=float(aoa))
        cd = r["CD_pred"] if not hasattr(r["CD_pred"], "si") else float(r["CD_pred"].si)
        unc = r["CD_pred_std"] if not hasattr(r["CD_pred_std"], "si") else float(r["CD_pred_std"].si)
        print(f"  AoA={aoa:3d} deg:  CD={cd:.5f} +/- {unc:.5f}")

# ── Polynomial surrogate ──────────────────────────────────────────────────────
print("\n=== Polynomial chaos surrogate (degree 4): C_d = f(Re) ===")
Re_train = np.logspace(4, 7, 20)
cd_sphere_train = (
    24.0 / Re_train
    + 6.0 / (1.0 + Re_train**0.5)
    + 0.4
    + 0.01 * rng.standard_normal(20)
)
# Work in log10(Re) space for numerical stability
log_Re_train = np.log10(Re_train)

poly_cd = make_poly_adapter(
    X_train=log_Re_train,
    y_train=cd_sphere_train,
    x_name="log_Re",
    y_name="CD_sphere",
    degree=4,
    name="sphere_drag_poly",
    desc="Sphere drag coefficient polynomial surrogate",
)
print(f"  {'Re':>10}  {'CD_pred':>9}  {'CD_exact':>10}")
for Re in [1e4, 1e5, 5e5, 1e6, 5e6]:
    r = poly_cd(log_Re=np.log10(Re))
    cd_pred  = r["CD_sphere"] if not hasattr(r["CD_sphere"], "si") else float(r["CD_sphere"].si)
    cd_exact = 24/Re + 6/(1+Re**0.5) + 0.4
    print(f"  {Re:10.2e}  {cd_pred:9.5f}  {cd_exact:10.5f}")

# ── RBF surrogate (2-input) ────────────────────────────────────────────────────
print("\n=== RBF surrogate (2 inputs): lift = f(AoA, Mach) ===")
n_pts  = 40
aoa_s  = rng.uniform(-4, 14, n_pts)
mach_s = rng.uniform(0.1, 0.8, n_pts)
cl_s   = (2 * np.pi * np.radians(aoa_s)
           / np.sqrt(1 - mach_s**2)
           + 0.02 * rng.standard_normal(n_pts))

X_2d = np.column_stack([aoa_s, mach_s])

rbf_cl = make_rbf_adapter(
    X_train=X_2d, y_train=cl_s,
    input_names=["AoA_deg", "Mach"],
    y_name="CL_pred",
    function="multiquadric",
    name="lift_rbf",
    desc="Lift coefficient RBF surrogate (AoA, Mach)",
)
print(f"  {'AoA':>5}  {'Mach':>5}  {'CL_RBF':>8}  {'CL_theory':>10}")
for aoa, mach in [(2, 0.3), (5, 0.3), (5, 0.6), (8, 0.5)]:
    r = rbf_cl(AoA_deg=float(aoa), Mach=float(mach))
    cl_rbf = r["CL_pred"] if not hasattr(r["CL_pred"], "si") else float(r["CL_pred"].si)
    import math
    cl_th  = 2*math.pi*math.radians(aoa) / math.sqrt(1 - mach**2)
    print(f"  {aoa:5.1f}  {mach:5.2f}  {cl_rbf:8.4f}  {cl_th:10.4f}")

# ── GP surrogate in Anvil System ──────────────────────────────────────────────
if gp_cd is not None:
    print("\n=== GP surrogate in System: drag polar study ===")
    sys_ = anvil.system("surrogate_polar")
    sys_.add("AoA_deg", 0.0)
    sys_.use(gp_cd)

    alphas = np.linspace(-2, 14, 9)
    sweep  = sys_.sweep("AoA_deg", alphas)
    print(f"  {'AoA':>5}  {'CD_pred':>9}  {'uncertainty':>12}")
    for i in range(len(alphas)):
        row = sweep.table.iloc[i]
        cd  = row.get("CD_pred", None)
        if cd is None:
            continue
        cd  = float(cd.si) if hasattr(cd, "si") else float(cd)
        unc = row.get("CD_pred_std", 0.0)
        unc = float(unc.si) if hasattr(unc, "si") else float(unc)
        print(f"  {alphas[i]:5.1f}  {cd:9.5f}  +/-{unc:.5f}")

# ── Register ──────────────────────────────────────────────────────────────────
if HAS_SKLEARN:
    print("\n=== Register demo adapter ===")
    register()
    print("  Global: gp_demo_sine -> domain surrogate.demo")
    print("  Factories: make_gp_adapter, make_poly_adapter, make_rbf_adapter")

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: UQ Adapter -- Monte Carlo Uncertainty Propagation in Anvil¶

examples/ex_uq_adapter.py: the uq_montecarlo adapter: propagate input uncertainty through

import anvil
from anvil import Q
from anvil.adapters.uq_surrogate import uq_montecarlo, register


def _num(v):
    """Plain float of a Q or number (uq outputs are plain floats)."""
    return float(v.value) if hasattr(v, "value") else float(v)


W = 64
print("=" * W)
print("  UQ Monte Carlo Adapter Example (native numpy MC)")
print("=" * W)

register()

# ── 1. UQ over several response models (native numpy MC, always runs) ────────
print("\n[1] a ~ N(10,1), b ~ N(5,0.5), 20000 samples (native numpy)")
print(f"  {'model':10s}  {'mean':>10s}  {'std':>9s}  {'p05':>10s}  {'p95':>10s}  {'R2':>6s}")
print(f"  {'-'*10}  {'-'*10}  {'-'*9}  {'-'*10}  {'-'*10}  {'-'*6}")
for model in ("sum", "product", "ratio", "quadratic"):
    r = uq_montecarlo(model=model, a_mean=10.0, a_std=1.0,
                      b_mean=5.0, b_std=0.5, n_samples=20000, seed=0)
    print(f"  {model:10s}  {_num(r['mean']):10.4f}  {_num(r['std']):9.4f}  "
          f"{_num(r['p05']):10.4f}  {_num(r['p95']):10.4f}  {_num(r['surrogate_r2']):6.3f}")
print(f"  (source: {r['source']})")

# ── 1b. Optional scikit-learn surrogate ──────────────────────────────────────
print("\n[1b] Optional scikit-learn surrogate (surrogate='sklearn')")
try:
    rs = uq_montecarlo(model="product", a_mean=10.0, a_std=1.0,
                       b_mean=5.0, b_std=0.5, n_samples=20000, seed=0,
                       surrogate="sklearn")
    print(f"  sklearn surrogate R^2 = {_num(rs['surrogate_r2']):.4f}  "
          f"(source: {rs['source']})")
except ImportError as e:
    print("  scikit-learn is not installed -- the optional sklearn surrogate")
    print(f"  is unavailable: {e}")
    print("  Install scikit-learn to use it: pip install scikit-learn")
    print("  (The native numpy MC above ran fine without it.)")

# ── 2. Pipeline: UQ feeding a margin calculation ─────────────────────────────
print("\n[2] System: design margin from UQ mean & std (native numpy MC)")
uq = uq_montecarlo(model="product", a_mean=100.0, a_std=5.0,
                   b_mean=2.0, b_std=0.1, n_samples=20000, seed=0)
print(f"  mean    = {_num(uq['mean']):.2f}  (source: {uq['source']})")
print(f"  std     = {_num(uq['std']):.2f}")

study = anvil.system("uq_study")
study.add("mean", _num(uq["mean"]), "1")
study.add("std", _num(uq["std"]), "1")
study.add("limit", 250.0, "1")

def margin(mean, std, limit):
    # Number of std-devs of headroom before exceeding the limit.
    n_sigma = (limit - mean) / std if std > 0 else float("inf")
    return {"n_sigma": Q(n_sigma, "1")}
study.use(margin)

res = study.solve_forward()
print(f"  n_sigma = {res['n_sigma'].value:.2f}")

print("\n" + "=" * W)
print("  Done.")
print("=" * W)

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Example: XFOIL 2D Airfoil Adapter (real only -- requires XFOIL on PATH)¶

examples/ex_xfoil_adapter.py: xfoil_polar and xfoil_alpha_sweep against a real XFOIL binary.

import sys, os

import anvil
from anvil.adapters import xfoil_airfoil
from anvil.adapters.xfoil_airfoil import xfoil_polar, xfoil_alpha_sweep, register

if not xfoil_airfoil.is_available():
    print("XFOIL binary not found on PATH -- skipping example.")
    print("Install: https://web.mit.edu/drela/Public/web/xfoil/")
    print("(put xfoil.exe / xfoil on your PATH, then re-run)")
    raise SystemExit(0)

# ── Single operating point ────────────────────────────────────────────────────
print("=== NACA2412: single point (alpha = 4 deg, Re = 1e6) ===")
r = xfoil_polar(airfoil="NACA2412", Re=1e6, alpha_deg=4.0, Mach=0.1)
print(f"  CL   = {r['CL']:.4f}")
print(f"  CD   = {r['CD']:.5f}")
print(f"  CM   = {r['CM']:.4f}")
print(f"  L/D  = {r['CL']/r['CD']:.1f}")
print(f"  transition (top/bot): {r['xtr_top']:.3f} / {r['xtr_bot']:.3f}")

# ── Polar sweep ───────────────────────────────────────────────────────────────
print("\n=== Alpha sweep (-4 to 12 deg, Re = 1.5e6) ===")
r = xfoil_alpha_sweep(airfoil="NACA2412", Re=1.5e6,
                      alpha_min=-4.0, alpha_max=12.0, alpha_step=2.0,
                      Mach=0.15)
print(f"  Converged points: {r['n_converged']}")
print(f"  CL range: [{r['CL_array'].min():.3f}, {r['CL_array'].max():.3f}]")
best = (r['CL_array'] / r['CD_array']).argmax()
print(f"  Best L/D = {r['LD_max']:.1f} at alpha = {r['alpha_array'][best]:.1f} deg")
print(f"  CL_max   = {r['CL_max']:.3f}")

# ── Reynolds sweep with an Anvil System ──────────────────────────────────────
print("\n=== Reynolds effect on drag (alpha = 4 deg) ===")
sys_ = anvil.system("xfoil_re_study")
sys_.add("airfoil",   "NACA2412")
sys_.add("alpha_deg", 4.0)
sys_.add("Mach",      0.1)
sys_.add("Re",        1e6)   # placeholder; swept below
sys_.use(xfoil_polar)

sweep = sys_.sweep("Re", [2e5, 5e5, 1e6, 2e6, 5e6])
print(f"  {'Re':>10}  {'CL':>7}  {'CD':>8}")
for i in range(len(sweep.table)):
    row = sweep.table.iloc[i]
    print(f"  {row['Re']:10.1e}  {row['CL']:7.4f}  {row['CD']:8.5f}")
print("  (CD drops with Re: thinner boundary layer, later transition)")

# ── Register in project ───────────────────────────────────────────────────────
print("\n=== Register in global registry ===")
register()
print("  xfoil_polar, xfoil_alpha_sweep -> domain aero.xfoil")

Requires an external tool that is not installed here. Run anvil doctor for the exact install command, then run the script to see its output.

Anvil v0.3 Showcase¶

examples/showcase.py: the full RSQ workflow

import os

# Windows consoles default to cp1252; this output uses Greek symbols.
if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

import numpy as np


import anvil
from anvil import Q, System

print("=" * 60)
print("  Anvil v0.3 Showcase")
print("=" * 60)

# ─────────────────────────────────────────────────
# 1. Browse what's available
# ─────────────────────────────────────────────────

print("\n--- What's in the registry? ---")
anvil.registry.list()

# ─────────────────────────────────────────────────
# 2. Search for something specific
# ─────────────────────────────────────────────────

print("\n--- Search: 'shock' ---")
anvil.registry.search("shock")

print("\n--- Search: 'thrust' ---")
anvil.registry.search("thrust")

# ─────────────────────────────────────────────────
# 3. Use a Relation directly from the registry
# ─────────────────────────────────────────────────

print("\n--- Call isentropic_ratios directly ---")
result = anvil.R.isentropic_ratios(M=2.0, gamma=1.4)
print(f"  M = 2.0, gamma = 1.4")
print(f"  T0/T   = {result['T0_T']:.4f}")
print(f"  P0/P   = {result['P0_P']:.4f}")
print(f"  rho0/rho = {result['rho0_rho']:.4f}")

print("\n--- Normal shock at M=3 ---")
shock = anvil.R.normal_shock(M1=3.0, gamma=1.4)
print(f"  M1 = 3.0")
print(f"  M2     = {shock['M2']:.4f}")
print(f"  P2/P1  = {shock['P2_P1']:.4f}")
print(f"  T2/T1  = {shock['T2_T1']:.4f}")
print(f"  P02/P01 = {shock['P02_P01']:.4f}")

# ─────────────────────────────────────────────────
# 4. Load a pre-built System, no .add() needed
# ─────────────────────────────────────────────────

print("\n--- Load the rocket nozzle system ---")
nozzle = anvil.S.rocket_nozzle
print(nozzle.info())

# Solve with defaults
print("\n--- Solve with defaults ---")
nozzle.solve().summary()

# ─────────────────────────────────────────────────
# 5. Override with .set(), clean, no re-declaration
# ─────────────────────────────────────────────────

print("\n--- Override chamber pressure and solve again ---")
nozzle.set(P0=10e6, T0=3200)
nozzle.solve().summary()

# Override with different unit system
print("\n--- Override with imperial units ---")
nozzle.set(P0=Q(1500, "psi"))
nozzle.solve().summary()

# ─────────────────────────────────────────────────
# 6. Parametric sweep
# ─────────────────────────────────────────────────

print("\n--- Sweep: thrust vs chamber pressure ---")
nozzle.set(P0=6.9e6)  # reset to baseline
sweep = nozzle.sweep("P0", np.linspace(1e6, 20e6, 10))
sweep.summary(outputs=["M_exit", "V_exit", "mdot", "thrust", "Isp"])

# ─────────────────────────────────────────────────
# 7. Compose: nozzle inside a bigger system
# ─────────────────────────────────────────────────

print("\n--- Composition: nozzle + custom delta-V calc ---")


# Define a custom Relation
def delta_v(Isp, mass_ratio):
    """Tsiolkovsky rocket equation: dV = Isp * g0 * ln(mass_ratio)"""
    import numpy as np

    dv = Isp * 9.80665 * np.log(mass_ratio)
    return {"delta_v": Q(dv, "m/s")}


# Build a stage system, use() with a System inherits its defaults
# Get a fresh nozzle from the registry
from anvil.registry.loader import load_rsq

fresh_nozzle = load_rsq(
    anvil.registry._get_store().get("rocket_nozzle"), anvil.registry._get_store()
)

stage = System("rocket_stage")
stage.use(fresh_nozzle)  # inherits all 7 nozzle defaults
stage.add("mass_ratio", 4.0, desc="Initial/final mass ratio")
stage.use(delta_v)

# Solve
stage.solve().summary()

# ─────────────────────────────────────────────────
# 8. Register your own RSQ
# ─────────────────────────────────────────────────

print("\n--- Register a custom Relation ---")


def oblique_shock_angle(M, theta_deg, gamma=1.4):
    """Find weak shock angle beta for given deflection theta."""
    import numpy as np

    from anvil import solvers

    theta = np.radians(theta_deg)
    mu = np.arcsin(1.0 / M)  # Mach angle

    def residual(beta_deg):
        b = np.radians(beta_deg)
        num = M**2 * np.sin(b) ** 2 - 1
        den = M**2 * (gamma + np.cos(2 * b)) + 2
        return np.tan(theta) - 2 * (1 / np.tan(b)) * num / den

    # Initial guess: midpoint between Mach angle and 60 degrees
    x0 = np.degrees(mu) + (60 - np.degrees(mu)) * 0.4
    beta = solvers.find_root(residual, x0=x0, method="newton")
    return {"beta_deg": beta, "beta_rad": np.radians(beta)}


anvil.push(
    oblique_shock_angle,
    domain="aero.compressible",
    tags=["shock", "oblique", "compressible"],
    description="Oblique shock wave angle from deflection angle and Mach",
)

# Now use it from the registry
print("\n--- Use the newly registered Relation ---")
result = anvil.R.oblique_shock_angle(M=3.0, theta_deg=20.0)
print(f"  M=3.0, theta=20 deg")
print(f"  beta = {result['beta_deg']:.2f} deg")

# ─────────────────────────────────────────────────
# 9. Unit conversions on results
# ─────────────────────────────────────────────────

print("\n--- Unit conversions ---")
nozzle.set(P0=6.9e6)
r = nozzle.solve()

F = r["thrust"]
print(f"  Thrust:  {F}  →  {F.to('kN')}  →  {F.to('lbf')}")

T = r["T_exit"]
print(f"  T_exit:  {T}  →  {T.to('R')}")

V = r["V_exit"]
print(f"  V_exit:  {V}  →  {V.to('ft/s')}")

print("\n" + "=" * 60)
print("  Done.")
print("=" * 60)

Output:

============================================================
  Anvil v0.3 Showcase
============================================================

--- What's in the registry? ---

  Relations (171):
    hx_duty                       
    hx_eff_ntu                    
    drag_force                      [aero]  (builtin)
      Drag force: D = 0.5 * rho * V^2 * S * CD
    drag_polar                      [aero]  (builtin)
      Parabolic drag polar: CD = CD0 + CL^2/(pi*e*AR)
    dynamic_pressure                [aero]  (builtin)
      Dynamic pressure: q = 0.5 * rho * V^2
    induced_drag                    [aero]  (builtin)
      Induced drag: CDi = CL^2 / (pi * e * AR)
    lift_force                      [aero]  (builtin)
      Lift force: L = 0.5 * rho * V^2 * S * CL
    oswald_efficiency               [aero]  (builtin)
      Oswald span efficiency estimate from aspect ratio (empirical)
    reynolds_num                    [aero]
      Reynolds number: Re = rho V L / mu
    skin_friction_flat_plate        [aero]  (builtin)
      Average skin-friction coefficient on a flat plate (laminar or turbulen
    stall_speed                     [aero]  (builtin)
      Aircraft stall speed: Vs = sqrt(2*W/(rho*S*CLmax))
    thin_airfoil_cl                 [aero]  (builtin)
      Thin airfoil theory: CL = 2*pi*(alpha + alpha_L0); M correction via Pr
    isa_atmosphere                  [aero.atmosphere]  (builtin)
      International Standard Atmosphere (ISA) up to 86 km
    area_mach_subsonic              [aero.compressible]  (builtin)
      Subsonic Mach from area ratio (A/A*)
    area_mach_supersonic            [aero.compressible]  (builtin)
... (541 more lines)

Pressure-tank blowdown through a hole -- "half-life" drain time for a gas.¶

examples/tank_blowdown.py: Problem

from __future__ import annotations

import argparse
import math

import numpy as np

import anvil
from anvil import Q
from anvil.db import fluids


# --------------------------------------------------------------------------- #
#  Gas properties  (real tabulated data from anvil.db.fluids -- no mocks)
# --------------------------------------------------------------------------- #
# Map friendly CLI names / chemical symbols onto anvil.db.fluids keys.
_SPECIES_ALIASES = {
    "air": "air",
    "n2": "nitrogen", "nitrogen": "nitrogen",
    "o2": "oxygen", "oxygen": "oxygen",
    "co2": "co2", "carbon_dioxide": "co2",
    "h2": "hydrogen", "hydrogen": "hydrogen",
    "he": "helium", "helium": "helium",
    "ch4": "methane", "methane": "methane",
    "ar": "argon", "argon": "argon",
    "c3h8": "propane", "propane": "propane",
}


def gas_properties(species: str, T: float, P: float):
    """Return (gamma, R_gas [J/kg/K]) for `species` from the real fluid DB."""
    key = _SPECIES_ALIASES.get(species.lower().strip(), species.lower().strip())
    props = fluids.get(key, T=T, P=P)          # raises KeyError w/ suggestions
    if "gamma" not in props or "R_gas" not in props:
        raise ValueError(
            f"Fluid '{species}' has no ideal-gas (gamma, R_gas) data in the DB. "
            f"Choose a gas species, e.g. air, N2, O2, CO2, H2, He, CH4, Ar."
        )
    return float(props["gamma"]), float(props["R_gas"].si)


# --------------------------------------------------------------------------- #
#  Core compressible-orifice relation (registered into the Anvil registry)
# --------------------------------------------------------------------------- #
def _mass_flux(P_up, T_up, gamma, R_gas, P_down):
    """Mass flux [kg/s per m^2 of geometric hole area, Cd = 1] and choked flag.

    Pure-float core so it is fast inside the ODE right-hand side. Handles both
    the choked (sonic) and subsonic branches of the de Saint-Venant - Wantzel
    orifice equation.
    """
    r_crit = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
    r = P_down / P_up                                  # downstream / upstream
    if r <= r_crit:                                    # --- choked (sonic) ---
        flux = P_up * math.sqrt(gamma / (R_gas * T_up)) \
            * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
        choked = True
    else:                                              # --- subsonic ---
        term = r ** (2.0 / gamma) - r ** ((gamma + 1.0) / gamma)
        flux = P_up * math.sqrt(
            max(0.0, 2.0 * gamma / ((gamma - 1.0) * R_gas * T_up) * term)
        )
        choked = False
    return flux, choked, r_crit


@anvil.relation(domain="fluids.compressible", tags=["orifice", "blowdown"],
                register=False)
def orifice_mass_flow(P_up, T_up, gamma, R_gas, A_eff, P_down=101325.0, Cd=0.62):
    """Ideal-gas mass flow through N holes of total geometric area `A_eff`.

    Returns the instantaneous mdot plus the choked flag and the critical
    pressure ratio -- usable directly (anvil.R.orifice_mass_flow(...)), inside a
    System, or in a parametric sweep.
    """
    flux, choked, r_crit = _mass_flux(P_up, T_up, gamma, R_gas, P_down)
    return {
        "mdot":   Q(Cd * A_eff * flux, "kg/s"),
        "choked": 1.0 if choked else 0.0,
        "r_crit": r_crit,
    }


# --------------------------------------------------------------------------- #
#  Blowdown solver
# --------------------------------------------------------------------------- #
class BlowdownResult:
    """Container for a solved blowdown (time histories + key scalars)."""

    def __init__(self, **kw):
        self.__dict__.update(kw)

    def summary(self):
        P_bar = lambda p: p / 1e5
        print("=" * 66)
        print(f"  Tank blowdown -- {self.species}  ({self.model})")
        print("=" * 66)
        print(f"  Tank volume            V      = {self.V:.4g} m^3")
        print(f"  Hole diameter          d      = {self.d*1e3:.4g} mm")
        print(f"  Number of holes        N      = {self.N}")
        print(f"  Total hole area        A_eff  = {self.A_eff*1e6:.4g} mm^2")
        print(f"  Discharge coefficient  Cd     = {self.Cd}")
        print(f"  Initial pressure       P0     = {P_bar(self.P0):.4g} bar "
              f"({self.P0:.4g} Pa)")
        print(f"  Temperature            T      = {self.T0:.4g} K")
        print(f"  Ambient pressure       P_amb  = {P_bar(self.P_amb):.4g} bar")
        print(f"  gamma / R_gas                 = {self.gamma:.4g} / "
              f"{self.R_gas:.4g} J/kg/K")
        print(f"  Initial gas mass       m0     = {self.m0*1e3:.4g} g")
        print("-" * 66)
        print(f"  Critical pressure ratio r_crit = {self.r_crit:.4f}")
        print(f"  Choking threshold      P*      = {P_bar(self.P_unchoke):.4g} bar")
        started = "choked" if self.P0 > self.P_unchoke else "SUBSONIC (never choked)"
        print(f"  Flow starts             : {started}")
        print(f"  Characteristic time    tau     = {self.tau:.4g} s")
        if self.model == "isothermal" and self.P0 > self.P_unchoke:
            print(f"  >>> HALF-LIFE  t_half (choked) = {self.t_half:.4g} s "
                  f"(CONSTANT while choked)")
            print(f"      Clean half-lives before unchoke = {self.n_half_choked:.2f}")
            print(f"      Time to unchoke  (P -> P*)      = {self.t_unchoke:.4g} s")
        elif self.model == "adiabatic" and self.P0 > self.P_unchoke:
            print(f"  >>> FIRST HALF-LIFE (P0->P0/2) = {self.t_half:.4g} s "
                  f"(analytic, choked)")
            print(f"      NOTE: adiabatic decay is power-law, so each successive")
            print(f"            halving takes LONGER (half-life is not constant).")
            print(f"      Time to unchoke  (P -> P*)      = {self.t_unchoke:.4g} s")
        else:
            print(f"  >>> First pressure HALF-LIFE   = {self.t_half:.4g} s "
                  f"(numeric, {self.model})")
            if self.t_unchoke is not None:
                print(f"      Time to unchoke  (P -> P*)      = "
                      f"{self.t_unchoke:.4g} s")
        print(f"  Time to 90% drained (P->P_amb) = {self.t_90:.4g} s")
        print(f"  Time to 99% drained            = {self.t_99:.4g} s")
        print("=" * 66)


def solve_blowdown(V, d, N, P0, T, species="air", Cd=0.62, P_amb=101325.0,
                   model="adiabatic", n_points=600, verbose=False):
    """Integrate the tank blowdown and return a BlowdownResult.

    Parameters
    ----------
    V : float     tank volume [m^3]
    d : float     hole diameter [m]
    N : int       number of holes
    P0 : float    initial (full) tank pressure [Pa, absolute]
    T : float     tank temperature [K]
    species : str gas name (air, N2, O2, CO2, H2, He, CH4, Ar, ...)
    Cd : float    orifice discharge coefficient (0.62 sharp hole, ~0.9 rounded)
    P_amb : float ambient/back pressure [Pa]
    model : str   "isothermal" (constant T, analytic half-life) or "adiabatic"
    """
    gamma, R_gas = gas_properties(species, T, P0)
    A_eff = N * math.pi * 0.25 * d ** 2                 # total geometric hole area
    rho0 = P0 / (R_gas * T)
    m0 = rho0 * V
    r_crit = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
    P_unchoke = P_amb / r_crit                          # tank P at which flow unchokes

    # ---- instantaneous tank state as a function of remaining mass m --------
    def state(m):
        rho = m / V
        if model == "adiabatic":
            P = P0 * (rho / rho0) ** gamma
            T_now = T * (rho / rho0) ** (gamma - 1.0)
        else:                                           # isothermal
            T_now = T
            P = rho * R_gas * T_now
        return P, T_now

    # ---- ODE right-hand side:  dm/dt = -mdot -------------------------------
    def rhs(t, y):
        m = max(y[0], 0.0)
        P, T_now = state(m)
        if P <= P_amb:
            return [0.0]
        flux, _, _ = _mass_flux(P, T_now, gamma, R_gas, P_amb)
        return [-Cd * A_eff * flux]

    # ---- analytic choked time scale / half-life ----------------------------
    #   Choked mdot = k*P,  k = Cd*A*sqrt(gamma/(R*T))*(2/(g+1))**((g+1)/(2(g-1)))
    #   The characteristic time  tau = V/(k*R*T)  governs BOTH closures:
    #     isothermal:  P/P0 = exp(-t/tau)                         (const half-life)
    #     adiabatic :  P/P0 = [1 + ((g-1)/2)(t/tau)]**(-2g/(g-1)) (power-law decay)
    k = Cd * A_eff * math.sqrt(gamma / (R_gas * T)) \
        * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
    tau = V / (k * R_gas * T)

    def _t_choked(P_target):
        """Analytic time (from full tank) to reach P_target *while choked*."""
        if model == "adiabatic":
            return tau * (2.0 / (gamma - 1.0)) \
                * ((P_target / P0) ** (-(gamma - 1.0) / (2.0 * gamma)) - 1.0)
        return tau * math.log(P0 / P_target)            # isothermal (exponential)

    if P0 > P_unchoke:
        t_unchoke_an = _t_choked(P_unchoke)
        n_half_choked = math.log2(P0 / P_unchoke)       # only "constant" if isothermal
    else:
        t_unchoke_an = None
        n_half_choked = 0.0
    # First pressure half-life: analytic if 0.5*P0 is still choked, else numeric later
    half_still_choked = (0.5 * P0) >= P_unchoke
    t_half_an = _t_choked(0.5 * P0) if half_still_choked else None

    # ---- integration horizon & terminal event -----------------------------
    # Drain is asymptotic to P_amb; stop at 99.9% drained (or a time cap).
    P_stop = P_amb + 0.001 * (P0 - P_amb)
    m_stop, _ = _mass_for_pressure(P_stop, model, V, rho0, P0, R_gas, T, gamma)
    t_cap = 40.0 * tau + 1.0

    def ev_drained(t, y):
        return y[0] - m_stop
    ev_drained.terminal = True
    ev_drained.direction = -1

    sol = anvil.solvers.solve_ode(
        rhs, (0.0, t_cap), [m0],
        method="RK45", rtol=1e-9, atol=1e-12,
        max_step=t_cap / 200.0, events=ev_drained, verbose=verbose,
    )
    t_end = float(sol["t"][-1])
    dense = sol["sol"].sol                               # scipy dense output y(t)

    # ---- sample fine time histories ----------------------------------------
    t = np.linspace(0.0, t_end, n_points)
    m_t = np.clip(dense(t)[0], 0.0, None)
    P_t = np.empty_like(t)
    T_t = np.empty_like(t)
    mdot_t = np.empty_like(t)
    choked_t = np.zeros_like(t)
    for i, m in enumerate(m_t):
        P, Tn = state(m)
        P_t[i], T_t[i] = P, Tn
        flux, ch, _ = _mass_flux(max(P, P_amb), Tn, gamma, R_gas, P_amb)
        mdot_t[i] = Cd * A_eff * flux if P > P_amb else 0.0
        choked_t[i] = 1.0 if (ch and P > P_amb) else 0.0

    # ---- numeric scalars: half-life, unchoke, 90/99% drained ---------------
    def _time_at_pressure(P_target):
        if P_target >= P0:
            return 0.0
        if P_target <= P_t[-1]:
            return t_end
        return float(np.interp(-P_target, -P_t, t))     # P_t is monotone decreasing

    t_half = t_half_an if t_half_an is not None else _time_at_pressure(0.5 * P0)
    t_unchoke = t_unchoke_an if t_unchoke_an is not None else (
        _time_at_pressure(P_unchoke) if P0 > P_unchoke else None)
    t_90 = _time_at_pressure(P_amb + 0.10 * (P0 - P_amb))
    t_99 = _time_at_pressure(P_amb + 0.01 * (P0 - P_amb))

    return BlowdownResult(
        species=species, model=model,
        V=V, d=d, N=N, A_eff=A_eff, Cd=Cd, P0=P0, T0=T, P_amb=P_amb,
        gamma=gamma, R_gas=R_gas, rho0=rho0, m0=m0,
        r_crit=r_crit, P_unchoke=P_unchoke,
        tau=tau, t_half=t_half,
        n_half_choked=n_half_choked,
        t_unchoke=t_unchoke,
        t_90=t_90, t_99=t_99,
        t=t, P=P_t, T=T_t, mdot=mdot_t, m=m_t, choked=choked_t,
        _sol=sol,
    )


def _mass_for_pressure(P_target, model, V, rho0, P0, R_gas, T, gamma):
    """Invert the state relation P(m) -> m (remaining gas mass at pressure P)."""
    if model == "adiabatic":
        rho = rho0 * (P_target / P0) ** (1.0 / gamma)
    else:
        rho = P_target / (R_gas * T)
    return rho * V, rho


# --------------------------------------------------------------------------- #
#  Plotting
# --------------------------------------------------------------------------- #
def plot_blowdown(res, save=None, show=True):
    import matplotlib.pyplot as plt

    adiabatic = res.model == "adiabatic"
    nrows = 3 if adiabatic else 2
    fig, axes = plt.subplots(nrows, 1, figsize=(9, 4 * nrows), sharex=True)
    axP, axM = axes[0], axes[1]

    # shade the choked window
    choked = res.choked > 0.5
    if choked.any():
        t_unchoke = res.t[choked][-1]
        for ax in axes:
            ax.axvspan(res.t[0], t_unchoke, color="tab:orange", alpha=0.10,
                       label="choked (sonic)")

    # --- pressure ---
    axP.plot(res.t, res.P / 1e5, color="tab:blue", lw=2)
    axP.axhline(res.P_unchoke / 1e5, ls="--", color="tab:orange", lw=1.2,
                label=f"unchoke P* = {res.P_unchoke/1e5:.2f} bar")
    axP.axhline(res.P_amb / 1e5, ls=":", color="grey", lw=1.2, label="P_amb")
    # half-life markers
    if res.t_half is not None and res.t_half < res.t[-1]:
        axP.axvline(res.t_half, ls="-.", color="tab:red", lw=1.2,
                    label=f"t_half = {res.t_half:.3g} s")
        axP.plot([res.t_half], [0.5 * res.P0 / 1e5], "o", color="tab:red")
    axP.set_ylabel("Tank pressure  [bar]")
    axP.set_title(f"Pressure-tank blowdown -- {res.species} ({res.model})")
    axP.grid(alpha=0.3)
    axP.legend(fontsize=8, loc="upper right")

    # --- mass flow ---
    axM.plot(res.t, res.mdot * 1e3, color="tab:green", lw=2)
    axM.set_ylabel("Mass-flow rate  [g/s]")
    axM.grid(alpha=0.3)
    if choked.any():
        axM.axvline(t_unchoke, ls="--", color="tab:orange", lw=1.0)
        axM.annotate("choked -> subsonic", xy=(t_unchoke, res.mdot.max() * 1e3 * 0.6),
                     xytext=(8, 0), textcoords="offset points", fontsize=8,
                     color="tab:orange", rotation=90, va="center")

    # --- temperature (adiabatic only: gas cools as it expands) ---
    if adiabatic:
        axT = axes[2]
        axT.plot(res.t, res.T, color="tab:purple", lw=2)
        axT.axhline(res.T0, ls=":", color="grey", lw=1.0,
                    label=f"T0 = {res.T0:.0f} K")
        axT.set_ylabel("Tank gas temperature  [K]")
        axT.grid(alpha=0.3)
        axT.legend(fontsize=8, loc="lower right")
        axT.set_xlabel("time  [s]")
    else:
        axM.set_xlabel("time  [s]")

    fig.tight_layout()
    if save:
        fig.savefig(save, dpi=150, bbox_inches="tight")
        print(f"  saved plot -> {save}")
    if show:
        plt.show()
    return fig


# --------------------------------------------------------------------------- #
#  Bonus: half-life as a function of design, via an Anvil System + sweep
# --------------------------------------------------------------------------- #
def halflife_vs_diameter(V, N, P0, T, species="air", Cd=0.62, P_amb=101325.0,
                         d_range=(0.5e-3, 5e-3), n=25, save=None, show=True):
    """Sweep hole diameter and plot the (choked, isothermal) half-life.

    Demonstrates wrapping the closed-form result in an Anvil System so it can be
    swept / optimized like any other Anvil model.
    """
    gamma, R_gas = gas_properties(species, T, P0)

    def choked_halflife(V, d, N, gamma, R_gas, T, Cd):
        A = N * math.pi * 0.25 * d ** 2
        k = Cd * A * math.sqrt(gamma / (R_gas * T)) \
            * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
        tau = V / (k * R_gas * T)
        return {"tau": Q(tau, "s"), "t_half": Q(tau * math.log(2.0), "s")}

    sys = anvil.system("tank_halflife")
    sys.add("V", V, "m^3")
    sys.add("d", d_range[0], "m")
    sys.add("N", N)
    sys.add("gamma", gamma)
    sys.add("R_gas", R_gas, "J/kg/K")
    sys.add("T", T, "K")
    sys.add("Cd", Cd)
    sys.use(choked_halflife)

    sweep = sys.sweep("d", np.linspace(d_range[0], d_range[1], n))
    fig = anvil.viz.sweep_plot(sweep, y=["t_half"], x_label="hole diameter d [m]",
                               show=False)
    if save:
        fig.savefig(save, dpi=150, bbox_inches="tight")
        print(f"  saved plot -> {save}")
    if show:
        import matplotlib.pyplot as plt
        plt.show()
    return sweep


# --------------------------------------------------------------------------- #
#  CLI
# --------------------------------------------------------------------------- #
def _build_parser():
    p = argparse.ArgumentParser(
        description="Pressure-tank gas blowdown / half-life (Anvil).")
    p.add_argument("--V", type=float, default=0.05, help="tank volume [m^3]")
    p.add_argument("--d", type=float, default=2e-3, help="hole diameter [m]")
    p.add_argument("--N", type=int, default=1, help="number of holes")
    p.add_argument("--P0", type=float, default=10e6,
                   help="initial tank pressure [Pa, absolute]")
    p.add_argument("--T", type=float, default=300.0, help="temperature [K]")
    p.add_argument("--species", type=str, default="air",
                   help="gas: air, N2, O2, CO2, H2, He, CH4, Ar, propane")
    p.add_argument("--Cd", type=float, default=0.62,
                   help="discharge coeff (0.62 sharp hole, ~0.9 rounded)")
    p.add_argument("--P_amb", type=float, default=101325.0,
                   help="ambient pressure [Pa]")
    p.add_argument("--model", choices=["isothermal", "adiabatic"],
                   default="adiabatic")
    p.add_argument("--save", type=str, default=None,
                   help="path to save the time-history PNG")
    p.add_argument("--sweep", action="store_true",
                   help="also plot half-life vs hole diameter")
    p.add_argument("--no-show", action="store_true", help="do not display plots")
    return p


def main(argv=None):
    args = _build_parser().parse_args(argv)
    res = solve_blowdown(
        V=args.V, d=args.d, N=args.N, P0=args.P0, T=args.T,
        species=args.species, Cd=args.Cd, P_amb=args.P_amb, model=args.model,
    )
    res.summary()
    try:
        plot_blowdown(res, save=args.save, show=not args.no_show)
        if args.sweep:
            halflife_vs_diameter(args.V, args.N, args.P0, args.T,
                                 species=args.species, Cd=args.Cd,
                                 P_amb=args.P_amb, show=not args.no_show)
    except ImportError:
        print("  (matplotlib not installed -- skipping plots; "
              "pip install matplotlib)")
    return res


if __name__ == "__main__":
    main()

Runs a full solve that takes a while; run the script locally to see its output.

Gas-Turbine Cycle Analysis¶

Anvil ships a GasTurb-style engine cycle pack: air-breathing engines built station by station from small native Relations, wired into a solvable System you can sweep and optimize like any other Anvil problem.

from anvil import propulsion as jet

tj = jet.build_turbojet()
tj.set(M0=0.85, pi_c=12, T04=1500, mdot=25)
res = tj.solve()
res.summary()

print(jet.station_table(res))     # per-station stagnation T0, P0
fig = jet.cycle_diagram(res)      # T-s diagram (needs matplotlib)

Station numbering¶

Each component maps the stagnation state at its inlet station to the state at its exit station, so the System chains them automatically by variable name. Station numbers follow the usual aero convention:

Station Location
0 freestream / ambient
2 compressor (or fan) face, after the intake
13 fan / bypass duct exit (turbofan)
3 compressor exit = combustor inlet
4 combustor exit = turbine inlet (turbine inlet temperature, TIT)
45 HP-turbine exit = LP-turbine inlet (two-spool)
5 turbine exit = nozzle (or afterburner) inlet
7 afterburner exit
9 core nozzle exit
19 bypass nozzle exit (turbofan)

Engines¶

Four ready-to-solve builders, each returning a System:

jet.build_turbojet()      # single spool: intake, compressor, combustor, turbine, nozzle
jet.build_turbofan()      # two-spool, separate exhaust, fan + bypass duct
jet.build_turbojet_ab()   # turbojet with an afterburner (reheat)
jet.build_turboprop()     # free power turbine driving a propeller / shaft

Override the design point with .set(...). Common design inputs: M0 (flight Mach), pi_c (compressor pressure ratio), pi_f (fan pressure ratio), bypass (bypass ratio), T04 (turbine inlet temperature), T07 (afterburner temperature), mdot (core mass flow).

Outputs¶

A solved cycle reports specific thrust, absolute thrust, TSFC, fuel-air ratio, exit Mach, and the thermal, propulsive and overall efficiencies. A turboprop reports shaft power, specific power and PSFC instead.

res.summary(keys=["specific_thrust", "TSFC", "thermal_eff",
                  "propulsive_eff", "overall_eff"])

station_table(res) prints the stagnation temperature and pressure at every station present in the result. cycle_diagram(res, kind="Ts") draws the temperature-entropy cycle; kind="hs" draws the enthalpy-entropy (Mollier) form. Both mark each station and return a Matplotlib figure.

Sweeps and optimization¶

Because an engine is a normal System, the usual analysis tools apply:

# how compressor pressure ratio trades specific thrust against TSFC
sweep = tj.sweep("pi_c", [6, 10, 15, 20, 25, 30, 40])
sweep.summary(outputs=["specific_thrust", "TSFC", "thermal_eff"])

# pressure ratio that maximizes specific thrust
best = tj.optimize("specific_thrust", {"pi_c": (4, 45)}, minimize=False)
print(best.x["pi_c"], best.fun)

Higher pressure ratio lowers TSFC (better fuel economy) while specific thrust peaks at a moderate pressure ratio, the classic turbojet trade.

Gas model¶

The default is a constant-cp, cold/hot split: cold cp and gamma for the intake, fan and compressor, hot cp and gamma for the combustor, turbine, afterburner and nozzle. This is the textbook / GasTurb-"simple" model and needs no external tools. A variable-cp path is available through propulsion.gas_properties, which accepts a thermo adapter (for example one backed by CoolProp or Cantera) and returns temperature-dependent cp, gamma and R that you can pass to the components.

Component RSQs¶

Every stage is a registered RSQ you can search, inspect and reuse on its own or inside your own System: ram_intake, compressor, combustor, turbine, nozzle, thrust_performance (turbojet); fan, hp_compressor, hp_turbine, lp_turbine, bypass_nozzle, turbofan_thrust (turbofan); afterburner; and power_turbine, turboshaft_performance (turboprop). The full engines are the turbojet_cycle, turbofan_cycle, turbojet_ab_cycle and turboprop_cycle systems.

See the Examples page for a complete, runnable walkthrough.

Physics¶

A pack of fundamental-physics relations spanning mechanics, electromagnetism, optics, waves, relativity and quantum. Each is a native RSQ with unit-aware inputs and outputs, reachable at anvil.R.<name> or by domain at anvil.R.physics.<subdomain>.<name>.

These relations were authored with an LLM using the workflow in docs/RSQ_AUTHORING_PROMPT.md and validated against textbook values before being seeded, a worked demonstration of that pipeline.

import anvil

anvil.R.kinetic_energy(m=2, v=3)                       # {"KE": 9.0 J}
anvil.R.mass_energy_equivalence(m=1)                   # {"E_rest": 8.99e16 J}
anvil.R.snell_refraction_angle(n1=1, n2=1.5, theta1_deg=30)  # 19.47 deg

Mechanics (physics.mechanics)¶

Name Inputs Outputs Relation
kinetic_energy m, v KE KE = 1/2 m v^2
newton_gravitation m1, m2, r F_grav F = G m1 m2 / r^2
projectile_range v0, angle_deg, g=9.81 range R = v0^2 sin(2 theta) / g
pendulum_period L, g=9.81 period T = 2 pi sqrt(L/g)

Electromagnetism (physics.em)¶

Name Inputs Outputs Relation
coulomb_force q1, q2, r F_coulomb F = q1 q2 / (4 pi eps0 r^2)
parallel_plate_capacitor_energy A, d, V U_stored U = 1/2 (eps0 A / d) V^2
lorentz_force_magnitude q, v, B, angle_deg=90 F_lorentz F = q v B sin(theta)

Optics (physics.optics)¶

Name Inputs Outputs Relation
snell_refraction_angle n1, n2, theta1_deg theta2_deg, theta2_rad, total_internal_reflection n1 sin(theta1) = n2 sin(theta2)
thin_lens_image_distance f, d_o d_i 1/f = 1/d_o + 1/d_i
photon_energy_frequency f E_photon E = h f

snell_refraction_angle flags total internal reflection (returns total_internal_reflection = 1.0 and NaN angles) past the critical angle. thin_lens_image_distance returns an infinite image distance when the object sits at the focal point (d_o = f) instead of raising.

Waves (physics.waves)¶

Name Inputs Outputs Relation
wave_speed frequency, wavelength speed v = f lambda
relativistic_doppler_shift f_src, v_radial f_obs, shift_factor f_obs = f_src sqrt((1+beta)/(1-beta))

Relativity (physics.relativity)¶

Name Inputs Outputs Relation
lorentz_factor v gamma gamma = 1 / sqrt(1 - (v/c)^2)
mass_energy_equivalence m E_rest E = m c^2

Quantum (physics.quantum)¶

Name Inputs Outputs Relation
de_broglie_wavelength p wavelength lambda = h / p
wien_peak_wavelength T lambda_peak lambda_peak = b / T

Notes¶

  • Physical constants (c, G, h, eps0, Wien b, ...) are baked into each relation as SI literals, so no constant inputs are required.
  • Angle inputs are named *_deg and taken in degrees; outputs carry SI units (J, N, m, s, m/s, Hz) except for dimensionless results (gamma, refraction angles, shift_factor).
  • Run examples/ex_physics.py for a full tour with computed values.

Chemistry¶

A pack of general-chemistry relations spanning stoichiometry, gas laws, solutions and colligative properties, thermodynamics, kinetics, equilibrium, electrochemistry and acid-base. Each is a native RSQ reachable at anvil.R.<name> or by domain at anvil.R.chemistry.<subdomain>.<name>.

Authored with an LLM using the workflow in docs/RSQ_AUTHORING_PROMPT.md and validated against textbook values before seeding.

import anvil

anvil.R.ph_from_concentration(H_conc=1e-7)              # {"pH": 7.0}
anvil.R.moles_ideal_gas(P=101325, V=0.0224, T=273.15)  # ~1 mol at STP
anvil.R.gibbs_free_energy(dH=-1e5, T=298.15, dS=-100)   # {"dG": -70185 J/mol}

Unit convention¶

Outputs use SI. In particular dimensioned concentration is mol/m^3, so a 0.25 M solution is 250 mol/m^3. molarity additionally returns a c_molar convenience value in mol/L. Where a concentration only enters a ratio or a logarithm (pH, Henderson-Hasselbalch, dilution) it is a unit-agnostic plain-float input, and the assumed unit is noted in the relation description.

Stoichiometry (chemistry.stoichiometry)¶

Name Inputs Outputs Relation
moles_from_mass m, M n n = m / M
percent_yield actual, theoretical percent_yield 100 actual / theoretical
molarity n, V c, c_molar c = n / V

Gas laws (chemistry.gas)¶

Name Inputs Outputs Relation
moles_ideal_gas P, V, T n n = P V / (R T)
combined_gas_law P1, V1, T1, P2, T2 V2 P1 V1 / T1 = P2 V2 / T2

Solutions (chemistry.solution, chemistry.solution.colligative)¶

Name Inputs Outputs Relation
dilution M1, V1, M2 V2 M1 V1 = M2 V2
beer_lambert_absorbance eps, l, c A A = eps l c
freezing_point_depression i, Kf, m dTf dTf = i Kf m
osmotic_pressure i, M, T Pi Pi = i M R T
raoult_vapor_pressure x_solvent, P_pure P P = x P_pure

Thermodynamics and equilibrium (chemistry.thermo, chemistry.equilibrium)¶

Name Inputs Outputs Relation
gibbs_free_energy dH, T, dS dG dG = dH - T dS
gibbs_from_equilibrium_constant K, T dG dG = -R T ln K
equilibrium_constant_from_gibbs dG, T K K = exp(-dG / R T)

Kinetics (chemistry.kinetics)¶

Name Inputs Outputs Relation
arrhenius_rate_constant A, Ea, T k k = A exp(-Ea / R T)
first_order_half_life k t_half t = ln 2 / k

Electrochemistry and acid-base (chemistry.electro, chemistry.acidbase)¶

Name Inputs Outputs Relation
nernst_cell_potential E0, n, T, Q_rxn E E = E0 - (R T / n F) ln Q
ph_from_concentration H_conc pH pH = -log10[H+]
henderson_hasselbalch pKa, conc_base, conc_acid pH pH = pKa + log10([A-]/[HA])

Notes¶

  • Constants (R, Faraday F) are baked in as SI literals, so no constant inputs are needed.
  • Log and reciprocal relations guard their domain: gibbs_from_equilibrium_constant returns NaN for K <= 0, nernst_cell_potential for Q <= 0, and the rate/half-life relations return infinity at the zero-rate limit instead of raising.
  • Run examples/ex_chemistry.py for a full tour with computed values.

Search results