# noodlelab: everything in one file

Generated by scripts/gen_llms.py from the code. Sections: the guide for agents, noodlelab.verify, the MCP tools, the command line, core concepts, every node, the examples.

# noodlelab for AI agents

noodlelab makes scientific and engineering calculations verifiable. Use it
whenever code computes a physical quantity, a measurement result, a design
margin or anything a person will rely on. The point is that a reviewer (or
another agent) can check what you did, not just trust it.

## The rules

1. **Units on every number.** Use quantities (`q("9.81 m/s^2")`), never bare
   floats with the unit in a variable name or a comment. Mixing dimensions then
   raises instead of silently giving nonsense. Dimensionless results say so
   (`unit="1"`).
2. **Named constants, never retyped digits.** Use `nv.const.c`, `nv.const.g0`
   or `rec.constant("k_B")` rather than `299792458` or `9.81`. A constant carries
   its unit, its source and, for measured ones such as `G` or `m_e`, its CODATA
   uncertainty. `pi`, `tau` and `e` are the `math` module's. A *local* value
   (the gravity at your site, a material's density) is a measurement:
   `measure()` it with its source.
3. **Uncertainty on every measured input, and a source.** Write
   `"9.81 ± 0.02 m/s^2"` and say where it came from (an instrument, datasheet,
   paper or dataset). Uncertainty propagates by itself (GUM, first order, with
   correlations kept). `budget()` shows which input dominates. For strongly
   non-linear models, check with `monte_carlo()`.
4. **Write the requirements down before checking them**, one per line:
   `COM-001 link_margin >= 3 dB [Analysis]  # The link shall close with 3 dB to spare`.
   Verify each one: a check reports its margin, not just pass or fail. `>` and
   `<` are strict. An uncertain result whose margin is no larger than its
   expanded uncertainty U = 2u is **inconclusive**, which is not a pass: reduce
   the uncertainty or change the design, don't drop the uncertainty.
5. **Leave a record.** Wrap the calculation in `with nv.record(...)`, or build
   it as a graph. Every run then writes a `provenance.json` holding inputs,
   results, checks, the code (hash, git commit), files (SHA-256) and the
   environment.
6. **Verify before you say you are done.** Run `noodlelab verify <script.py |
   graph.json> --json` and fix what it reports. Exit code 0 means every check
   passed and every requirement was verified; an inconclusive requirement
   exits 1. Report the margins and any
   warnings to the user; don't hide failures.

Never invent a measured value or its uncertainty. When a number is assumed,
say so with `rec.note(...)` and `source="assumed: ..."`.

## Python: `noodlelab.verify`

```python
import noodlelab.verify as nv

with nv.record("link budget") as rec:  # writes runs/<time>-link-budget-<id>/provenance.json
    p_tx = rec.input("p_tx", "10.0 ± 0.3 W", source="PA datasheet rev C")
    d = rec.input("d", nv.q("1200 km"), source="orbit design")
    ...
    margin = rec.result("link_margin", computed_margin)  # a quantity, e.g. in dB
    rec.require("COM-001 link_margin >= 3 dB [Analysis]  # The link shall close")
    rec.verify("COM-001", margin)  # Check(passed, margin=+1.2 dB, ...)
    rec.expect(0 < efficiency.m < 1, "efficiency is a fraction")
    rec.close_to("vs. textbook", result, nv.q("2.006 s"), rtol=0.01)
print(rec.summary())
```

- Constants: `nv.constant("c")`, `nv.const.g0`, `from noodlelab.constants import
  c, h, k_B`. `rec.constant("g0")` records one as an input, with its source.
  `noodlelab constants` lists them all: exact SI ones (`c`, `h`, `hbar`, `q_e`
  (the elementary charge), `k_B`, `N_A`, `R`, `F`, `sigma_SB`), measured ones
  (`G`, `m_e`, `m_p`, `m_n`, `m_u`, `alpha`, `mu_0`, `eps_0`), conventions
  (`g0`, `atm`, `T0`) and `R_E`, `GM_E`, `au`.
  `nv.define_constant("g_local", 9.8123, "m/s^2", uncertainty=5e-4, source=...)`
  adds your own, and `nv.load_constants("constants.toml")` loads a file of them.
- `nv.q(text_or_number, unit)` returns an exact quantity. `nv.measure("x ± u unit")`
  or `nv.measure(x, u, unit, name=...)` returns an uncertain one.
- `nv.requirements(text)` and `nv.verify(req, value)` work outside a record too.
- `@nv.traced` records every call of a function (arguments, result, source hash)
  inside a record.
- `nv.monte_carlo(model, trials)`, where `model` makes its inputs with
  `measure()` and returns the result, checks whether the GUM result can be
  trusted (JCGM 101).
- `nv.audit(record)` lists findings NL001–NL011: failed checks, unverified
  requirements, results without units or uncertainty, inputs without a source,
  code that was dirty or has changed since, and inconclusive requirements.
- Requirements in decibels take plain numbers (already in dB) or quantities in
  dB. A dimensionless quantity is refused, since it could be a ratio (4 is
  6.02 dB) or a level. Limits on temperature differences go in `K` or
  `delta_degC`: a `degC` limit reads a `K` value as an absolute temperature
  (a `delta_degC` value is compared as a difference).

## Graphs: node pipelines the user can open in the editor

A graph is a JSON file `<name>.graph.json` in the workspace. Nodes are typed
Python functions (`list_nodes`, `describe_node`). Every input is either a
value or a link to another node's output:

```json
{"nodes": [
  {"id": "1", "type": "core.number", "inputs": {"value": {"value": 3}}},
  {"id": "2", "type": "core.math",
   "inputs": {"operation": {"value": "power"},
              "a": {"link": {"node": "1", "output": "result"}},
              "b": {"value": 2}}}
 ],
 "report": [
  {"id": "1", "type": "report.new_report", "inputs": {"title": {"value": "Results"}}},
  {"id": "2", "type": "report.add_value",
   "inputs": {"report": {"link": {"node": "1", "output": "result"}},
              "label": {"value": "3 squared"},
              "value": {"link": {"node": "2", "output": "result", "tab": "processing"}}}},
  {"id": "3", "type": "report.render_report",
   "inputs": {"report": {"link": {"node": "2", "output": "result"}}}}
 ]}
```

- `nodes` is the processing canvas and `report` is the Reporting canvas. A
  report node reads a processing output with `"tab": "processing"` in its
  link. Data only flows from processing into the report.
- The usual shape for a verified analysis:
  - **Requirements** (`requirements.requirements`, one per line), then the
    inputs, with units and uncertainty (`units.*`, `uncertainty.*`), then the
    model.
  - **Verify Requirement** for each requirement, chaining its `log` output
    from one to the next.
  - A report: **New Report**, Add Heading / Text / Value / Figure /
    Requirements, then Add Compliance Matrix (from the last `log`), Run
    Details (provenance), and Render Report (the PDF).
- Constants in graphs: a **Constant** node (`units.constant`, `name` = `"g0"`,
  optional `unit`; unit `"1"` makes π a dimensionless quantity for Quantity
  Math). In a symbolic **Values** node, `g = g0`, or just the line `c`, takes
  the constant with its unit and uncertainty. Evaluate never fills in constants
  by itself. A graph's own constants go in `"constants"` next to `"nodes"`:
  `{"g_local": {"value": 9.8123, "unit": "m/s^2", "uncertainty": 0.0005,
  "source": "survey"}}` or `{"rho_w": "998.2 kg/m^3"}` (with `edit_graph`,
  `{"op": "constant", "name": ..., "value": ...}`). Constants for every graph
  in a workspace go in `constants.toml` at its root.
- Matrices: a matrix is a quantity holding a 2-D array, with one unit for
  every entry. **Matrix** (`maths.matrix`) reads MATLAB-style text,
  `"200, -100; -100, 200"` with `unit` `"N/m"`; `"10; 0"` is a column. Solve
  Linear System (`maths.solve_linear`) solves A x = b with units (N over N/m
  is m), and Natural Frequencies (`maths.natural_frequencies`) solves
  K φ = ω² M φ. Mixed-unit matrices are refused: use consistent SI numbers.
  Rows, columns and modes are numbered from 1 (`maths.element`).
- Systems of equations: **Equations** (`symbolic.equations`, one per line),
  then Solve System (in symbols, `unknowns` `"x1, x2"`), Solve Numerically
  (SciPy, with guesses; propagates uncertainty), or Linear System (the A
  and b of A x = b) and **Evaluate Matrix** to put numbers in. Evaluate
  itself takes one expression, not a matrix.
- Control: systems travel on SYSTEM sockets (python-control transfer
  functions and state space, in SI units). Build with Transfer Function,
  Zero-Pole-Gain, State Space, PID Controller, First/Second-Order System or
  Mass-Spring-Damper (from M, K and C matrices); connect with Series,
  Parallel and Feedback; analyse with Step Response (times in s, for
  requirements), Stability Margins, Bode, Nyquist, Pole-Zero Map, Root Locus.
- Fourier Transform (`maths.fourier_transform`) takes a signal and its times
  (or a sample spacing) and gives frequencies in Hz (1/m along a distance),
  amplitudes in the signal's unit and `peak_frequencies`.
- Start from a similar example (`list_examples`, `get_example`). Example 23
  (satellite link budget), example 24 (cantilever bracket) and example 25
  (two masses on springs: equations, matrices, a PID loop and an FFT check)
  are complete verified studies with reports.

## MCP tools (server `noodlelab mcp`)

| Tool | Use |
|---|---|
| `guide` | this text |
| `list_nodes`, `describe_node` | find nodes and their inputs, outputs, units and options |
| `list_examples`, `get_example` | complete graphs to copy from |
| `copy_example` | copy an example into the workspace with its data files, to run or adapt it |
| `list_graphs`, `get_graph` | the workspace's graphs |
| `save_graph` | write a whole graph (validated; the open editor reloads it) |
| `edit_graph` | add, set, remove, retitle or track nodes, or define the graph's constants, without resending the graph |
| `check_graph` | problems before running: types, units, missing inputs |
| `run_graph` | run it; returns each node's result, checks, requirements, files and provenance |
| `requirements` | each requirement's latest verdict and margin, and how many runs are recorded |
| `verify` | audit a script, graph or records in the workspace, as `noodlelab verify --json` does; a script is run to do it |

When you are started from the editor's Agent panel, the user is watching the
canvas: every graph you save opens there, laid out automatically.
`NOODLELAB_GRAPH` names the graph they had open.

The workflow:
1. `guide`, then `list_examples` / `list_nodes`.
2. `save_graph`, then `check_graph` until there are no errors.
3. `run_graph`, then read the checks and margins.
4. Fix and iterate. Finish with `verify`.
5. Tell the user what passed, the margins, what was assumed, and where the
   report PDF and provenance are.

# noodlelab.verify

Verifiable calculations in plain Python: units, uncertainty, requirements,
checks and an audit trail, for people and for AI agents writing code.

This is noodlelab's science without the editor. Everything here works in any
script, notebook or test after ``pip install noodlelab``::

    import noodlelab.verify as nv

    with nv.record("pendulum") as rec:                      # writes runs/<...>/provenance.json
        L = rec.input("L", "1.000 ± 0.002 m", source="tape measure, lab book p. 12")
        g = rec.input("g", "9.81 ± 0.02 m/s^2", source="local gravity survey")
        T = rec.result("period", nv.const.tau * (L / g) ** 0.5)  # (2.006 ± 0.003) s
        rec.require("PER-001 period <= 2.1 s [Analysis]  # The swing shall take at most 2.1 s")
        rec.verify("PER-001", T)                                # ✓ margin +0.094 s
    assert rec.passed

The rules it helps you keep (and :func:`audit` checks afterwards):

1. **Every number has a unit.** Values are Pint quantities; mixing dimensions
   raises instead of silently giving nonsense. Dimensionless results say so
   (``unit="1"``). Constants are named, never retyped: ``nv.const.c``,
   ``rec.constant("g0")`` (see :mod:`noodlelab.constants`), with their unit,
   their source and, for measured ones such as ``G``, their CODATA uncertainty.
2. **Every measured input has an uncertainty and a source.** Uncertainty is
   propagated by the GUM's law (first order, with correlations kept), and
   :func:`budget` says which input contributes most. :func:`monte_carlo`
   checks the linear result where the model is not linear (JCGM 101).
3. **Requirements are written down before they are checked**, as text a person
   can read (``COM-001 link_margin >= 3 dB [Analysis]``), and each check
   reports its margin, not just pass or fail. An uncertain result whose margin
   is no larger than its expanded uncertainty U = 2u is **inconclusive**, and
   does not pass: the design may meet the requirement, but the calculation
   cannot show it.
4. **Every run leaves a record**: inputs, results, requirements, checks, the
   code (its hash and git commit), the files read and written (SHA-256) and the
   environment (Python, platform, package versions), so someone else can see
   exactly what was computed and repeat it.

``noodlelab verify script.py`` runs a script, audits every record it wrote and
exits non-zero when a check fails or is inconclusive, for CI and for agents checking their own
work. Graphs from the editor are verified the same way (``noodlelab verify
analysis.graph.json``).

## class nv.Finding

Something :func:`audit` found in a record.

## class nv.MonteCarlo

A Monte Carlo evaluation (JCGM 101) of a model, next to its GUM result.

### MonteCarlo.inconclusive (property)

Too few trials to tell (the message says how many would).

## class nv.Record

The audit trail of one calculation. See :func:`record`.

### Record.close(self) -> Path | None

Finish the record and write it (done on leaving the ``with`` block).

### Record.input(self, name: str, value: Any, *, unit: str = '', source: str = '', note: str = '') -> Any

A value the calculation starts from: ``"9.81 ± 0.02 m/s^2"``, a
:func:`measure`d or :func:`q` value, or a plain number with ``unit``.
Name its ``source`` (instrument, dataset, paper, datasheet): the audit
asks for one. Measured inputs are labelled ``name`` in uncertainty
budgets. Returns the value to compute with.

### Record.constant(self, name: str, *, label: str = '') -> Any

A named constant as an input (``rec.constant("g0")``), recorded under
``label`` (default: its name) with its source, so the audit needs no
more. Returns the value to compute with.

### Record.result(self, name: str, value: Any, *, unit: str = '', exact: bool = False, note: str = '') -> Any

A result of the calculation, recorded with its unit, uncertainty and
budget. Converted to ``unit`` when given. ``exact=True`` says it has no
uncertainty on purpose (a count, a definition), which the audit then
accepts. Returns the value.

### Record.require(self, spec: str | Requirement | RequirementSet) -> RequirementSet

Write down requirements (text, one per line, or parsed ones) before
checking them; each should be verified before the record closes.

### Record.verify(self, req: str | Requirement, value: Any) -> Check

Check a result against a requirement: its id (given with
:meth:`require`), a :class:`Requirement`, or a line of text.

### Record.expect(self, condition: Any, message: str) -> Check

A check that is not a requirement: a sanity bound, a conservation
law, agreement with a reference. ``condition`` is truthy when it holds.

### Record.close_to(self, name: str, value: Any, reference: Any, *, rtol: float = 0.0, atol: Any = 0.0, k=2.0) -> Check

Check ``value`` agrees with ``reference`` (a textbook value, another
method, an earlier result): within ``rtol``/``atol`` when given, else
within k times their combined standard uncertainty (En ≤ 1 at k = 2).

Quantities are compared in the reference's unit, so 20 degC and
293.15 K agree. With a temperature in degC or degF, ``atol`` is a
difference (``0.5 K`` or ``0.5 delta_degC``; ``0.5 degC`` is read as
one too) and ``rtol`` is relative to the absolute temperature.

### Record.read(self, path: str | Path) -> Path

Note a file the calculation reads (its SHA-256 goes in the record).

### Record.output(self, name: str) -> Path

A path in this record's folder to write an output to (a plot, a
table); its SHA-256 is recorded when the record closes.

### Record.wrote(self, path: str | Path) -> Path

Note a file the calculation wrote outside the record's folder.

### Record.note(self, text: str) -> None

A remark for the reader: an assumption, a simplification, a caveat.

### Record.passed (property)

Every check passed, every requirement written down was verified,
and the block did not raise.

### Record.unverified (property)

Requirements written down but never checked.

### Record.to_dict(self) -> dict[str, Any]

The record as JSON-ready data (what :meth:`write` saves).

### Record.write(self) -> Path

Save the record as ``provenance.json`` in its folder.

### Record.summary(self) -> str

A few lines for a person (or an agent's context): the verdict, each
check, requirements left unverified and the audit's findings.

## nv.audit(rec: Record | dict[str, Any] | str | Path) -> list[Finding]

Check a record against the rules of a verifiable calculation (see
:data:`RULES`): failed or missing checks, results without units or
uncertainty, inputs without a source, code that was not committed or has
changed since. Takes a :class:`Record`, its data, or its file.

## nv.budget(value: Any) -> list[tuple[str, float, float]]

``[(input, contribution, share of variance), ...]``, largest first: which
named inputs the uncertainty of ``value`` comes from.

## nv.constant(name: str) -> Any

A named constant with its unit: ``constant("c")``, ``constant("g0")``.
Measured ones (``G``, ``m_e``...) carry their CODATA uncertainty. The same
as ``nv.const.c``; add your own with :func:`define_constant`.

## nv.current() -> Record | None

The record being written (inside ``with record(...)``), or None.

## nv.define_constant(name: str, value: Any, unit: str = '', *, uncertainty: float = 0.0, title: str = '', source: str = '', symbol: str = '', override: bool = False) -> Constant

Add a constant for every graph and script in this process::

    define("g_local", 9.8123, "m/s^2", uncertainty=0.0005, source="survey 2024")
    define("rho_water", "998.2 kg/m^3", title="density of water at 20 °C")

Replacing a built-in constant warns, unless ``override=True`` says it is
meant. ``pi``, ``tau`` and ``e`` cannot be replaced.

## nv.finished() -> list[Record]

Every record finished in this process, oldest first.

## nv.fmt(value: Any, k: float = 1.0) -> str

A value for people: ``(2.006 ± 0.003) s`` (GUM rounding), ``3 dB``, ``0.5``.
``k``: coverage factor for an expanded uncertainty.

## nv.load(path: str | Path) -> dict[str, Any]

A record from its ``provenance.json`` (or the folder holding it).

## nv.load_constants(path: str | Path) -> dict[str, Constant]

:func:`define` every constant in a ``constants.toml`` file. Raises
ConstantsError listing every problem, since a script should stop on them.

## nv.measure(value: Any, u: float | None = None, unit: str = '', *, name: str = '', distribution: Literal['normal', 'rectangular', 'triangular', 't'] = 'normal', dof: float | None = None) -> Any

A measured value with its standard uncertainty:

- ``measure("9.81 ± 0.02 m/s^2")``, ``measure("9.81(2) m/s^2")``
- ``measure(9.81, 0.02, "m/s^2")``
- ``measure(q("9.81 m/s^2"), 0.02)``

``name`` labels it in :func:`budget`. ``distribution`` (and ``dof`` for
Student's t) is what :func:`monte_carlo` samples; ``u`` is always the GUM
standard uncertainty (for a rectangular distribution of half-width a, give
a/√3; for the mean of n readings with spread s, s/√n with ``dof`` = n - 1,
which is the scale of the t distribution sampled, JCGM 101 6.4.9). An
unknown distribution, or t without ``dof``, is refused. Inside a
:func:`monte_carlo` model this returns one draw instead.

## nv.monte_carlo(model: Callable[[], Any], trials: int = 20000, *, seed: int = 1, p: float = 0.95) -> MonteCarlo

Evaluate ``model`` (a function of no arguments that makes its uncertain
inputs with :func:`measure` and returns a number or quantity) by Monte
Carlo, and compare with linear GUM propagation (JCGM 101 8). Use it when
the model is not linear in its inputs: ``agrees`` says whether the GUM
uncertainty can be trusted. The inputs must be made *inside* the model.

## nv.nominal(value: Any) -> Any

The value without its uncertainty.

## nv.q(value: Any, unit: str = '') -> Any

A quantity: ``q("9.81 m/s^2")``, ``q(9.81, "m/s^2")``, ``q("3 dB")``.
Text with an uncertainty (``"9.81 ± 0.02 m/s^2"``) gives an uncertain one.

## nv.record(name: str, *, out: str | Path | None = None, write: bool = True) -> Record

Start a record of a calculation: ``with nv.record("link budget") as rec: ...``.

On leaving the block the record is written to
``<out>/<time>-<name>-<id>/provenance.json`` (``out`` defaults to ``runs``
next to the script, or ``$NOODLELAB_RECORDS``). ``write=False`` keeps it
in memory only (tests).

## nv.requirement(line: str) -> Requirement

One requirement from a line of text (see :func:`requirements`).

## nv.requirements(text: str | Iterable[dict[str, Any]]) -> RequirementSet

Requirements from text, one per line::

    COM-001 link_margin >= 3 dB [Analysis]   # The link shall close with 3 dB to spare
    PWR-001 dc_power <= 40 W [Inspection]
    TMP-001 temperature between -20 and 60 degC

or from records (rows of a CSV/YAML spec: id, quantity, op, limit, unit...).

## nv.std_dev(value: Any) -> Any

The standard uncertainty (0 for an exact value), in the value's unit.

## nv.traced(fn: Optional[~F] = None, *, name: str | None = None) -> Any

Record every call of a function in the active record: its arguments,
result (with units and uncertainty), duration and a hash of its source.
Outside a record the function runs as usual, at no cost::

    @nv.traced
    def drag(rho, v, cd, area):
        return 0.5 * rho * v**2 * cd * area

## nv.verify(req: Requirement | str, value: Any) -> Check

Check ``value`` against a requirement (a :class:`Requirement` or its line
of text). The :class:`Check` says whether it passed, what was achieved and
the margin in the requirement's unit (negative: outside the limit). An
array passes when every element does; its margin is the worst case.

## Audit rules

- NL001: a check failed
- NL002: a requirement was written down but never verified
- NL003: nothing was verified: no requirement or check
- NL004: a result has no unit
- NL005: a result has no uncertainty and is not marked exact
- NL006: an input has no source
- NL007: the calculation raised an error
- NL008: the code had uncommitted changes when it ran
- NL009: the code changed since it ran
- NL010: Monte Carlo does not validate the GUM uncertainty
- NL011: a requirement is inconclusive: its uncertainty exceeds the margin

# MCP tools (noodlelab mcp)

## guide() -> str

How to use noodlelab well: the rules, the Python API, the graph
format and the workflow. Read it first.

## list_nodes(query: str = '', category: str = '', limit: int = 80) -> dict[str, Any]

Installed node types, one line each (id, title, what it does). Filter
with words in ``query`` (all must match the id, title, category or
description) or a ``category``.

## describe_node(type: str) -> dict[str, Any]

One node type in full: its documentation, and each input (type,
default, options, description) and output.

## list_examples() -> list[dict[str, Any]]

The bundled example graphs: id, title, what they show, and whether
the installed packs can run them.

## get_example(id: str) -> dict[str, Any]

An example graph (its ``graph`` in the format ``save_graph`` takes).

## copy_example(id: str, path: str = '') -> dict[str, Any]

Copy an example into the workspace (as ``path``, default
``examples/<id>``) with its data files, to run or adapt it.

## list_graphs() -> list[dict[str, Any]]

The graphs in the workspace, with their titles.

## get_graph(path: str) -> dict[str, Any]

A graph of the workspace: its ``graph`` (nodes and report), metadata
and revision.

## save_graph(path: str, graph: dict[str, Any], title: str = '', description: str = '') -> dict[str, Any]

Write a whole graph (``{"nodes": [...], "report": [...]}``). Input
values may be given bare (``"b": 2``) or as ``{"value": 2}``; links as
``{"link": {"node": "1", "output": "result"}}``. It is saved even with
problems (so it can be fixed in steps); they are returned.

## edit_graph(path: str, operations: list[dict[str, Any]]) -> dict[str, Any]

Change a graph step by step, creating it if new. Operations, applied
in order (``section`` is ``"nodes"``, the default, or ``"report"``):

- ``{"op": "add", "type": "core.number", "id": "7", "title": "...",
  "inputs": {"value": 3}}`` (``id`` is optional)
- ``{"op": "set", "node": "7", "inputs": {"b": {"link": {"node": "3",
  "output": "result"}}}}``
- ``{"op": "remove", "node": "7"}``
- ``{"op": "title", "node": "7", "title": "Drag force"}``
- ``{"op": "track", "node": "7", "output": "result", "label": "drag"}``:
  follow the output in the editor's Tracked tab
- ``{"op": "constant", "name": "g_local", "value": 9.8123, "unit": "m/s^2",
  "uncertainty": 0.0005, "source": "survey 2024", "title": "local g"}``:
  a named constant of the graph's own, for Constant nodes and Values
  lines (``"value": null`` removes it; ``"value": "998.2 kg/m^3"`` works too)

## check_graph(path: str) -> dict[str, Any]

Problems with a saved graph before running it: unknown types,
mismatched types or units, missing inputs, cycles.

## run_graph(path: str, targets: list[str] | None = None) -> dict[str, Any]

Run a saved graph (only what ``targets``, node ids, need). Returns
each node's outputs in short, the checks and requirement verdicts with
their margins, errors, the files written (a report's PDF) and the
provenance record.

## requirements(path: str) -> dict[str, Any]

Each requirement of a graph as its latest run left it (passed,
failed, inconclusive or not verified, with the margin and where it came from), and
how many runs are recorded.

## verify(target: str, strict: bool = False) -> dict[str, Any]

Run and audit a Python script (that uses noodlelab.verify.record), a
graph (a path in the workspace) or records written earlier, as
``noodlelab verify --json`` does: ``passed`` is true when every check
passed and every requirement was verified. Verifying a script runs it,
as ``python script.py`` would, so only targets inside the workspace
are accepted.

# Command line

- `noodlelab serve folder --host --port --home --auth --no-browser --demo`: start the editor, API and admin portal
- `noodlelab run graph --target --workspace --home --no-checkpoints --no-provenance -v --run-id --events --cancel-file --info`: run a saved graph without the editor
- `noodlelab test graphs --baseline --rtol --atol --junit --workspace --home --no-checkpoints`: run graphs as tests: checks and baselines, for CI
- `noodlelab verify targets --json --strict --arg --workspace --home --no-checkpoints`: run scripts or graphs and audit them: checks, requirements, units, provenance
- `noodlelab mcp --workspace --home`: MCP server for AI agents (stdio): build, run and verify graphs and reports
- `noodlelab init-agent folder --command --no-claude`: set up a project for AI agents: AGENTS.md, CLAUDE.md, skill and .mcp.json
- `noodlelab nodes --json --strict`: list installed nodes
- `noodlelab constants graph --workspace --json`: list the named constants: built-in, the workspace's constants.toml, a graph's own
- `noodlelab tiers --json`: show the installed tier and how to add others
- `noodlelab export graph -o --workspace`: export a graph as a Python script
- `noodlelab new-pack name --directory`: scaffold a new node pack
- `noodlelab check-pack module --pack`: lint a node pack module
- `noodlelab admin-token --home`: print a new admin token

# Core concepts

## noodlelab.core.units

Physical quantities as socket types, with `Pint <https://pint.readthedocs.io>`_.

Two ways to give a socket a unit, which work together:

* ``Quantity["km/h"]``: the value is a Pint quantity. Any quantity of the same
  dimension connects, and arrives converted to the unit the input declares, so
  a node taking ``Quantity["m"]`` always receives metres. A bare ``Quantity``
  accepts any dimension (checked when the graph runs).
* A ``NewType`` with a registered unit: the value stays a plain ``float`` (or
  array), and the unit is part of the type::

      Seconds = unit_type("Seconds", "s")        # NewType("Seconds", float) + its unit
      register_unit(Days, "day")                 # for a NewType defined elsewhere

  ``Seconds`` then connects to ``Days`` (converted on the link), to
  ``Quantity["h"]`` (wrapped as a quantity) and back, but never to a length.
  A NewType without a unit stays strict: it needs an explicit converter.

Conversions happen on the link, in runs, checks, previews and exported
scripts alike, the way Blender converts between socket types. Mixing
dimensions is refused while editing, before anything runs.

Quantity inputs get a text widget that reads values like ``"9.81 m/s^2"``.
Pint comes with noodlelab; it is only imported by graphs that use quantities.

## noodlelab.core.uncertainty

Measurement uncertainty, following the GUM (JCGM 100), with `uncertainties
<https://uncertainties.readthedocs.io>`_.

An uncertain value is a number (or a Pint quantity) with a standard
uncertainty: ``9.81 ± 0.02 m/s²``. It is a property of the value, not of the
socket, so it flows wherever the number would:

* **Every node propagates it.** An uncertain value arriving at an input that
  takes a plain number or a quantity is handled by the framework (:func:`lift`):
  the node runs on the nominal values, and again with each uncertain input
  nudged, which gives the sensitivity coefficients (∂f/∂xᵢ) of the GUM's law
  of propagation. The results carry the combined uncertainty, and
  correlations: two results computed from the same measurement stay
  correlated, so ``a - a`` is exactly 0. Node authors write nothing.
* ``Uncertain`` in a signature asks for the uncertain value itself (to show it,
  or to report its budget); ``Uncertain["m"]`` asks for one in metres. Such an
  input may also receive an exact number, which has no uncertainty.
* Widgets for ``Uncertain`` and ``Quantity`` inputs read ``9.81 ± 0.02 m/s^2``,
  ``9.81 +/- 0.02`` and the concise ``9.81(2)``.
* Each value made by Make Uncertain has a name, so a result can list how much
  each input contributed to its uncertainty (:func:`budget`).

Linear propagation is exact for linear models and a first-order approximation
otherwise (GUM 5.1.2); results are reported with the uncertainty rounded to two
significant digits and the value to the same decimal place (GUM 7.2.6).

## noodlelab.core.constants

Named constants: π, the speed of light, standard gravity, and your own.

A constant is a number someone else already fixed, so it should be named, not
retyped. ``299792.458`` typed into a graph cannot be told from a measurement,
and ``9.81`` might be standard gravity or a local survey. A constant carries
its unit, its source and, where it was measured rather than defined, its
standard uncertainty. That uncertainty then propagates like any other
(see :mod:`.uncertainty`):

* **Mathematical constants** (``pi``, ``tau``, ``e``) are the :mod:`math`
  module's, plain numbers without a unit.
* **Exact constants** are defined, not measured: the SI's since 2019 (``c``,
  ``h``, ``k_B``, ``N_A``, ``q_e``...) and conventions such as ``g0`` and
  ``atm``. They are quantities with no uncertainty.
* **Measured constants** (``G``, ``m_e``, ``alpha``...) come from CODATA 2022
  with their standard uncertainty. They are uncertain values named after the
  constant, so an uncertainty budget can say how much ``G`` contributed.

Pint knows most of these too, as units. That is why ``h`` in a unit is an
hour, ``G`` is a gauss and ``e`` is an elementary charge. The names here
follow the physics instead, and ``e`` stays Euler's number as in :mod:`math`
and in symbolic expressions. The elementary charge is ``q_e``.

**Your own constants** come in layers. Each layer can add constants and
replace those of the layers before it:

1. the built-in table (:data:`BUILTIN`);
2. constants registered from Python with :func:`define` (a node pack can do
   this when it is imported);
3. the workspace's ``constants.toml``, shared by every graph in it;
4. the graph's own (``ExecGraph.constants``), which travel with the file.

Replacing a built-in constant is allowed, since a lab may use a local value,
but it is flagged unless the entry says ``override = true``. ``pi``, ``tau``
and ``e`` can never be replaced, because symbolic expressions fix their
meaning. In ``constants.toml``, an entry is either text or a table::

    rho_water = "998.2 kg/m^3"
    k_spring = "1520 ± 12 N/m"

    [g_local]
    value = 9.8123
    unit = "m/s^2"
    uncertainty = 0.0005
    title = "local acceleration of gravity"
    source = "gravity survey 2024, station 12"

The layers that apply to a graph make up a :class:`Scope`. The executor enters
it (:func:`scope`) while it checks, probes, runs or exports that graph, so
nodes read it with :func:`get` and need no extra input. A node that reads
constants tells the executor which ones (``@my_node.uses_constants``). Their
definitions then go into its cache key, and editing ``constants.toml`` reruns
exactly the nodes that use what changed. The scope lives in a
:class:`~contextvars.ContextVar`. ``asyncio.to_thread`` and tasks carry it
along, but a bare ``threading.Thread`` does not: start threads that run node
code through ``contextvars.copy_context().run``.

## noodlelab.core.montecarlo

Monte Carlo propagation of distributions (JCGM 101, GUM Supplement 1).

The Monte Carlo node asks the executor to run what feeds it again and again
(:meth:`.executor.Executor._monte_carlo`): the nodes upstream of its input
that depend on an uncertainty source (a Measurement, a Type A or B
evaluation, a value typed with ±). In each trial every source draws a value
from its own distribution (:func:`.uncertainty.make` does it while a
:class:`Sampler` is active): normal, rectangular, triangular, or Student's t
for a mean of few readings. Everything downstream runs on plain numbers, so
non-linear models and arrays are propagated exactly, which the linear (GUM)
propagation only approximates. When every node re-run is vectorized (see
``@node(vectorized=True)``), the trials run in batches, each source drawing an
array, with the same samples (see :class:`Sampler`).

The node then reports the mean, the standard uncertainty, a probabilistically
symmetric coverage interval, and whether the GUM result is validated by the
Monte Carlo one (JCGM 101, 8).

## noodlelab.core.requirements

Requirements: what a design must achieve, as values the graph can use and verify.

A :class:`Requirement` says that a quantity (a metric such as ``link_margin``
or ``dc_power``) must be at least, at most, equal to or between limits, in a
unit. Requirements travel through the graph as a :class:`RequirementSet`, so
the same statement of need is used three ways:

* as an **input**: a requirement's limit drives the design (the required bit
  error rate sets the required signal-to-noise ratio);
* as a **check**: :meth:`Requirement.verify` compares a result with it and
  returns a :class:`~noodlelab.core.checks.Check` with the margin, which the
  executor reports like any check (Problems tab, ``noodlelab test``,
  provenance). A result with an uncertainty is judged with its expanded
  uncertainty U = 2u: when the nominal value meets the limit but by no more
  than U, the verdict is **inconclusive**, which is not a pass;
* as a **filter**: :meth:`Requirement.margins` scores many candidates at
  once, for trade studies and selection.

The Requirements pack has the nodes; :class:`Verifications` collects checks
into a compliance matrix for a report.

Requirements are written one per line::

    COM-001 link_margin >= 3 dB [Analysis]  # The link shall close with 3 dB margin
    PWR-001 dc_power <= 40 W
    THM-001 temperature between -20 and 60 degC
    ORB-001 altitude == 550 ± 5 km

``>`` and ``<`` are strict: a value exactly at the limit meets ``>=`` but
not ``>``. A value without a unit is taken to be in the requirement's unit
(decibels and bit rates are usually plain numbers); a quantity is converted
to it. A dimensionless quantity against a requirement in decibels is refused:
it could be a ratio (4, which is 6.02 dB) or a level already in dB. A
temperature difference (``delta_degC``, as ``degC - degC`` gives it) against
a limit in ``degC`` is compared as a difference; a value in ``K`` is an
absolute temperature, so write limits on differences in ``K`` or
``delta_degC``.

## noodlelab.core.checks

Checks: expectations about results that are reported without stopping the run.

A node reports a check by returning a :class:`Check` (usually as one of the
outputs of a NamedTuple, next to the value it checked). The executor turns
each one into a ``node_check`` event, from a fresh result and from one reused
from the cache alike, so a check never goes quiet; ``noodlelab test`` fails
when any check fails. The Checks pack has the nodes (Expect Value, Expect In
Range, Expect Table).

A check that verifies a requirement (see :mod:`noodlelab.core.requirements`)
also says which one, what was required and achieved, and the margin: how far
the value is inside the limit (negative: outside), in the requirement's unit.

A check of an uncertain value can also be **inconclusive**: its nominal value
meets the requirement, but not by more than its expanded uncertainty
U = 2u, so the true value may well not. Such a check has ``passed=False`` and
``inconclusive=True``, so everything that only asks whether a check passed
(``noodlelab test``, ``bool(check)``) treats it as not passed; :attr:`Check.status`
tells the three verdicts apart.

## noodlelab.core.provenance

Run provenance: what produced the files in a run folder.

Every run that writes outputs (into ``runs/<time>-<run id>/``, see
:meth:`RunContext.path`) gets a ``provenance.json`` next to them, with
everything needed to tell later how they came about:

* the graph exactly as it was run, and who ran it, when, and with what result;
* the environment: noodlelab, Python and platform, and the version of every
  installed distribution (including the node packs);
* per node: its type, the hash of its source code, its settings and links,
  its Merkle key, and whether it was computed in this run or reused (with the
  run that originally computed it, for results restored from a checkpoint);
* fingerprints of the files each node read: size, modification time and,
  for local files up to a size limit, a SHA-256 of the content;
* the files the run wrote, with their sizes and hashes.

The record is plain JSON, meant to be read by people and tools alike. It is
written when the run ends, whether it succeeded, failed or was cancelled; the
files are fingerprinted then too, in a worker thread.

## noodlelab.core.graph

Graph documents.

The editor keeps its own layout (litegraph's serialisation: positions, sizes,
groups, colours) but *executes* a small, editor-agnostic description::

    {"nodes": [
        {"id": "1", "type": "core.number", "inputs": {"value": {"value": 2.5}}},
        {"id": "2", "type": "core.math",
         "inputs": {"a": {"link": {"node": "1", "output": "result"}},
                    "b": {"value": 3}, "operation": {"value": "multiply"}}}
    ]}

A saved :class:`GraphDocument` contains both, so a graph can be reopened in the
editor *and* run headless with ``noodlelab run graph.json``.

**Subgraphs.** A node of type ``"subgraph"`` holds a graph of its own, like a
Blender node group. Its ``inputs`` and ``outputs`` are ports: inside, a link
to ``{"node": "..", "output": "signal"}`` reads the subgraph's input
``signal``, and each output names the inner output it forwards. Outside, it
is linked like any node::

    {"id": "5", "type": "subgraph", "title": "Clean signal",
     "inputs": {"signal": {"link": {"node": "1", "output": "result"}}},
     "subgraph": {
        "inputs": [{"name": "signal"}],
        "outputs": [{"name": "clean", "link": {"node": "2", "output": "result"}}],
        "nodes": [{"id": "2", "type": "science.savgol_filter",
                   "inputs": {"y": {"link": {"node": "..", "output": "signal"}}}}]}}

Before anything checks or runs a graph, :func:`flatten` dissolves subgraphs
into ordinary nodes with path-like ids (``"5/2"``: node 2 inside node 5), so
the executor, health checks, probes, checkpoints and the exporter see a flat
graph, and problems and results are reported against the inner nodes.

**The report.** The editor shows the report in a tab of its own, and a graph
keeps it apart as well: ``report`` holds the report's nodes, whose links point
at other report nodes, or, with ``"tab": "processing"``, at the output of a
processing node::

    {"nodes": [{"id": "5", "type": "maths.xy_plot", ...}],
     "report": [
        {"id": "1", "type": "report.new_report", "inputs": {...}},
        {"id": "2", "type": "report.add_figure",
         "inputs": {"report": {"link": {"node": "1", "output": "result"}},
                    "figure": {"link": {"node": "5", "output": "result",
                                        "tab": "processing"}}}}]}

Data flows one way: processing nodes cannot link into the report.
:func:`flatten` merges the report in with ids like ``"report/2"``.

**Repeat zones.** A node of type ``"repeat"`` runs its body again and again,
like Blender's repeat zone. Its ``state`` items are fed back: each pass starts
from what the previous one produced, ``next`` naming the body output that
gives the next value. The zone's inputs of the same names give the first
values, and ``iterations`` (or ``mode: "until"`` with a boolean ``until``
output, up to ``max_iterations``) says how often it runs::

    {"id": "7", "type": "repeat", "title": "Time steps",
     "inputs": {"iterations": {"value": 100},
                "T": {"link": {"node": "3", "output": "result"}}},
     "repeat": {
        "state": [{"name": "T", "next": {"node": "5", "output": "result"}, "collect": true}],
        "nodes": [{"id": "5", "type": "science.cool",
                   "inputs": {"T": {"link": {"node": "..", "output": "T"}},
                              "k": {"link": {"node": "2", "output": "result"}}}}]}}

Unlike a subgraph, a zone's body shares the ids of the graph around it, so body
nodes read outer nodes directly (``"2"`` above), and ``{"node": "..",
"output": "iteration"}`` is the pass number, from 0. Outside, ``{"node": "7",
"output": "T"}`` is the final value, and ``iterations``, ``converged`` and
``history`` (the collected values of every pass, by state name) describe the
run. :func:`flatten` turns a zone into ordinary nodes: the driver ``7``
(``core.repeat``), a pass-through ``7:T`` (``core.repeat_state``) per state
item, ``7:iteration`` (``core.repeat_iteration``) and the body, wired as for the
first pass, and describes it in :attr:`ExecGraph.zones` for the executor.

## noodlelab.core.tracked

Tracked values: node outputs followed from run to run.

A graph lists the outputs it tracks in its metadata (``tracked``: node,
output and optionally a field path, with a label). After each run of the
graph, :meth:`TrackLog.record` appends one line per run to
``.noodlelab/tracked/<graph>.jsonl`` in the workspace: for every tracked
output its value (for a single number, with its unit and uncertainty; see
:func:`noodlelab.core.provenance.scalar_value`), a short summary, whether
the run computed it or reused it, and which run computed it when. The editor's
Tracked tab reads the history back with :meth:`TrackLog.history`.

The history describes runs, so it goes with them: deleting a run removes its
line (:meth:`JsonlLog.forget_runs`), and :meth:`JsonlLog.clear` starts a
graph's history again.

## noodlelab.core.reqlog

Requirements followed from run to run.

After each run of a graph, :meth:`RequirementLog.record` appends one line to
``.noodlelab/requirements/<graph>.jsonl`` in the workspace. The line lists
every requirement the run knew about, by id:

- the requirements a node produced (a Requirements or Load Requirements node's
  set), which are ``not verified`` until some node checks them;
- the verdict of every check of a requirement (see :mod:`.checks`): passed,
  inconclusive or failed, what was required and achieved, the margin and
  uncertainty, and where it came from.
  That is the node and output that checked it, whether the run computed the
  check or reused it, and which run computed it when.

The editor's Requirements tab reads it back with :meth:`RequirementLog.latest`
(the state of each requirement now) and :meth:`RequirementLog.history`.

# Nodes

`type(inputs) -> outputs`: what it does. By pack.

## checks

- `checks.expect_in_range(value: Any, minimum: float | None, maximum: float | None, unit: str = '', allow_missing: bool = False) -> (value: Any, check: Check)`: Expect In Range. Expect a number, an array or a column to lie within limits (either may be left empty). Missing values (NaN) fail unless allowed.
- `checks.expect_table(table: DataFrame, columns: str = '', complete: str = '', unique: str = '', min_rows: int = 1, max_rows: int = 0) -> (value: Any, check: Check)`: Expect Table. Expect a table to have its columns, no gaps where it matters, a unique key and a plausible number of rows.
- `checks.expect_value(value: Any, expected: Uncertain = 0.0, tolerance: float = 0.0, relative: float = 0.0, coverage: float = 2.0) -> (value: Any, check: Check)`: Expect Value. Expect a result to equal a value: typed ("9.81", "9.81 m/s^2", "9.81 ± 0.02") or linked. Passes within the tolerance, or, when either side has an uncertainty, when |Δ| ≤ k·√(u₁² + u₂²). Arrays are compared element-wise.

## core

- `core.boolean(value: bool = False) -> (result: bool)`: Boolean. A constant true/false switch.
- `core.compare(a: float = 0.0, b: float = 0.0, operation: Literal['less', 'less or equal', 'greater', 'greater or equal', 'equal'] = 'less', epsilon: float = 1e-06) -> (result: bool)`: Compare. Compare two numbers.
- `core.describe_data(value: Any, title: str = '', description: str = '', source: str = '', licence: str = '', citation: str = '', flags: str = '') -> (result: Any)`: Describe Data. Say what a value is and where it came from: a title, a description, its sources (files, instruments, URLs), licence, citations and quality notes. The value passes through unchanged; what you write travels with it and everything computed from it, into the inspector and to Add Data Sources in reports.
- `core.file_path(path: Path = 'data.csv') -> (result: Path)`: File Path. A path to a file or folder, relative to the workspace. The node it is linked into checks that the file exists and previews it.
- `core.float_to_int(value: float, mode: Literal['round', 'floor', 'ceil', 'truncate'] = 'round') -> (result: int)`: Float To Integer. Turn a number into an integer. Inserted when a float is linked into an integer input.
- `core.format_text(template: str = 'Result: {value}', value: Any) -> (result: str)`: Format Text. Python str.format with a single `value`, e.g. "{value:.3f} mm". Fields are names only: {value.attr} and {value[0]} are refused.
- `core.format_values(template: str = '{a} and {b}', a: Any, b: Any, c: Any, d: Any, e: Any, f: Any, g: Any, h: Any) -> (result: str)`: Format Values. Text with up to eight computed values, for report paragraphs: "The slope is {a:.3f} ± {b:.2g} (R² = {c:.4f})". Quantities take Pint's format codes too: {a:.2f~P} gives "9.81 m/s²". Fields are names only: {a.attr} and {a[0]} are refused.
- `core.integer(value: int = 0) -> (result: int)`: Integer. A constant integer.
- `core.join_text(a: str = '', b: str = '', separator: str = ' ') -> (result: str)`: Join Text. Join two pieces of text.
- `core.map_range(value: float = 0.5, from_min: float = 0.0, from_max: float = 1.0, to_min: float = 0.0, to_max: float = 1.0, clamp: bool = True) -> (result: float)`: Map Range. Linearly remap a value from one range to another.
- `core.math(operation: Literal['add', 'subtract', 'multiply', 'divide', 'power', 'minimum', 'maximum', 'modulo', 'sqrt', 'absolute', 'exponent', 'log', 'sine', 'cosine', 'tangent', 'round'] = 'add', a: float = 0.0, b: float = 0.0) -> (result: float)`: Math. Scalar math. Unary operations ignore B.
- `core.number(value: float = 0.0) -> (result: float)`: Number. A constant floating point value.
- `core.optimize(parameters: str = 'x = 0 .. 10', method: Literal['Nelder-Mead', 'Powell', 'L-BFGS-B'] = 'Nelder-Mead', tolerance: float = 1e-06, max_evaluations: int = 200) -> (best: dict[str, Any], objective: float, evaluations: Any, converged: bool, iterations: int)`: Optimize Zone. Find the parameter values that make the zone's objective smallest, e.g. a sum of squared residuals to calibrate a model against data. The body runs once per point the optimiser (SciPy) tries.
- `core.path_to_text(path: Path) -> (result: str)`: Path To Text. A path as text. Applied automatically when a path is linked into a text input.
- `core.read_text_file(path: FileRef) -> (result: str)`: Read Text File. Read a UTF-8 text file, from the workspace or remote storage. It is read again when the file changes.
- `core.repeat(iterations: int = 10, mode: Literal['count', 'until'] = 'count', max_iterations: int = 1000) -> (iterations: int, converged: bool, history: dict[str, Any])`: Repeat Zone. Run the nodes of the zone again and again, each pass starting from the state the previous one produced. ``iterations`` is the number of passes made, ``converged`` whether the Until condition came true (always true in count mode), and ``history`` the collected state of every pass.
- `core.repeat_iteration() -> (result: int)`: Iteration. The number of the pass, from 0.
- `core.repeat_state(initial: Any) -> (result: Any)`: Repeat State. A zone's state item: its first value on the first pass, then what the previous pass produced.
- `core.save_text_file(content: str, filename: str = 'output.txt') -> (result: Path)`: Save Text File. Write text into this run's output folder.
- `core.sweep(design: Any) -> (results: Any, iterations: int)`: Sweep Zone. Run the nodes of the zone once per row of the design, each pass getting that row's values, and collect what reaches Sweep Output into a table: the design with a column per result.
- `core.sweep_result() -> (result: Any)`: Result. A result a sweep or optimize zone collects on each pass.
- `core.sweep_row() -> (result: dict[str, Any])`: Parameters. The parameter values of the current pass of a sweep or optimize zone.
- `core.text(value: str = '') -> (result: str)`: Text. A constant piece of text.
- `core.value_class(value: float = 0.0, breaks: str = '0', labels: str = 'low, high') -> (result: str)`: Value To Class. The label of the class a number falls in, e.g. a vibration velocity into ISO zones A–D or a concentration into "below / above the limit". A value equal to a break goes into the upper class.
- `core.viewer(value: Any) -> (result: Any)`: Viewer. Pass a value through unchanged. Select the node to inspect it in full.

## engineering

- `engineering.axial_stress(force: Quantity[N], area: Quantity[mm^2]) -> (result: Quantity[MPa])`: Axial Stress. Direct stress under an axial force, σ = F / A (tension positive).
- `engineering.beam(length: Quantity[m] = '2.0 m', load: Quantity[kN] = '5.0 kN', youngs_modulus: Quantity[GPa] = '200.0 GPa', second_moment: Quantity[mm^4] = '4000000.0 mm ** 4', support: Literal['simply supported', 'cantilever', 'fixed-fixed'] = 'simply supported', load_type: Literal['point', 'uniform'] = 'point', points: int = 101) -> (max_deflection: Quantity[mm], max_moment: Quantity[N*m], max_shear: Quantity[N], x: NDArray[float64], deflection: NDArray[float64], plot: Figure, summary: dict[str, float])`: Beam. Maximum deflection, bending moment and shear of a prismatic beam, and its deflected shape. ``load`` is the total load: a point load at midspan (at the free end of a cantilever), or spread uniformly over the span. Fixed-fixed moments are the largest, at the supports.
- `engineering.bending_stress(moment: Quantity[N*m], section_modulus: Quantity[mm^3]) -> (result: Quantity[MPa])`: Bending Stress. The largest bending stress in a section, σ = M / Z.
- `engineering.bode_plot(system: LTI, min_frequency: Quantity[rad/s] = '0.0 rad / s', max_frequency: Quantity[rad/s] = '0.0 rad / s', points: int = 500, input: str = '', output: str = '') -> (frequency: NDArray[float64], magnitude_db: NDArray[float64], phase_deg: NDArray[float64], plot: Figure, gain_margin_db: float, phase_margin_deg: float, summary: dict[str, float])`: Bode Plot. Magnitude (dB) and phase (degrees) against frequency (rad/s), with the gain and phase margins read as for an open loop (NaN where the curve does not cross 0 dB or −180°). Link the open loop L(s), the controller and plant in series, to see how far the closed loop is from instability.
- `engineering.controllability(system: LTI) -> (controllable: bool, observable: bool, controllable_rank: int, observable_rank: int, states: int, controllability_matrix: NDArray[float64], observability_matrix: NDArray[float64], summary: dict[str, float])`: Controllability & Observability. Whether the inputs can steer every state (controllable) and the outputs reveal every state (observable): the ranks of the controllability matrix [B AB A²B …] and the observability matrix [C; CA; CA²; …] against the number of states. Pole placement and LQR need a controllable system.
- `engineering.convection(coefficient: Quantity[W/(m^2*K)] = '10.0 W / K / m ** 2', area: Quantity[m^2] = '1.0 m ** 2', temperature_difference: Quantity[delta_degC] = '20.0 Δ°C') -> (heat_flow: Quantity[W], resistance: Quantity[K/W])`: Convection. Heat flow from a surface to a fluid, Q = h A ΔT (Newton's law of cooling). Typical h: 5–25 W/(m²·K) still air, 10–200 forced air, 500–10 000 forced water.
- `engineering.convert_system(system: LTI, to: Literal['transfer function', 'state space'] = 'state space') -> (result: LTI)`: Convert System. The same system as a transfer function or in state space. Without the optional slycot package, only single-input, single-output systems convert to a transfer function.
- `engineering.db_budget(items: str = 'Transmit power 30 dBm\nCable loss -2\nAntenna gain 12\nPath loss -120', extra: float = 0.0) -> (total: float, table: DataFrame)`: dB Budget. Add up gains and losses in dB, one per line (``#`` starts a comment). The table has the running total after each item, for a report.
- `engineering.dc_gain(system: LTI, input: str = '', output: str = '') -> (result: float)`: DC Gain. The steady-state gain: the output for a constant unit input, once everything has settled, G(0) (or G(1) when discrete). Infinite with an integrator.
- `engineering.decision_matrix(table: DataFrame, option: str = '', criteria: str = '') -> (ranking: DataFrame, best: str, plot: Figure)`: Decision Matrix. Rank options (one per row) by a weighted sum of criteria. Each criterion column is scaled from 0 (worst option) to 1 (best), so units do not matter, then weighted by the magnitude of its weight. The score is out of 100.
- `engineering.discretize(system: LTI, sample_time: Quantity[s] = '0.01 s', method: Literal['zoh', 'foh', 'tustin', 'matched', 'euler', 'backward_diff'] = 'zoh') -> (result: LTI)`: Discretize. A continuous system as a discrete one sampled every ``sample_time``, for a digital controller: G(s) becomes G(z).
- `engineering.euler_buckling(youngs_modulus: Quantity[GPa], second_moment: Quantity[mm^4], length: Quantity[m], area: Quantity[mm^2] | None, ends: Literal['pinned-pinned', 'fixed-free', 'fixed-pinned', 'fixed-fixed'] = 'pinned-pinned') -> (critical_load: Quantity[kN], critical_stress: Quantity[MPa], slenderness: float)`: Euler Buckling. The elastic buckling load of a slender column, P = π² E I / (K L)², with the effective length factor K for the end conditions. Give the ``area`` for the critical stress and slenderness ratio K L / r; stocky columns (slenderness below about 100 in steel) yield before they buckle.
- `engineering.feedback(system: LTI, feedback: Any, sign: Literal['negative', 'positive'] = 'negative') -> (result: LTI)`: Feedback. Close the loop around ``system``: G / (1 + G H) with negative feedback, the usual kind, where the output is compared with the set point. With unity feedback (no H) and the open loop L = C G linked, this is the closed loop T = L / (1 + L).
- `engineering.first_order_system(gain: float = 1.0, time_constant: Quantity[s] = '1.0 s', delay: Quantity[s] = '0.0 s', pade_order: int = 3) -> (result: LTI)`: First-Order System. K / (τ s + 1): a lag such as a heater, a tank or an actuator, reaching 63 % of its final value after one time constant. A delay is approximated by a Padé filter of ``pade_order``.
- `engineering.from_db(db: float, kind: Literal['power', 'amplitude'] = 'power') -> (result: float)`: From dB. The ratio a value in decibels stands for.
- `engineering.impulse_response(system: LTI, duration: Quantity[s] = '0.0 s', points: int = 1000, input: str = '', output: str = '') -> (t: Quantity[s], y: NDArray[float64], plot: Figure, peak: float, final_value: float, summary: dict[str, float])`: Impulse Response. The response to a unit impulse (a hammer blow): the system's own motion, which rings at its natural frequencies. ``peak`` is the value largest in size, with its sign.
- `engineering.initial_response(system: LTI, initial_state: str = '1, 0', duration: Quantity[s] = '0.0 s', points: int = 1000, output: str = '') -> (t: Quantity[s], y: NDArray[float64], plot: Figure, peak: float, final_value: float, summary: dict[str, float])`: Initial Response. The free motion from an initial state with no input: a structure released from a deflected shape. The states are those of the state-space model (Mass-Spring-Damper: positions, then velocities, in SI units).
- `engineering.lqr(system: LTI, Q: Quantity | NDArray[floating] | None, R: Quantity | NDArray[floating] | None) -> (gain: NDArray[float64], closed_loop: LTI, poles: NDArray[complex128], summary: dict[str, float])`: LQR. The linear-quadratic regulator: the state feedback u = −K x that minimises ∫ (xᵀQx + uᵀRu) dt. Larger Q entries hold those states closer; larger R entries spend less input. ``closed_loop`` is the system with the feedback in place, driven by a reference r added to the input.
- `engineering.mass_spring_damper(mass: Quantity | NDArray[floating], stiffness: Quantity | NDArray[floating], damping: Quantity | NDArray[floating] | None, force_at: str = '1', measure: str = 'x1') -> (result: LTI)`: Mass-Spring-Damper. The state-space model of masses, springs and dampers, M x'' + C x' + K x = F, for Step Response, Feedback and the rest. The states are the positions x1… and velocities v1…, the inputs the forces F1… at ``force_at``, and the outputs as ``measure`` says. M in kg, K in N/m and C in N·s/m (other units are converted; plain numbers are SI).
- `engineering.material(name: str = 'Aluminium 6061-T6') -> (youngs_modulus: Quantity[GPa], poisson_ratio: float, density: Quantity[kg/m^3], yield_strength: Quantity[MPa], ultimate_strength: Quantity[MPa], thermal_expansion: Quantity[1/K], thermal_conductivity: Quantity[W/(m*K)], properties: dict[str, Any])`: Material. Typical properties of a common material: stiffness, density, strength, thermal expansion and conductivity. For preliminary design: check a datasheet before relying on the strengths.
- `engineering.material_table() -> (result: DataFrame)`: Material Table. Every built-in material as a table, with specific stiffness and strength (per unit density), for filtering, plotting or a Decision Matrix.
- `engineering.minimal_realisation(system: LTI, tolerance: float = 1e-06) -> (result: LTI)`: Minimal Realisation. The system without its cancelling poles and zeros: (s + 1)/((s + 1)(s + 2)) is 1/(s + 2). Without the optional slycot package, a state-space system must have one input and one output.
- `engineering.nyquist_plot(system: LTI, input: str = '', output: str = '') -> (plot: Figure, encirclements: int, open_loop_unstable_poles: int, closed_loop_stable: bool, summary: dict[str, float])`: Nyquist Plot. The open loop L(jω) drawn in the complex plane for all frequencies. The closed loop (unity negative feedback) is stable when the curve encircles −1 anticlockwise once for each unstable open-loop pole: Z = N + P unstable closed-loop poles, with N the clockwise encirclements.
- `engineering.parallel(a: LTI, b: LTI, sign: Literal['add', 'subtract'] = 'add') -> (result: LTI)`: Parallel. Two systems side by side on the same input, their outputs added (or b subtracted from a).
- `engineering.pid_controller(kp: float = 1.0, ki: float = 0.0, kd: float = 0.0, derivative_filter: Quantity[s] = '0.01 s') -> (result: LTI)`: PID Controller. A PID controller, C(s) = Kp + Ki/s + Kd s/(Tf s + 1). The derivative is filtered, as any real one is: without the filter it would be improper, amplifying noise without limit. Gains are in consistent SI units.
- `engineering.pipe_conduction(conductivity: Quantity[W/(m*K)], inner_radius: Quantity[mm] = '25.0 mm', outer_radius: Quantity[mm] = '50.0 mm', length: Quantity[m] = '1.0 m', temperature_difference: Quantity[delta_degC] = '20.0 Δ°C') -> (heat_flow: Quantity[W], resistance: Quantity[K/W])`: Pipe Conduction. Radial heat flow through a pipe wall or insulation layer, Q = 2π k L ΔT / ln(r₂ / r₁).
- `engineering.pipe_pressure_drop(flow_rate: Quantity[L/s] = '2.0 l / s', diameter: Quantity[mm] = '50.0 mm', length: Quantity[m] = '100.0 m', roughness: Quantity[mm] = '0.045 mm', density: Quantity[kg/m^3] = '998.0 kg / m ** 3', viscosity: Quantity[Pa*s] = '0.001 Pa * s') -> (pressure_drop: Quantity[kPa], velocity: Quantity[m/s], reynolds: float, friction_factor: float, regime: str)`: Pipe Pressure Drop. The friction pressure drop along a straight, full, circular pipe, Δp = f (L / D) ρ v² / 2. The Darcy friction factor f is 64 / Re in laminar flow and from the Haaland equation otherwise. Roughness: about 0.0015 mm drawn tubing and plastic, 0.045 mm commercial steel, 0.26 mm cast iron.
- `engineering.pole_placement(system: LTI, poles: str = '-2, -3') -> (gain: NDArray[float64], closed_loop: LTI, poles: NDArray[complex128], summary: dict[str, float])`: Pole Placement. The state feedback u = −K x that puts the closed-loop poles where you say: further left is faster, and a pair a ± bj has damping ratio −a/√(a² + b²). The system must be controllable.
- `engineering.pole_zero_map(system: LTI, input: str = '', output: str = '') -> (poles: NDArray[complex128], zeros: NDArray[complex128], table: DataFrame, plot: Figure, stable: bool, min_damping: float, dc_gain: float, summary: dict[str, float])`: Pole-Zero Map. The poles (×) and zeros (○) in the complex plane, and a table with each pole's natural frequency ωn, damping ratio ζ and time constant τ. Poles in the right half plane (outside the unit circle, when discrete) make the system unstable; lightly damped ones make it ring.
- `engineering.reynolds_number(velocity: Quantity[m/s], length: Quantity[m] = '0.05 m', density: Quantity[kg/m^3] = '998.0 kg / m ** 3', viscosity: Quantity[Pa*s] = '0.001 Pa * s') -> (reynolds: float, regime: str)`: Reynolds Number. Re = ρ v L / μ, with ``length`` the pipe diameter (or a body's characteristic length), and the pipe-flow regime: laminar below 2300, turbulent above 4000. The defaults are water at 20 °C.
- `engineering.root_locus(system: LTI, gain: float = 1.0, max_gain: float = 0.0, input: str = '', output: str = '') -> (plot: Figure, poles_at_gain: NDArray[complex128], stable_at_gain: bool, critical_gain: float, summary: dict[str, float])`: Root Locus. Where the closed-loop poles go as the gain K of the loop K L(s) rises from 0: they start at the open-loop poles (×) and end at its zeros (○) or run off to infinity. ``critical_gain`` is the smallest gain at which the closed loop turns unstable (infinite if it never does, NaN if it is never stable).
- `engineering.safety_factor(capacity: Quantity, demand: Quantity, required: float = 1.5) -> (factor: float, margin: float, passes: bool)`: Safety Factor. How far a design is from failing: the factor of safety capacity / demand (e.g. yield strength / stress), and the margin of safety capacity / (required × demand) − 1, which passes when it is ≥ 0.
- `engineering.second_order_system(natural_frequency: Quantity[rad/s] = '1.0 rad / s', damping_ratio: float = 0.5, gain: float = 1.0) -> (result: LTI)`: Second-Order System. The standard second-order system, K ωn² / (s² + 2ζωn s + ωn²): a mass on a spring and damper, an RLC circuit. Below ζ = 1 it overshoots.
- `engineering.section_properties(shape: Literal['rectangle', 'hollow rectangle', 'circle', 'tube', 'I-beam'] = 'rectangle', width: Quantity[mm] = '50.0 mm', height: Quantity[mm] = '100.0 mm', wall: Quantity[mm] = '5.0 mm', web: Quantity[mm] = '5.0 mm') -> (area: Quantity[mm^2], ixx: Quantity[mm^4], iyy: Quantity[mm^4], section_modulus: Quantity[mm^3], radius_of_gyration: Quantity[mm], summary: dict[str, float])`: Section Properties. Area, second moments of area, elastic section modulus (about x) and least radius of gyration.
- `engineering.series(a: LTI, b: LTI, c: LTI | None) -> (result: LTI)`: Series. Systems one after another: the signal goes through a, then b (then c). A controller, an actuator and a plant in series make the open loop L(s).
- `engineering.simulate(system: LTI, t: Quantity | NDArray[floating], u: NDArray[floating], input: str = '', output: str = '') -> (t: Quantity[s], y: NDArray[float64], plot: Figure, peak: float, final_value: float, summary: dict[str, float])`: Simulate. The response to any input signal u(t), from rest: a measured road profile, a set-point schedule, a sine sweep. ``t`` in seconds (a plain array counts as seconds), one value of u per time.
- `engineering.stability_margins(system: LTI, input: str = '', output: str = '') -> (gain_margin_db: float, phase_margin_deg: float, gain_crossover: Quantity[rad/s], phase_crossover: Quantity[rad/s], stability_margin: float, closed_loop_stable: bool, summary: dict[str, float])`: Stability Margins. How far an open loop L(s) is from instability once the loop is closed with unity negative feedback. The gain margin (dB) is how much the gain can rise, at the phase crossover (phase −180°); the phase margin (°) how much phase lag can be added, at the gain crossover (|L| = 1). Infinite when the curve never crosses. ``stability_margin`` is the closest the Nyquist curve comes to −1 (1 is far, 0 is unstable).
- `engineering.state_space(A: Quantity | NDArray[floating], B: Quantity | NDArray[floating], C: Quantity | NDArray[floating], D: Quantity | NDArray[floating] | None, sample_time: Quantity[s] = '0.0 s') -> (result: LTI)`: State Space. A system in state space: x' = A x + B u, y = C x + D u. A is n×n, B n×m, C p×n and D p×m (zero when not linked). A in 1/time is taken in 1/s; the others in consistent SI units.
- `engineering.step_response(system: LTI, duration: Quantity[s] = '0.0 s', points: int = 1000, input: str = '', output: str = '', settling: float = 2.0) -> (t: Quantity[s], y: NDArray[float64], plot: Figure, final_value: float, overshoot: float, rise_time: Quantity[s], settling_time: Quantity[s], peak_time: Quantity[s], stable: bool, summary: dict[str, float])`: Step Response. The response to a unit step: overshoot (%), 10–90 % rise time, settling time (within ``settling`` % of the final value for good), time of the peak and final value. An unstable system has no final value, overshoot or settling time (NaN). The system comes from Transfer Function, Feedback and the other builders (its coefficients used to be typed here).
- `engineering.thermal_expansion(length: Quantity[m], expansion: Quantity[1/K] = '1.2e-05 / K', temperature_change: Quantity[delta_degC] = '50.0 Δ°C') -> (result: Quantity[mm])`: Thermal Expansion. The change in length of a free bar, ΔL = α L ΔT.
- `engineering.to_db(ratio: float, kind: Literal['power', 'amplitude'] = 'power') -> (result: float)`: To dB. A ratio in decibels: 10 log10 of a power ratio, 20 log10 of an amplitude ratio. Use a ratio to 1 mW for dBm, to 1 W for dBW.
- `engineering.transfer_function(numerator: str = '1', denominator: str = '1, 0.8, 1', sample_time: Quantity[s] = '0.0 s') -> (result: LTI)`: Transfer Function. A transfer function G(s) = num(s) / den(s), typed as polynomial coefficients in descending powers of s: ``1, 2, 1`` is s² + 2s + 1. With a sample time it is a discrete G(z) instead.
- `engineering.transfer_function_expression(text: str = '10/(s*(s + 2))', values: SymbolValues | None, variable: str = 's') -> (result: LTI)`: Transfer Function (Expression). A transfer function typed as an expression in s, with other symbols taken from ``values`` (as plain numbers in SI units), such as ``K/(tau*s + 1)`` with K and tau from a Values node.
- `engineering.von_mises(sigma_x: Quantity[MPa], sigma_y: Quantity[MPa] = '0.0 MPa', tau_xy: Quantity[MPa] = '0.0 MPa') -> (result: Quantity[MPa])`: Von Mises Stress. The equivalent (von Mises) stress of a plane stress state, to compare with the yield strength: √(σx² − σx σy + σy² + 3 τxy²).
- `engineering.wall_conduction(conductivity: Quantity[W/(m*K)], thickness: Quantity[mm] = '100.0 mm', area: Quantity[m^2] = '1.0 m ** 2', temperature_difference: Quantity[delta_degC] = '20.0 Δ°C') -> (heat_flow: Quantity[W], resistance: Quantity[K/W])`: Wall Conduction. Heat flow through a plane wall, Q = k A ΔT / t, and its thermal resistance t / (k A) (add resistances in series for layered walls).
- `engineering.zero_pole_gain(zeros: str = '', poles: str = '-1, -2', gain: float = 1.0) -> (result: LTI)`: Zero-Pole-Gain. A transfer function from its zeros, poles and gain: G(s) = k (s − z₁)(s − z₂)… / ((s − p₁)(s − p₂)…). ``a ± bj`` is a complex pair.

## geo

- `geo.add_coordinates(data: GeoDataFrame, crs: str = 'EPSG:4326', x_name: str = 'lon', y_name: str = 'lat') -> (result: GeoDataFrame)`: Add Coordinates. Add each feature's x and y (of its centroid, for lines and polygons) as columns, for tables in reports or plots.
- `geo.as_features(table: DataFrame, crs: str = '') -> (result: GeoDataFrame)`: As Features. Features again after table nodes: the table nodes (Add Column, Filter Rows, Join Tables, ...) work on GeoDataFrames too, but say they return a plain table. This passes a GeoDataFrame through, or rebuilds one from a ``geometry`` column. Offered when such a table is linked into a features input.
- `geo.buffer(data: GeoDataFrame, distance: Quantity[m] = '100.0 m', merge: bool = False) -> (result: GeoDataFrame)`: Buffer. The area within a distance of each feature, e.g. a protection zone around a river. Computed in metres whatever the CRS; the result is in the input's CRS.
- `geo.centroids(data: GeoDataFrame) -> (result: GeoDataFrame)`: Centroids. The centre point of each feature (computed in metres, returned in the input's CRS).
- `geo.classify(raster: Raster, breaks: str = '0.1, 0.3, 0.6', labels: str = 'water, bare, grassland, forest', area_unit: Literal['km²', 'ha', 'm²'] = 'km²') -> (classes: Raster, areas: DataFrame)`: Classify. Group values into classes (e.g. NDVI into land cover), and the area of each class. A value equal to a break goes into the upper class.
- `geo.clip(data: GeoDataFrame, mask: GeoDataFrame) -> (result: GeoDataFrame)`: Clip. Cut features to the area of the mask polygons (points outside are dropped).
- `geo.count_in_polygons(polygons: GeoDataFrame, points: GeoDataFrame, name: str = 'count', value: str = '', aggregate: Literal['mean', 'sum', 'max', 'min', 'median'] = 'mean', density: bool = True) -> (result: GeoDataFrame)`: Count In Polygons. How many points fall in each polygon (earthquakes per region, samples per district), optionally with an aggregate of a point attribute and the density per km².
- `geo.dissolve(data: GeoDataFrame, by: str = '', aggregate: Literal['first', 'sum', 'mean', 'min', 'max', 'count'] = 'sum') -> (result: GeoDataFrame)`: Dissolve. Merge features into one per group, aggregating their numeric columns.
- `geo.distance_to_nearest(data: GeoDataFrame, targets: GeoDataFrame, name: str = 'distance_m') -> (result: GeoDataFrame)`: Distance To Nearest. Add the distance in metres from each feature to the nearest target feature (e.g. wells to the river), computed in a metric CRS.
- `geo.filter_features(data: GeoDataFrame, condition: str = '') -> (result: GeoDataFrame)`: Filter Features. Keep the features matching a condition on their attributes.
- `geo.gutenberg_richter(catalog: DataFrame, magnitude: str = 'magnitude', bin_width: float = 0.1, completeness: Literal['maximum curvature', 'fixed'] = 'maximum curvature', mc: float = 2.0, correction: float = 0.2) -> (b_value: float, b_error: float, a_value: float, completeness: float, events: int, table: DataFrame, plot: Figure, summary: dict[str, Any])`: Gutenberg-Richter. The magnitude–frequency distribution log₁₀ N(≥M) = a − b·M of a catalogue.
- `geo.hillshade(dem: Raster, azimuth: float = 315.0, altitude: float = 45.0, z_factor: float = 1.0) -> (result: Raster)`: Hillshade. Shaded relief, 0 (dark) to 1 (lit), as seen with the sun at the given direction and height: the classic map backdrop for terrain.
- `geo.idw_interpolation(points: GeoDataFrame, value: str = '', template: Raster | None, cell: float = 100.0, power: float = 2.0, neighbours: int = 12, padding: float = 0.0, name: str = '') -> (result: Raster)`: IDW Interpolation. Interpolate point measurements onto a grid by inverse distance weighting: each cell is the average of its nearest points, weighted by 1/distanceᵖ. Uses the template's grid if one is linked (to line up with other rasters), otherwise the points' extent. Points in longitude and latitude are projected to UTM first.
- `geo.map_plot(raster: Raster | None, hillshade: Raster | None, polygons: GeoDataFrame | None, lines: GeoDataFrame | None, points: GeoDataFrame | None, polygon_color: str = '', polygon_labels: str = '', point_color: str = '', point_labels: str = '', point_size: float = 18.0, size_by: str = '', raster_colormap: Literal['viridis', 'terrain', 'gist_earth', 'RdYlGn', 'YlGnBu', 'YlOrRd', 'magma', 'Blues', 'coolwarm', 'Greys'] = 'viridis', class_colors: str = '', point_colormap: Literal['viridis', 'plasma', 'YlOrRd', 'RdYlBu_r', 'coolwarm', 'magma'] = 'YlOrRd', raster_label: str = '', point_label: str = '', title: str = '', scale_bar: bool = True, north_arrow: bool = True, width: float = 6.5) -> (result: Figure)`: Map Plot. A map from any mix of layers, drawn bottom to top: hillshade, raster, polygons, lines, points. Layers are projected to the CRS of the first of raster, polygons, lines and points. Classified rasters get a legend, other rasters and numeric point colours a colour bar; the default class colours suit land cover (water, bare, grassland, forest), ``class_colors`` sets others.
- `geo.mask_raster(raster: Raster, polygons: GeoDataFrame, invert: bool = False) -> (result: Raster)`: Mask Raster. Keep the cells inside the polygons (or outside, inverted); the rest become missing.
- `geo.measure(data: GeoDataFrame, area_unit: Literal['km²', 'ha', 'm²'] = 'km²', length_unit: Literal['km', 'm'] = 'km') -> (result: GeoDataFrame)`: Measure. Add each feature's area and perimeter (polygons) or length (lines) as columns. Longitude/latitude data is measured on the ellipsoid (geodesic), projected data in its own units (metres).
- `geo.normalized_difference(a: Raster, b: Raster, name: str = 'NDVI') -> (result: Raster)`: Normalized Difference. (A − B) / (A + B), between −1 and 1: with A = near infrared and B = red, the vegetation index NDVI; with green and NIR, the water index NDWI.
- `geo.point_density(points: GeoDataFrame, template: Raster | None, cell: float = 1000.0, bandwidth: float = 5000.0, padding: float = 10000.0, weight: str = '') -> (result: Raster)`: Point Density. Kernel density: how many points (or how much weight) per km², smoothed with a Gaussian of the given bandwidth. Hot spots of events such as earthquakes or observations.
- `geo.points_from_table(table: DataFrame, x: str = 'lon', y: str = 'lat', crs: str = 'EPSG:4326') -> (result: GeoDataFrame)`: Points From Table. Make point features from two coordinate columns, e.g. station or sample locations in a CSV. Rows without coordinates are dropped.
- `geo.raster_math(a: Raster, b: Raster | None, value: float = 1.0, operation: Literal['add', 'subtract', 'multiply', 'divide', 'power', 'minimum', 'maximum', 'greater than', 'less than', 'equal'] = 'multiply', name: str = '') -> (result: Raster)`: Raster Math. Cell-by-cell arithmetic between two rasters on the same grid, or a raster and a number. Comparisons give 1 (true) and 0 (false).
- `geo.raster_statistics(raster: Raster) -> (mean: float, std: float, minimum: float, maximum: float, valid_cells: int, area_km2: float, summary: dict[str, Any])`: Raster Statistics. Summary statistics of the valid cells, and the area they cover (km², for rasters in metres).
- `geo.raster_values(raster: Raster) -> (result: NDArray[float64])`: Raster Values. The raster's valid (non-missing) values as a flat array, for histograms and statistics. Inserted when a raster is linked into an array input.
- `geo.read_ascii_grid(path: FileRef, crs: str = '', name: str = '') -> (result: Raster)`: Read ASCII Grid. Read an ESRI ASCII grid (.asc), the plain-text raster format most GIS programs export. The CRS comes from a .prj file with the same name, or is given here. No-data cells become NaN.
- `geo.read_raster(path: FileRef, band: int = 1, name: str = '') -> (result: Raster)`: Read Raster. Read one band of a GeoTIFF, or of any raster GDAL reads (.vrt, .img, .jp2, NetCDF...), as a grid of floats. No-data cells become NaN, and the CRS comes from the file.
- `geo.read_vector(path: FileRef, layer: str = '') -> (result: GeoDataFrame)`: Read Vector File. Read GeoJSON, GeoPackage, Shapefile or FlatGeobuf features, from the workspace or remote storage (downloaded once and cached).
- `geo.reclassify(classes: Raster, values: str = '', name: str = '') -> (result: Raster)`: Reclassify. Give each class of a classified raster a number, such as a runoff coefficient or a roughness per land-cover class.
- `geo.reproject(data: GeoDataFrame, crs: str = 'UTM') -> (result: GeoDataFrame)`: Reproject. Transform the coordinates to another CRS: UTM for metres, EPSG:4326 for longitude and latitude, or a national grid.
- `geo.sample_raster(points: GeoDataFrame, raster: Raster, name: str = '') -> (result: GeoDataFrame)`: Sample Raster. Add the raster value under each point as a column (the cell the point falls in; NaN outside the grid).
- `geo.save_ascii_grid(raster: Raster, filename: str = 'grid.asc', decimals: int = 3) -> (result: Path)`: Save ASCII Grid. Write a raster as an ESRI ASCII grid into this run's output folder, with a .prj file for its CRS.
- `geo.save_geotiff(raster: Raster, filename: str = 'raster.tif') -> (result: Path)`: Save GeoTIFF. Write a raster as a compressed GeoTIFF (float32, NaN as no-data) into this run's output folder.
- `geo.save_vector(data: GeoDataFrame, filename: str = 'features.geojson') -> (result: Path)`: Save Vector File. Write features into this run's output folder. The extension picks the format: .geojson, .gpkg (GeoPackage) or .fgb (FlatGeobuf).
- `geo.slope_aspect(dem: Raster, z_factor: float = 1.0) -> (slope: Raster, aspect: Raster)`: Slope & Aspect. Terrain slope in degrees (0 = flat) and aspect, the compass direction the slope faces (0 = north, 90 = east), from an elevation model in a projected CRS.
- `geo.spatial_join(left: GeoDataFrame, right: GeoDataFrame, relation: Literal['within', 'intersects', 'contains', 'nearest'] = 'within', keep: Literal['all left features', 'matches only'] = 'all left features', max_distance: Quantity[m] = '0.0 m') -> (result: GeoDataFrame)`: Spatial Join. Attach the attributes of ``right`` to each feature of ``left`` by their spatial relation: the district each well lies within, or the nearest river (with the distance in metres in ``distance_m``).
- `geo.track_statistics(table: DataFrame, time: str = 'time', lat: str = 'lat', lon: str = 'lon', elevation: str = 'elevation', stopped_below: float = 1.0, smoothing: int = 5) -> (points: DataFrame, line: GeoDataFrame, distance: Quantity[km], ascent: Quantity[m], descent: Quantity[m], moving_time: Quantity[h], total_time: Quantity[h], moving_speed: Quantity[km/h], summary: dict[str, Any])`: Track Statistics. Analyse a GPS log (one row per fix, in time order): the geodesic distance on the WGS84 ellipsoid, speed, grade, total ascent and descent (from lightly smoothed elevation, so GPS noise does not add up), and moving time.
- `geo.zonal_statistics(zones: GeoDataFrame, raster: Raster, prefix: str = '', threshold: float = 0.0, share_above: bool = False, categories: bool = False) -> (result: GeoDataFrame)`: Zonal Statistics. Summarise a raster inside each polygon: mean, min, max, standard deviation, and the area covered (km²); optionally the share above a threshold (e.g. of an interpolated concentration over a limit), or, for a classified raster, the share of each class. A cell belongs to a zone when its centre is inside.

## maths

- `maths.add_noise(x: NDArray[floating], sigma: float = 0.1, seed: int = 0) -> (result: NDArray[floating])`: Add Noise. Add Gaussian noise. A fixed seed keeps runs reproducible (and cacheable).
- `maths.apply_function(x: NDArray[floating], function: Literal['sin', 'cos', 'exp', 'log', 'log10', 'sqrt', 'abs', 'gaussian'] = 'sin') -> (result: NDArray[floating])`: Apply Function. Apply a common function element-wise.
- `maths.array_math(a: NDArray[floating], b: float | NDArray[floating] = 1.0, operation: Literal['add', 'subtract', 'multiply', 'divide', 'power'] = 'multiply') -> (result: NDArray[floating])`: Array Math. Element-wise math between an array and a scalar or another array.
- `maths.clip_values(x: NDArray[number], minimum: float = 0.0, maximum: float = 1.0) -> (result: NDArray[floating])`: Clip Values. Limit values to [minimum, maximum].
- `maths.cumulative_sum(x: NDArray[number]) -> (result: NDArray[floating])`: Cumulative Sum. Running total of an array (NaNs count as zero).
- `maths.curve_fit(x: NDArray[number], y: NDArray[number], model: Literal['linear', 'quadratic', 'exponential decay', 'exponential growth', 'gaussian', 'logistic', 'power law', 'michaelis-menten', 'damped sine'] = 'exponential decay', initial: str = '', sigma: NDArray[number] | None, confidence: float = 0.95, curve_points: int = 300) -> (parameters: DataFrame, values: dict[str, float], fitted: NDArray[float64], residuals: NDArray[float64], r_squared: float, rmse: float, curve_x: NDArray[float64], curve_y: NDArray[float64], equation: str, summary: dict[str, Any])`: Curve Fit. Non-linear least squares fit of a standard model (scipy.optimize.curve_fit).
- `maths.derivative(x: NDArray[number], y: NDArray[number]) -> (result: NDArray[floating])`: Derivative. dy/dx by central differences (numpy.gradient), for uneven spacing too.
- `maths.determinant(matrix: Quantity | NDArray[floating]) -> (result: Quantity)`: Determinant. The determinant, in the matrix's unit to the power of its size. Zero means the matrix is singular: its equations are not independent.
- `maths.diagonal_matrix(diagonal: str = '1, 1', unit: str = 'kg', values: Quantity | NDArray[floating] | None) -> (result: Quantity)`: Diagonal Matrix. A matrix with ``diagonal`` on its diagonal and zeros elsewhere, such as the mass matrix of masses on springs. A linked vector replaces the text.
- `maths.eigenvalues(a: Quantity | NDArray[floating], b: Quantity | NDArray[floating] | None, sort: Literal['ascending', 'descending', 'as computed'] = 'ascending') -> (values: Quantity, vectors: NDArray[number], real: bool, symmetric: bool, summary: dict[str, float])`: Eigenvalues. The eigenvalues λ and eigenvectors v of A (A v = λ v), or of A against B (A v = λ B v). λ is in A's unit over B's. Column j of ``vectors`` belongs to value j, scaled to length 1.
- `maths.element(matrix: Quantity | NDArray[floating], row: int = 1, column: int = 1) -> (result: Quantity)`: Element. One entry of a matrix or vector, with its unit. Rows and columns are numbered from 1, so row 2 of a solution vector is x2.
- `maths.fourier_transform(signal: Quantity | NDArray[number], time: Quantity | NDArray[floating] | None, sample_spacing: Quantity = '1.0 s', window: Literal['hann', 'hamming', 'blackman', 'flattop', 'none'] = 'hann', detrend: Literal['mean', 'linear', 'none'] = 'mean', padding: int = 1, sides: Literal['one-sided', 'two-sided'] = 'one-sided', scaling: Literal['amplitude', 'rms', 'raw'] = 'amplitude', peaks: int = 3) -> (frequency: Quantity, amplitude: Quantity, spectrum: NDArray[complex128], phase: NDArray[float64], plot: Figure, peak_frequencies: Quantity, dominant_frequency: Quantity, dominant_amplitude: Quantity, resolution: Quantity, summary: dict[str, float])`: Fourier Transform. The frequencies in a signal, with the discrete Fourier transform (NumPy's FFT). ``amplitude`` is in the signal's unit, scaled so that a sine of amplitude a reads a at its frequency (with a window, exactly so only at a bin; flattop reads amplitudes best, hann separates peaks best). ``spectrum`` is the complex DFT itself, for Inverse Fourier Transform.
- `maths.heatmap(table: DataFrame, colormap: Literal['viridis', 'plasma', 'cividis', 'magma', 'coolwarm', 'RdBu_r', 'YlOrRd', 'Blues'] = 'RdBu_r', annotate: bool = True, symmetric: bool = True, title: str = '', colorbar_label: str = '') -> (result: Figure)`: Heatmap. A matrix as coloured cells, such as a correlation matrix. A first text column names the rows; the other columns must be numeric.
- `maths.histogram_plot(x: NDArray[number], bins: int = 30, normal_curve: bool = False, title: str = '', x_label: str = 'value', log_y: bool = False) -> (result: Figure)`: Histogram Plot. Distribution of values, optionally with the normal curve of the same mean and standard deviation for comparison.
- `maths.identity_matrix(size: int = 2, unit: str = '') -> (result: Quantity)`: Identity Matrix. The identity matrix: ones on the diagonal, zeros elsewhere.
- `maths.integrate(x: NDArray[number], y: NDArray[number]) -> (total: float, cumulative: NDArray[float64])`: Integrate. The area under y(x) by the trapezoidal rule, and its running total (e.g. rainfall rate to accumulated rain, velocity to distance).
- `maths.interpolate(x: NDArray[number], y: NDArray[number], new_x: NDArray[number], method: Literal['linear', 'cubic', 'nearest'] = 'linear') -> (result: NDArray[floating])`: Interpolate. Values of y at new positions: resample onto another grid, or fill gaps. Positions outside the data give NaN.
- `maths.inverse(matrix: Quantity | NDArray[floating]) -> (result: Quantity)`: Inverse. The inverse A⁻¹, in the reciprocal unit: the inverse of a stiffness matrix (N/m) is a flexibility matrix (m/N). To solve A x = b, Solve Linear System is more accurate than multiplying by the inverse.
- `maths.inverse_fourier_transform(spectrum: NDArray[complexfloating], frequency: Quantity, samples: int = 0) -> (time: Quantity, signal: NDArray[float64], imaginary: NDArray[float64])`: Inverse Fourier Transform. The signal back from its complex spectrum (the ``spectrum`` of Fourier Transform, perhaps filtered on the way). One-sided or two-sided is told from the frequencies. The round trip is exact with window none and detrend none; otherwise the result is the windowed, detrended signal. With a one-sided spectrum of an odd-length signal, give ``samples``.
- `maths.inverse_prediction(x: NDArray[number], y: NDArray[number], samples: DataFrame, response: str = '', replicates: int = 1, confidence: float = 0.95) -> (results: DataFrame, lod: float, loq: float, summary: dict[str, Any])`: Inverse Prediction. Calibration: fit the standards (x = known amount, y = signal) with a straight line, then estimate the amount in each sample from its signal.
- `maths.linear_regression(x: NDArray[number], y: NDArray[number], through_origin: bool = False, confidence: float = 0.95) -> (slope: float, intercept: float, slope_se: float, intercept_se: float, r_squared: float, p_value: float, residual_std: float, n: int, fitted: NDArray[float64], residuals: NDArray[float64], summary: dict[str, Any])`: Linear Regression. Ordinary least squares y = slope·x + intercept, with standard errors, R², the p-value of the slope and the residual standard deviation. Pairs with a NaN are left out. ``through_origin`` fixes the intercept at zero.
- `maths.linspace(start: float = 0.0, stop: float = 10.0, num: int = 200) -> (result: NDArray[float64])`: Linspace. Evenly spaced numbers over an interval.
- `maths.matrix(text: str = '2, -1; -1, 2', unit: str = '') -> (result: Quantity)`: Matrix. A matrix typed as text, MATLAB style: ``2, -1; -1, 2`` is a 2×2 matrix, ``10; 0`` a column. Every entry has the one ``unit``.
- `maths.matrix_multiply(a: Quantity | NDArray[floating], b: Quantity | NDArray[floating]) -> (result: Quantity)`: Matrix Multiply. The matrix product A B, with the units multiplied too: a stiffness matrix times a displacement vector is a force vector. A vector counts as a column. For element-by-element products use Quantity Math.
- `maths.natural_frequencies(stiffness: Quantity | NDArray[floating], mass: Quantity | NDArray[floating], normalise: Literal['largest = 1', 'mass'] = 'largest = 1') -> (frequency: Quantity[Hz], angular_frequency: Quantity[rad/s], shapes: NDArray[float64], first: Quantity[Hz], plot: Figure, summary: dict[str, float])`: Natural Frequencies. The natural frequencies and mode shapes of an undamped system of masses and springs, from its stiffness matrix K and mass matrix M: the solutions of K φ = ω² M φ. Frequencies are in ascending order, and column j of ``shapes`` is mode j, the way the masses move at that frequency.
- `maths.polynomial_fit(x: NDArray[number], y: NDArray[number], degree: int = 1) -> (coefficients: NDArray[float64], fitted: NDArray[float64], r_squared: float)`: Polynomial Fit. Least-squares polynomial fit, highest power first. Pairs with a NaN are left out; ``fitted`` has a value for every x.
- `maths.power_spectrum(signal: Quantity | NDArray[number], time: Quantity | NDArray[floating] | None, sample_spacing: Quantity = '1.0 s', method: Literal['welch', 'periodogram'] = 'welch', window: Literal['hann', 'hamming', 'blackman', 'flattop', 'none'] = 'hann', segment: int = 1024, scaling: Literal['density', 'spectrum'] = 'density') -> (frequency: Quantity, psd: Quantity, plot: Figure, rms: Quantity, dominant_frequency: Quantity, summary: dict[str, float])`: Power Spectrum. How the power of a signal is spread over frequency: its power spectral density (unit² per Hz), or with ``spectrum`` scaling the power of each tone (unit²). ``rms`` is the signal's root mean square about its mean, from the whole spectrum (Parseval).
- `maths.save_figure(figure: Figure, filename: str = 'figure.png', dpi: int = 200) -> (result: Path)`: Save Figure. Write a figure into this run's output folder. The extension picks the format: .png, .svg or .pdf.
- `maths.solve_linear(a: Quantity | NDArray[floating], b: Quantity | NDArray[floating], unit: str = '') -> (x: Quantity, residual: Quantity, condition: float, rank: int, method: str, summary: dict[str, float])`: Solve Linear System. Solve A x = b for x, such as K x = F for the displacements of a structure. x is in b's unit over A's (N over N/m is m), and has b's shape.
- `maths.statistics(x: NDArray[number], ddof: int = 1) -> (mean: float, std: float, minimum: float, maximum: float, total: float, count: int, summary: dict[str, float])`: Statistics. Summary statistics of an array, ignoring NaNs: mean, standard deviation, minimum, maximum, sum (``total``) and the number of values.
- `maths.transpose(matrix: Quantity | NDArray[floating]) -> (result: Quantity)`: Transpose. Rows become columns. A vector (a column) becomes a row, 1×n.
- `maths.xy_plot(x: NDArray[number], y: NDArray[number], y2: NDArray[number] | None, x2: NDArray[number] | None, error: NDArray[number] | None, style: Literal['line', 'scatter', 'scatter + line'] = 'line', title: str = '', x_label: str = 'x', y_label: str = 'y', label: str = 'data', label2: str = 'fit', log_x: bool = False, log_y: bool = False, style2: Literal['line', 'markers'] = 'line') -> (result: Figure)`: XY Plot. Plot y (and optionally y2, e.g. a fitted curve or marked peaks) against x.

## report

- `report.add_compliance_matrix(report: ReportDoc, verifications: Verifications, caption: str = 'Requirements compliance', summary: bool = True) -> (result: ReportDoc)`: Add Compliance Matrix. Add a table of verified requirements (the ``log`` of a chain of Verify Requirement nodes): each one's ID, statement, the required and achieved values, the margin, the verification method and whether it was met (or, for an uncertain value whose margin is within its uncertainty, is inconclusive).
- `report.add_data_sources(report: ReportDoc, heading: str = 'Data sources') -> (result: ReportDoc)`: Add Data Sources. List where the report's data came from: every file read, and the sources, licences, citations and quality notes given with Describe Data, gathered from everything put into the report before this node.
- `report.add_equation(report: ReportDoc, math: str = 'E = m c^2', where: str = '', numbered: bool = True, markup: bool = True) -> (result: ReportDoc)`: Add Equation. Add a displayed equation, numbered (1), (2), ... See the Typst math reference: sub- and superscripts with _ and ^, fractions with /, Greek letters by name (alpha, sigma), sqrt(x), sum_(i=1)^n.
- `report.add_figure(report: ReportDoc, figure: Figure, caption: str = '', width: int = 90) -> (result: ReportDoc)`: Add Figure. Add a Matplotlib figure (embedded as vector SVG).
- `report.add_heading(report: ReportDoc, text: str = 'Results', level: int = 1) -> (result: ReportDoc)`: Add Heading. 
- `report.add_image(report: ReportDoc, path: FileRef, caption: str = '', width: int = 80) -> (result: ReportDoc)`: Add Image. Add a PNG, JPEG, GIF or SVG image from a file, such as a photo of the setup, a logo, or a figure saved by an earlier node.
- `report.add_key_values(report: ReportDoc, data: Any, caption: str = '', digits: int = 4) -> (result: ReportDoc)`: Add Key Values. Add a compact label / value / unit table: from a dict (such as the ``summary`` output of the analysis nodes; quantities keep their units), a Series, or a table whose columns are label, value and optionally unit.
- `report.add_list(report: ReportDoc, items: str = '', numbered: bool = False, markup: bool = False) -> (result: ReportDoc)`: Add List. Add a bulleted or numbered list, one item per line (a leading "-", "*" or "1." is removed).
- `report.add_note(report: ReportDoc, text: str = '', style: Literal['note', 'tip', 'warning', 'important'] = 'note', title: str = '', markup: bool = False) -> (result: ReportDoc)`: Add Note. Add a highlighted box: a note, tip, warning or important remark.
- `report.add_page_break(report: ReportDoc) -> (result: ReportDoc)`: Add Page Break. Start a new page (not at the top of an already empty page).
- `report.add_requirements(report: ReportDoc, requirements: RequirementSet, caption: str = 'Requirements') -> (result: ReportDoc)`: Add Requirements. Add a table of requirements: each one's ID, the quantity it constrains, what is required, how it is verified and its statement.
- `report.add_run_details(report: ReportDoc, title: str = 'Reproducibility', appendix: bool = True, nodes: bool = True, files: bool = True) -> (result: ReportDoc)`: Add Run Details. Add a section describing the run that made this report: when and by whom, the software versions, the input files with their SHA-256 checksums, and every processing step with its settings. The full record is in provenance.json, next to the PDF in the run folder. Runs every time, so the details always describe the current run.
- `report.add_table(report: ReportDoc, data: DataFrame | GeoDataFrame | dict, caption: str = '', max_rows: int = 50, columns: str = '', digits: int = 4) -> (result: ReportDoc)`: Add Table. Add a table from a DataFrame, a GeoDataFrame (without its geometry) or a dict: of columns (``{"x": [...], "y": [...]}``), or of single values, such as the ``summary`` output of the analysis nodes, shown as a name / value table. Longer tables are cut at ``max_rows``, with a note saying so.
- `report.add_text(report: ReportDoc, text: str = '', markup: bool = False) -> (result: ReportDoc)`: Add Text. Add a paragraph. Connect a Format Text or Format Values node to include computed values.
- `report.add_value(report: ReportDoc, label: str = 'Value', value: Any, unit: str = '', digits: int = 4, coverage: float = 1.0) -> (result: ReportDoc)`: Add Value. Add a highlighted key result, e.g. "R²: 0.998". A quantity is shown in ``unit`` if one is given, otherwise in its own unit. A value with an uncertainty is shown as "9.806 ± 0.012", rounded to its uncertainty.
- `report.new_report(title: str = 'Analysis report', author: str = '', subtitle: str = '', abstract: str = '', date: str = '', contents: bool = False) -> (result: ReportDoc)`: New Report. Start a report. Chain Add nodes after it, then Render PDF.
- `report.render_report(report: ReportDoc, filename: str = 'report.pdf', template: str = 'default', timestamp: bool = True) -> (result: Path)`: Render PDF. Typeset the report with Typst into this run's output folder. With timestamp on, each run's PDF has its own name, so copies never clash.

## requirements

- `requirements.check_candidates(table: DataFrame, requirements: RequirementSet, only: str = '') -> (table: DataFrame, compliant: DataFrame)`: Check Candidates. Score candidate designs, one per row, against the requirements whose quantity is a column of the table. Adds a ``<ID> margin`` column for each, and ``compliant``: whether the row meets them all. ``compliant`` (the output) keeps only those rows, ready to sort and pick from.
- `requirements.load_requirements(path: FileRef) -> (result: RequirementSet)`: Load Requirements. Read requirements from a specification file: a CSV (or a YAML or JSON list) with the columns id, quantity, op, limit and optionally unit, upper, tolerance, text, method, priority and parent.
- `requirements.merge_requirements(a: RequirementSet, b: RequirementSet) -> (result: RequirementSet)`: Merge Requirements. One set from two, such as system and subsystem requirements. An ID may appear only once.
- `requirements.requirement_value(requirements: RequirementSet, id: str = '') -> (result: Any)`: Requirement Value. A requirement's limit, to design with: a quantity when it has a unit (for a range, the lower limit). The required data rate, say, sets the bandwidth the link needs.
- `requirements.requirements(text: str = 'REQ-001 margin >= 3 dB  # The design shall have 3 dB of margin') -> (result: RequirementSet)`: Requirements. Write down what the design must achieve, one requirement per line:
- `requirements.verify_requirement(requirements: RequirementSet, value: Any, id: str = '', log: Verifications | None) -> (value: Any, check: Check, log: Verifications)`: Verify Requirement. Check a result against a requirement. The check passes when the value is within the limit (every element of an array: the margin is then the worst case) and reports the margin, how far inside the limit it is in the requirement's unit. A failed requirement does not stop the run: it marks the node, is listed in the Problems tab and fails ``noodlelab test``. Chain ``log`` from node to node and put it in a report with Add Compliance Matrix.

## science

- `science.add_column(table: DataFrame, name: str = 'result', expression: str = '') -> (result: DataFrame)`: Add Column. Compute a new column from others (pandas ``DataFrame.eval``). Supports + - * / ** , comparisons, pi and sqrt, exp, log, log10, sin, cos, tan and abs; put names with spaces in `back quotes`. Attribute access, indexing and other Python are refused. When columns have units, the arithmetic is done with them: the new column gets its unit, and adding metres to seconds is an error.
- `science.bar_chart(table: DataFrame, category: str = '', value: str = '', error: str = '', horizontal: bool = False, title: str = '', y_label: str = '', color_by_sign: bool = False) -> (result: Figure)`: Bar Chart. One bar per row: a value per category, with optional error bars (e.g. mean ± standard error from Group Summary).
- `science.bootstrap_ci(x: NDArray[number], statistic: Literal['mean', 'median', 'std'] = 'mean', confidence: float = 0.95, resamples: int = 5000, seed: int = 0) -> (estimate: float, low: float, high: float)`: Bootstrap CI. A confidence interval for a statistic by resampling (percentile-BCa, scipy.stats.bootstrap), with no assumption about the distribution.
- `science.box_plot(table: DataFrame, value: str = '', group: str = '', show_points: bool = True, title: str = '', y_label: str = '') -> (result: Figure)`: Box Plot. Distribution per group: the box spans the quartiles, the line is the median, whiskers reach 1.5 IQR; individual values are drawn on top.
- `science.butterworth_filter(y: NDArray[number], sample_rate: float = 100.0, kind: Literal['lowpass', 'highpass', 'bandpass', 'bandstop'] = 'lowpass', cutoff: float = 10.0, cutoff_high: float = 20.0, order: int = 4) -> (result: NDArray[floating])`: Butterworth Filter. A Butterworth filter applied forwards and backwards (zero phase shift, scipy.signal.sosfiltfilt): keep frequencies below, above, between or outside the cutoffs.
- `science.climatology(table: DataFrame, time: str = 'date', value: str = '', period: Literal['month', 'season', 'day of year'] = 'month', baseline_start: int = 0, baseline_end: int = 0) -> (climatology: DataFrame, anomalies: DataFrame)`: Climatology. The typical value for each month (or season, or day of the year) over a baseline period, and each row's anomaly: its departure from that normal.
- `science.column_as_text(table: DataFrame, column: str = '', separator: str = ', ', max_items: int = 0, format: str = '') -> (result: str)`: Column As Text. The values of a column joined into one text, e.g. the names of the top three samples for a report sentence.
- `science.compare_groups(table: DataFrame, value: str = '', group: str = '', test: Literal['anova', 'kruskal-wallis'] = 'anova', alpha: float = 0.05) -> (groups: DataFrame, pairs: DataFrame, statistic: float, p_value: float, effect_size: float, summary: dict[str, Any])`: Compare Groups. Do the groups differ? One-way ANOVA (or the rank-based Kruskal-Wallis test) across all groups, then every pair: Welch's t-test (or Mann-Whitney U), with Holm's correction for the number of comparisons.
- `science.correlation_matrix(table: DataFrame, method: Literal['pearson', 'spearman', 'kendall'] = 'pearson', columns: str = '') -> (result: DataFrame)`: Correlation Matrix. Pairwise correlation coefficients between numeric columns. The first column, ``variable``, names the rows (Heatmap uses it as labels).
- `science.describe(table: DataFrame) -> (result: DataFrame)`: Describe. Descriptive statistics per column.
- `science.describe_columns(table: DataFrame, descriptions: str = '') -> (result: DataFrame)`: Describe Columns. Say what each column holds. The descriptions travel with the table and show in the inspector.
- `science.detect_outliers(table: DataFrame, column: str = '', method: Literal['iqr', 'z-score', 'mad'] = 'iqr', threshold: float = 1.5) -> (flagged: DataFrame, clean: DataFrame, count: int)`: Detect Outliers. Flag unusual values in a column. ``iqr``: outside [Q1 − t·IQR, Q3 + t·IQR] (t = 1.5 is Tukey's rule); ``z-score``: more than t standard deviations from the mean; ``mad``: robust z-score from the median absolute deviation. Outputs the table with an ``outlier`` column, the table without them, and how many there were.
- `science.detrend(y: NDArray[number], kind: Literal['linear', 'constant'] = 'linear') -> (result: NDArray[floating])`: Detrend. Remove a straight-line trend (or only the mean, ``constant``), such as sensor drift, before computing spectra.
- `science.drop_missing(table: DataFrame, columns: str = '') -> (result: DataFrame)`: Drop Missing. Remove rows with missing values (NaN or empty).
- `science.envelope(y: NDArray[number]) -> (result: NDArray[floating])`: Envelope. The amplitude envelope (magnitude of the analytic signal, via the Hilbert transform). The spectrum of the envelope of a band-passed signal reveals how often impacts repeat: the classic bearing-fault diagnosis.
- `science.fft(y: NDArray[number], sample_spacing: float = 1.0) -> (frequency: NDArray[float64], amplitude: NDArray[float64])`: Fft. One-sided amplitude spectrum of a real signal. Fourier Transform (in Math/Fourier) does more: units, phase, windows, padding and the inverse.
- `science.files_design(folder: Path = 'data', pattern: str = '*.csv') -> (result: DataFrame)`: Files Design. One row per file matching the pattern, so a Sweep zone processes each file in turn (column ``file``, and ``name`` without folder or suffix).
- `science.filter_rows(table: DataFrame, condition: str = '') -> (result: DataFrame)`: Filter Rows. Keep the rows matching a condition (pandas ``DataFrame.query``). The index is renumbered. The condition may use column names, constants, lists of constants (``region in ["North", "South"]``), arithmetic, comparisons, ``and``/``or``/``not`` and sqrt, exp, log, log10, sin, cos, tan and abs; attribute access, indexing and other Python are refused.
- `science.find_peaks(x: NDArray[number], y: NDArray[number], min_prominence: float = 0.0, min_distance: int = 1, max_peaks: int = 0) -> (table: DataFrame, positions: NDArray[float64], heights: NDArray[float64], count: int)`: Find Peaks. Local maxima of y (scipy.signal.find_peaks), as a table with position, height, prominence and width at half prominence (in x units), sorted by prominence, largest first.
- `science.get_column(table: DataFrame, column: str) -> (result: NDArray)`: Get Column. One column of a table as an array. Its type follows the column: numbers (NDArray[float64], or int64, which links anywhere floats do), text (NDArray[str_]), dates (NDArray[datetime64]) or true/false (NDArray[bool_]).
- `science.get_quantity_column(table: DataFrame, column: str, unit: str = '') -> (result: Quantity)`: Get Quantity Column. One numeric column as an array with its unit (see Set Column Units), so what follows converts and checks units.
- `science.grid_design(parameters: str = 'g = 9.7 .. 9.9\nL = 0.9 .. 1.1 m', levels: int = 5) -> (result: DataFrame)`: Grid Design. Every combination of evenly spaced values: ``levels`` per range (a normal parameter spans mean ± 2 sd), and each listed value.
- `science.group_summary(table: DataFrame, by: str = '', value: str = '', statistics: str = 'count, mean, std, sem, min, max') -> (result: DataFrame)`: Group Summary. Summary statistics of one column for each group: one row per group, in the order the groups first appear. ``ci95`` is the half-width of the 95 % confidence interval of the mean (Student's t).
- `science.join_tables(left: DataFrame, right: DataFrame, on: str = '', how: Literal['left', 'inner', 'outer', 'right'] = 'left') -> (result: DataFrame)`: Join Tables. Combine two tables on a shared key column (a database join). Columns with the same name in both get the suffixes _left and _right.
- `science.latin_hypercube(parameters: str = 'g = 9.7 .. 9.9\nL = 0.9 .. 1.1 m', samples: int = 50, seed: int = 1) -> (result: DataFrame)`: Latin Hypercube. A space-filling random design: each parameter's range is cut into ``samples`` slices and each slice is used once (scipy.stats.qmc).
- `science.load_csv(path: FileRef, separator: str = ',', units: bool = True) -> (result: DataFrame)`: Load CSV. Read a CSV file into a table: from the workspace (relative paths) or from remote storage (e.g. lab-s3://2026/run1.csv). Headers such as "length [m]" give the column a unit, which it keeps through the table nodes.
- `science.lookup_value(table: DataFrame, key_column: str = '', key: str = '', value_column: str = '') -> (result: Any)`: Lookup Value. One cell: the value in ``value_column`` of the first row whose ``key_column`` equals ``key`` (compared as text), e.g. the mean yield of treatment "N120" from a Group Summary. Without a key column, the first row's value, e.g. after Sort Rows.
- `science.make_table(x: NDArray, y: NDArray, x_name: str = 'x', y_name: str = 'y') -> (result: DataFrame)`: Make Table. Combine two arrays of the same length (numbers, text or dates) into a two-column table.
- `science.morris_design(parameters: str = 'g = 9.7 .. 9.9\nL = 0.9 .. 1.1 m', trajectories: int = 20, levels: int = 4, seed: int = 1) -> (result: DataFrame)`: Morris Design. Morris elementary-effects screening (SALib): cheap, it tells which parameters matter at all. Runs (parameters + 1) × trajectories times. Analyse the sweep's results with Sensitivity Analysis.
- `science.parse_dates(table: DataFrame, column: str = 'date', format: str = '') -> (result: DataFrame)`: Parse Dates. Turn a text column into dates and times, for the time series nodes.
- `science.pivot_table(table: DataFrame, rows: str = '', columns: str = '', values: str = '', aggregate: Literal['mean', 'sum', 'count', 'min', 'max', 'median'] = 'mean') -> (result: DataFrame)`: Pivot Table. Cross-tabulate: one row per value of ``rows``, one column per value of ``columns``, cells aggregated from ``values``.
- `science.random_design(parameters: str = 'g = 9.7 .. 9.9\nk = normal(2, 0.1)', samples: int = 100, seed: int = 1) -> (result: DataFrame)`: Random Design. Parameter sets drawn at random: uniformly within ranges, from normal distributions, or among listed values.
- `science.rename_columns(table: DataFrame, mapping: str = '') -> (result: DataFrame)`: Rename Columns. Give columns readable names, e.g. before adding a table to a report.
- `science.resample(table: DataFrame, time: str = 'date', every: Literal['hour', 'day', 'week', 'month', 'quarter', 'year'] = 'month', aggregate: Literal['mean', 'sum', 'min', 'max', 'median', 'count'] = 'mean', columns: str = '', min_count: int = 1) -> (result: DataFrame)`: Resample. Aggregate to regular periods: daily to monthly means, hourly to daily sums. Each period is labelled by its start. Adds a ``count`` column with the number of values in each period.
- `science.residual_plot(x: NDArray[number], residuals: NDArray[number], title: str = 'Residuals', x_label: str = 'x', y_label: str = 'residual') -> (result: Figure)`: Residual Plot. Residuals of a fit against x, with the zero line and a ±2σ band: patterns (curvature, funnels) mean the model misses something.
- `science.rolling_window(table: DataFrame, column: str = '', window: int = 7, statistic: Literal['mean', 'median', 'sum', 'std', 'min', 'max'] = 'mean', center: bool = True, name: str = '') -> (result: DataFrame)`: Rolling Window. Add a moving-window statistic of a column, e.g. a 7-day mean or a 5-year running average. Windows at the ends use the values they have.
- `science.save_csv(table: DataFrame, filename: str = 'table.csv') -> (result: Path)`: Save CSV. Write a table into this run's output folder.
- `science.savgol_filter(y: NDArray[floating], window: int = 21, order: int = 3) -> (result: NDArray[floating])`: Savitzky-Golay Filter. Smooth a signal while preserving peak shape (scipy.signal.savgol_filter).
- `science.scatter_plot(table: DataFrame, x: str = '', y: str = '', color: str = '', fit_line: bool = False, colormap: Literal['viridis', 'plasma', 'cividis', 'magma', 'coolwarm', 'RdBu_r', 'YlOrRd', 'Blues'] = 'viridis', title: str = '', x_label: str = '', y_label: str = '') -> (result: Figure)`: Scatter Plot. Two columns of a table against each other, optionally coloured by a third (numbers get a colour bar, categories a legend) and with a least-squares line.
- `science.select_columns(table: DataFrame, columns: str = '') -> (result: DataFrame)`: Select Columns. Keep only some columns, in the given order.
- `science.sensitivity_analysis(results: DataFrame, output: str = '') -> (indices: DataFrame, chart: Any)`: Sensitivity Analysis. How much each parameter influences a result, from a Sweep zone over a Morris Design (μ*: mean absolute effect, σ: non-linearity and interactions) or a Sobol Design (S1: variance share alone, ST: with interactions, with 95 % confidence half-widths).
- `science.set_column_units(table: DataFrame, units: str = '') -> (result: DataFrame)`: Set Column Units. Give columns their units. They stay with the table through filtering, sorting, grouping and joining, show in previews and report tables as "length (cm)", and make the table's column pins quantities.
- `science.signal_statistics(y: NDArray[number]) -> (rms: float, peak: float, peak_to_peak: float, crest_factor: float, kurtosis: float, summary: dict[str, float])`: Signal Statistics. Root mean square, peak absolute value, peak-to-peak range, crest factor (peak / RMS: about 1.41 for a pure sine) and kurtosis (3 for Gaussian noise). Impacts, such as from a damaged bearing, raise the last two.
- `science.sobol_design(parameters: str = 'g = 9.7 .. 9.9\nL = 0.9 .. 1.1 m', base: int = 256, seed: int = 1) -> (result: DataFrame)`: Sobol Design. Sobol variance-based sensitivity (SALib, Saltelli's sampling): how much of the output's variance each parameter causes, alone (S1) and with its interactions (ST). Analyse the sweep's results with Sensitivity Analysis.
- `science.sort_rows(table: DataFrame, column: str = '', descending: bool = False, limit: int = 0) -> (result: DataFrame)`: Sort Rows. Sort by a column; with a limit, the top N rows (e.g. the largest events).
- `science.table_info(table: DataFrame) -> (rows: int, columns: int)`: Table Info. The number of rows and columns, e.g. for "n = 120" in a report.
- `science.time_series_plot(table: DataFrame, time: str = 'date', columns: str = '', style: Literal['line', 'bars', 'steps'] = 'line', title: str = '', y_label: str = '', zero_line: bool = False, color_by_sign: bool = False) -> (result: Figure)`: Time Series Plot. One or more columns against time; the first is drawn boldest.
- `science.trend_test(table: DataFrame, time: str = '', value: str = '', per: float = 10.0, alpha: float = 0.05) -> (slope: float, intercept: float, tau: float, p_value: float, trend: str, table: DataFrame, summary: dict[str, Any])`: Trend Test. Is there a monotonic trend? The Mann-Kendall test (non-parametric, robust to outliers) with Sen's slope, the median of all pairwise slopes, as is usual for climate and hydrology series. Dates are converted to decimal years, so with ``per`` = 10 the slope is per decade. The rows need not be in time order, and several values may share a time. ``table``: the input with Sen's line added as ``<value>_trend``, for plotting.
- `science.two_way_anova(table: DataFrame, value: str = '', factor_a: str = '', factor_b: str = '', interaction: bool = False) -> (table: DataFrame, p_a: float, p_b: float, summary: dict[str, Any])`: Two-Way ANOVA. Analysis of variance with two factors, such as treatment and block in a randomised block trial: does each factor explain variation once the other is accounted for? Type II sums of squares (valid for unbalanced data); partial η² as the effect size.
- `science.welch_psd(y: NDArray[number], sample_rate: float = 100.0, segment: int = 1024, scaling: Literal['density', 'spectrum'] = 'density') -> (frequency: NDArray[float64], power: NDArray[float64])`: Welch PSD. Power spectral density by Welch's method: the average spectrum of overlapping windowed segments, much less noisy than a single FFT. ``density`` gives units²/Hz, ``spectrum`` units² (for reading tone amplitudes).

## symbolic

- `symbolic.differentiate(expression: Expr, variable: str = 'x', order: int = 1) -> (result: Expr)`: Differentiate. The derivative with respect to ``variable``, ``order`` times.
- `symbolic.equation(text: str = "E*I*y''''(x) = -w", a: Expr | None, b: Expr | None, c: Expr | None, d: Expr | None) -> (result: Equality)`: Equation. An equation, ``lhs = rhs``, for Solve and Solve ODE. Primes are derivatives: ``y''(x)`` is d²y/dx². Without ``=``, the expression equals zero.
- `symbolic.equations(text: str = 'x + y = 3\nx - y = 1', a: Expr | None, b: Expr | None, c: Expr | None, d: Expr | None) -> (result: EquationSystem)`: Equations. Several equations, one per line, to be solved together with Solve System or Solve Numerically, or written as a matrix with Linear System: one balance equation per mass of a structure, say. Lines starting with ``#`` are comments.
- `symbolic.evaluate(expression: Expr, values: SymbolValues, unit: str = '') -> (result: Quantity)`: Evaluate. Put numbers with units into an expression. Units are carried through, so the result has the right dimension: a moment from kN/m and m comes out in kN·m. An array value (a quantity holding many numbers) gives an array.
- `symbolic.evaluate_matrix(matrix: Expr, values: SymbolValues, unit: str = '') -> (result: Quantity)`: Evaluate Matrix. Put numbers with units into a symbolic matrix, such as the stiffness matrix from Linear System, for the matrix nodes. A matrix has one unit for every entry: entries that come out in different units are an error (zeros fit any unit). Values with uncertainties are used at their nominal values, since a matrix carries none: use Monte Carlo to propagate them.
- `symbolic.evaluate_range(expression: Expr, values: SymbolValues, variable: str = 'x', start: str = '0', stop: str = 'L', points: int = 201, unit: str = '', variable_unit: str = '') -> (x: Quantity, value: Quantity, peak: Quantity, peak_at: Quantity, minimum: Quantity, maximum: Quantity)`: Evaluate Over Range. Evaluate an expression at evenly spaced values of one variable, such as the deflection along a beam. ``peak`` is the value largest in size (with its sign) and ``peak_at`` where it occurs. Array quantities plot through Magnitude (Array).
- `symbolic.expression(text: str = 'w*L^2/12', a: Expr | None, b: Expr | None, c: Expr | None, d: Expr | None) -> (result: Expr)`: Expression. A symbolic expression typed as text. Names that are not functions become symbols; ``{a}`` … ``{d}`` insert the linked expressions, so ``-E*I*diff({a}, x, 2)`` differentiates whatever is linked to a.
- `symbolic.integrate(expression: Expr, variable: str = 'x', lower: str = '', upper: str = '') -> (result: Expr)`: Integrate. The integral with respect to ``variable``: indefinite (without a constant), or between two limits, which may be symbols such as 0 and L.
- `symbolic.iterate(expression: Expr | Equality, values: SymbolValues | None, variable: str = 'x', method: Literal['fixed point', 'newton'] = 'fixed point', start: str = '1', digits: int = 10, max_iterations: int = 100, unit: str = '') -> (result: Quantity, iterations: int, converged: bool, step: NDArray[float64], history: NDArray[float64], change: NDArray[float64])`: Iterate. Repeat a step until the answer stops changing: a while loop in one node.
- `symbolic.linear_system(equations: EquationSystem | Equality, unknowns: str = 'x1, x2') -> (A: ImmutableDenseMatrix, b: ImmutableDenseMatrix, unknowns: list[str])`: Linear System. Write linear equations as a matrix equation, A x = b, with x the unknowns in the order given: the equilibrium of masses on springs gives the stiffness matrix K and the force vector F. Evaluate Matrix puts numbers into both, and Solve Linear System solves it.
- `symbolic.set_value(values: SymbolValues | None, name: str = 'x', value: Quantity | float = 0.0) -> (result: SymbolValues)`: Set Value. Add a linked value (a quantity or a number from elsewhere in the graph) to a set of values, under ``name``.
- `symbolic.simplify(expression: Expr, method: Literal['simplify', 'factor', 'expand', 'cancel', 'together', 'trigsimp'] = 'simplify') -> (result: Expr)`: Simplify. Rewrite an expression: simplify (SymPy's heuristics), factor, expand products and powers, cancel common factors, put over one denominator (together), or simplify trigonometric functions.
- `symbolic.solve(equation: Equality | Expr, unknown: str = 'x', pick: int = 0) -> (solution: Expr, solutions: list[Expr], count: int)`: Solve. Solve an equation (or ``expression = 0``) for one unknown, in symbols. ``solution`` is the solution numbered ``pick``, ``solutions`` all of them.
- `symbolic.solve_numeric(equations: EquationSystem | Equality, guesses: str = 'x = 1', values: SymbolValues | None, method: Literal['hybr', 'lm'] = 'hybr', max_iterations: int = 1000) -> (values: SymbolValues, solution: Quantity, converged: bool, residual: float, iterations: int, summary: dict[str, float])`: Solve Numerically. Solve equations, linear or not, numerically for the unknowns that have a guess, starting from those guesses (SciPy's root). Each unknown comes out in the unit of its guess. The other symbols take ``values``, whose uncertainties are propagated to the solutions (GUM).
- `symbolic.solve_ode(equation: Equality, function: str = 'y', variable: str = 'x', conditions: str = '') -> (result: Expr)`: Solve ODE. Solve an ordinary differential equation for ``function(variable)``. With enough conditions the constants of integration are found; without, they stay as C1, C2, ... The result is the right-hand side, y(x).
- `symbolic.solve_system(equations: EquationSystem | Equality, unknowns: str = 'x1, x2', unknown: str = '', pick: int = 0) -> (answers: EquationSystem, solution: Expr, solutions: ImmutableDenseMatrix, count: int)`: Solve System. Solve several equations together for several unknowns, in symbols. ``answers`` shows them all (x1 = …, x2 = …), ``solution`` is one of them for Evaluate, and ``solutions`` is the column of all of them, in the order of ``unknowns``. An unknown the equations leave free stays a symbol.
- `symbolic.substitute(expression: Expr, substitutions: str = 'x = L/2') -> (result: Expr)`: Substitute. Replace symbols by expressions: ``x = L/2`` gives the value at mid-span, still in symbols. To put in numbers with units, use Evaluate.
- `symbolic.to_math(expression: Expr | Equality | EquationSystem, left: str = '') -> (result: str)`: Expression To Math. Typst math for the report's Add Equation node: the expression as it would be typeset, optionally as ``left = expression``. A system of equations is typeset one equation per line.
- `symbolic.values(text: str = 'E = 200 GPa\nL = 6 m') -> (result: SymbolValues)`: Values. Numbers, usually with units, for the symbols of an expression. A value without a unit is a plain number. A value can also name a constant, with its unit and uncertainty: ``g = g0`` is standard gravity, and a line that is just a name, such as ``c``, means ``c = c``. Constants are looked up before units, so ``h`` is Planck's constant here, not an hour: write ``t = 1 h`` for an hour. A name that means something else as a unit is flagged with a warning.

## uncertainty

- `uncertainty.add_uncertainty(value: float | Quantity, uncertainty: float = 0.0, given_as: Literal['standard uncertainty', 'expanded, k = 2', 'half-width, rectangular', 'half-width, triangular', 'relative, %'] = 'standard uncertainty', name: str = '') -> (result: Uncertain)`: Add Uncertainty (Type B). Give a value an uncertainty from a certificate, a resolution or a tolerance (GUM Type B), in the value's unit. A half-width a becomes a/√3 for a rectangular distribution (a resolution, a tolerance) and a/√6 for a triangular one. On a value that is already uncertain, this adds another independent component.
- `uncertainty.expanded_uncertainty(value: Uncertain, k: float = 2.0) -> (expanded: Any, lower: Any, upper: Any)`: Expanded Uncertainty. The expanded uncertainty U = k·u and the interval x ± U (GUM 6). k = 2 covers about 95 % for a normal distribution.
- `uncertainty.mean_of_repeats(values: ndarray | list, name: str = '', unit: str = '') -> (mean: Uncertain, std: float, count: int)`: Mean of Repeats (Type A). The mean of repeated readings, with its standard uncertainty s/√n (GUM Type A, 4.2). ``std`` is the spread of single readings (s, n - 1).
- `uncertainty.measurement(value: Uncertain = '9.810 ± 0.020 m/s**2', name: str = '') -> (result: Uncertain)`: Measurement. A measured value with its standard uncertainty, typed as text: 9.81 ± 0.02 m/s^2, 9.81 +/- 0.02 or 9.81(2).
- `uncertainty.monte_carlo(value: Any, trials: int = 10000, coverage: float = 0.95, seed: int = 1) -> (result: Any, uncertainty: Any, lower: Any, upper: Any, samples: Any, histogram: Any, agrees_with_gum: bool)`: Monte Carlo. Propagate the distributions of the inputs by simulation (GUM Supplement 1, JCGM 101): everything upstream that depends on an uncertain input runs ``trials`` times, each input drawn from its own distribution (normal, rectangular, triangular, or Student's t for a mean of few readings). Gives the mean and standard uncertainty, the coverage interval, the samples and a histogram, and says whether the linear (GUM) result agrees (JCGM 101, 8), which it may not for non-linear models. Arrays are handled element by element.
- `uncertainty.uncertainty_budget(value: Uncertain) -> (result: dict[str, list[Any]])`: Uncertainty Budget. Which inputs the uncertainty of a result comes from: each named input's contribution |∂y/∂xᵢ|·u(xᵢ), in the result's unit, and its share of the variance. Connect to Add Table for the report.
- `uncertainty.value_and_uncertainty(value: Uncertain) -> (value: Any, uncertainty: Any, relative: float)`: Value and Uncertainty. Split a value into its best estimate, its standard uncertainty (in the value's unit) and the relative uncertainty u/|x|.

## units

- `units.constant(name: str = 'pi', unit: str = '') -> (result: Any)`: Constant. A named constant: π, the speed of light, standard gravity... with its unit, and with its CODATA uncertainty when it was measured rather than defined. Exact constants without a unit are plain numbers; use unit '1' to feed one into Quantity Math. One node can feed many others: two nodes naming the same measured constant are sampled independently by Monte Carlo. Symbolic Values take constants by name too (a line 'g = g0').
- `units.convert_units(quantity: Quantity, unit: str = 'm') -> (result: Quantity)`: Convert Units. Express a quantity in another unit of the same dimension. Links do this by themselves; use the node to choose the unit a result is shown in.
- `units.magnitude(quantity: Quantity, unit: str = '') -> (result: float)`: Magnitude. The number without its unit, in ``unit`` (empty: the quantity's own). For array quantities, use Magnitude (Array).
- `units.magnitude_array(quantity: Quantity, unit: str = '') -> (result: NDArray[float64])`: Magnitude (Array). The numbers of an array quantity without the unit, in ``unit`` (empty: the quantity's own).
- `units.make_quantity(value: float, unit: str = 'm') -> (result: Quantity)`: Make Quantity. Attach a unit to a plain number.
- `units.make_quantity_array(values: NDArray[floating], unit: str = 'm') -> (result: Quantity)`: Make Quantity (Array). Attach a unit to an array of numbers, such as a table column.
- `units.quantity(value: Quantity = '1.0 m') -> (result: Quantity)`: Quantity. A constant with a unit, typed as text: 9.81 m/s^2, 3 km, 25 degC.
- `units.quantity_math(a: Quantity, b: Quantity = '1.0', operation: Literal['add', 'subtract', 'multiply', 'divide', 'power', 'root', 'minimum', 'maximum', 'modulo', 'sqrt', 'absolute', 'negate'] = 'multiply') -> (result: Quantity)`: Quantity Math. Arithmetic on quantities. Add, subtract, minimum, maximum and modulo need the same dimension; the result is in the first input's unit. A temperature plus or minus a difference (delta_degC) is a temperature, and one temperature minus another is a difference. Power raises A to B and root takes the B-th root of A: B is a plain number, and must be exact when A has a unit, because the result's unit depends on it. Sqrt, absolute and negate ignore B.

# Examples

- `01-getting-started`: 1 · Getting started. Three nodes and a Viewer: a number, a Math node set to power, and the answer as text. Change the numbers, press Ctrl+Enter, and select a node to see its result in the sidebar.
- `02-units`: 2 · Units: how fast was the run?. A 5 km run in 23 minutes, as a speed in km/h and a pace in minutes per kilometre. New: values with units, which carry through arithmetic and convert on request.
- `03-plot-a-function`: 3 · Plot a function. 200 evenly spaced numbers from 0 to τ = 2π, their sines, and a plot of the two. New: arrays, named constants (a Constant node, instead of typing 6.283...), and results (here a figure) shown on the node itself.
- `04-noise-and-smoothing`: 4 · Noise and smoothing. Add noise to a sine, smooth it with a Savitzky–Golay filter and measure how far the result is from the truth. Change the filter window and run again: only the filter and the nodes after it recompute. New: a pipeline, and checkpoints.
- `05-first-csv`: 5 · Your first CSV: pendulum timings. Load 55 stopwatch timings of a pendulum, average them per string length and plot the result. New: reading a CSV file, table previews, and summarising groups of rows.
- `06-straight-line-fit`: 6 · Straight-line fit: measuring g. A pendulum's period T satisfies T² = (4π²/g)·L, so a straight line through T² against length gives the acceleration of gravity from its slope. New: computed columns linked straight from a table, linear regression, and Math on a fit result and the constant τ = 2π.
- `07-filter-and-group`: 7 · Filter, group, compare: a field trial. Four fertiliser treatments on 72 wheat plots: drop a mistyped value, summarise the yield per treatment, rank the treatments and chart them. New: filtering and sorting rows, bar charts and box plots.
- `08-calibration`: 8 · Calibration curve and unknowns. Fit a photometric calibration to eight standards, check its residuals, then read twelve water samples back from it with confidence intervals, and list those below the limit of quantification or above the range. New: two input tables, inverse prediction, and requirements the calibration is verified against.
- `09-uncertainty`: 9 · Uncertainty: density of a cylinder. A metal cylinder is weighed and measured with a caliper; its density follows from ρ = 4m/(πd²h). The uncertainties of the three measurements flow through the arithmetic by themselves. New: measurements with uncertainties, an uncertainty budget, and a check that the result is aluminium.
- `10-curve-fit-subgraph`: 10 · Curve fitting in a reusable subgraph. Fit the Michaelis–Menten model to an enzyme's rates with and without a 5 mM inhibitor, and derive the inhibition constant from the two fits. New: non-linear curve fitting, and a subgraph built once and used twice.
- `11-time-series`: 11 · Time series: a station's climate. Fifteen years of daily weather: monthly means, the normal for each month, and whether the annual mean is rising (Mann–Kendall test, Sen's slope per decade). New: dates, resampling and trend tests.
- `12-signals`: 12 · Signals and spectra: a fan's vibration. Ten seconds of a fan's acceleration at 1 kHz: remove the drift, filter out what is not vibration, and find the frequencies it vibrates at. New: signal processing: detrending, filters, power spectra and peaks.
- `13-symbolic`: 13 · Symbolic maths: a cantilever beam. The deflection of a cantilever loaded at its tip, as a formula: substitute to get the tip deflection, evaluate it with units, plot it along the beam and solve for the largest load that keeps the tip within L/180. New: formulas as values (SymPy).
- `14-first-report`: 14 · Write it up: your first PDF report. The straight-line fit of example 6, written up as a two-page PDF with a figure, a value with its unit, a table and text that quotes computed numbers. New: reports.
- `15-repeat-zone`: 15 · Repeat zone: a cup of tea cooling. Newton's law of cooling, stepped forward one minute at a time for an hour. The zone's nodes run 60 times, each pass starting from the temperature the last one left, and every pass is kept for the plot. New: loops, drawn as a repeat zone.
- `16-until-converged`: 16 · Until it converges: pipe friction. The Colebrook equation for the friction in a pipe has no closed form, so it is solved by repeating a step until the answer stops changing: once as a repeat zone in until mode, and twice with the Iterate node (fixed point and Newton's method). New: while loops.
- `17-sweep-and-optimize`: 17 · Parameter sweep and optimisation: a projectile. How far a ball flies at each launch angle and speed, all 51 combinations in one sweep, the throws that meet a requirement, and the best angle found by an optimiser, whose throw is verified against the requirement. New: sweep and optimize zones, which run their nodes once per parameter set, and Check Candidates, which scores every row of a sweep against requirements.
- `18-gps-track`: 18 · GPS hike: distance and climbing on a map. A GPS log of a day's hike: distance, climbing and moving time from the fixes, a map of the route coloured by speed, an elevation profile and the pace. New: geographic data and maps.
- `19-earthquakes`: 19 · Earthquakes: counting per region. Two years of a regional earthquake catalogue: the Gutenberg–Richter b-value and the magnitude above which the catalogue is complete, then the complete events counted in each region and mapped. New: reading vector files, and joining points to polygons.
- `20-groundwater-nitrate`: 20 · Groundwater nitrate: joins, distances, interpolation. Combine wells, districts and a river: reproject, measure distances, join wells to districts, test land-use and river effects, interpolate a surface in a subgraph, and compute the area above the limit per district.
- `21-fixed-beam`: 21 · Fixed beam: stress and strain. Solve the equation of a beam fixed at both ends with SymPy, derive the moment, shear, stress and strain, evaluate them with units, compare with strain gauges and verify the design requirements.
- `22-watershed`: 22 · Watershed: terrain, land cover and runoff. The full picture: read rasters and vectors, derive slope and hillshade, classify land cover from NDVI (both in subgraphs), interpolate rainfall onto the DEM grid, estimate runoff volumes with physical units, map erosion risk and summarise everything per sub-catchment.
- `23-satellite-link`: 23 · Satellite downlink: choosing the radio. Pick the X-band radio for a small satellite: requirements as inputs, a link budget (slant range, path loss, Eb/N0, bit error rate) for every candidate at once, a trade table scored against the requirements, and a compliance matrix in the report.
- `24-cantilever-bracket`: 24 · Sizing a cantilever bracket. The engineering pack end to end: pick a material from a weighted trade of the built-in table, compute the section properties of a tube, the cantilever's deflection and root stress, the factor of safety against yield, and verify the requirements in a report.
- `25-spring-mass-control`: 25 · Controlling two masses on springs. Two masses between three springs, from equations to a controller: solve the balance equations in symbols and as K x = F, find the natural frequencies from the stiffness and mass matrices, confirm them with the Fourier transform of a tap, then close a PID loop around a state-space model and verify overshoot, settling time and stability margins in a report. New: matrices, systems of equations, the control toolbox and Fourier transforms.
