Architecture
============
This page is for those who want to extend kipe: use it with a new kind of measurement (e.g.,
MRI phase images instead of velocities on a mesh), compare model and data differently, read
another data format, or connect their own forward solver. It explains how kipe is put together
and where each of these extensions goes. The API pages document each part in detail.
Principles
----------
**The core is solver-agnostic.** The core (``kipe``) uses numpy and MPI only. Everything that
needs a discretization library lives in a backend subpackage (``kipe.fenicsx``), imported only
when a study uses it. The FitzHugh–Nagumo example (:mod:`kipe.examples.fitzhugh_nagumo`)
runs without any backend and keeps it that way.
**The solver owns its settings.** Time step, end time, output paths and solver tolerances
belong to the forward solver. kipe passes only state, parameters and time, so no setting is
defined twice.
**A study is one file.** The study file (:mod:`kipe.options`) holds everything a run needs. An
archived study file reproduces an HPC run; command-line options only change what is printed.
The same file serves synthesis and estimation, so a twin experiment cannot mismatch.
**Extensions are small and explicit.** A new component is a class, its options and one
``case`` in a build function, all in kipe. There are no plugin registries or entry points:
reading the build function shows every choice there is.
Overview
--------
The study file is read into options dataclasses. Build functions turn the options into the
objects that do the work: the forward solver, the parameterization and, for each measurement,
its observation operator. Synthesis runs the solver with its nominal parameters, or reads the
states from the solver's checkpoints, and writes the measurement data. The data can also come
from an acquisition, converted to kipe's format outside kipe. Estimation reads the data and
runs the filter.
.. mermaid::
flowchart TD
study[/"study file"/] --> options["options
kipe.options"]
options --> solver["forward solver
(external)"]
options --> operators["observation operators
H = M ∘ S
kipe.measurements"]
options --> parameterization["parameterization
kipe.parameters"]
solver -- "run, or read checkpoints" --> synthesis["kipe synthesis
kipe.synthesis"]
operators --> synthesis
synthesis --> data[/"measurement data"/]
acquired[/"acquired data
(e.g., MRI)"/] -. "converted" .-> data
solver --> estimation["kipe estimation
kipe.estimation"]
operators --> estimation
parameterization --> estimation
data --> estimation
estimation --> history[/"estimation history"/]
The forward solver
------------------
kipe sees the forward model through :class:`kipe.forward_solver.ForwardSolver`, a protocol:
a solver conforms structurally and does not import kipe. It is constructed by the
``factory`` named in the study file, with the ``arguments`` given there; from Python, the
arguments can be any objects, e.g., the solver's own options.
- **Stateless calls.** :meth:`~kipe.forward_solver.ForwardSolver.initial_state` and
:meth:`~kipe.forward_solver.ForwardSolver.propagate` take and return the state; a call's
result must not depend on earlier calls. The filter propagates every sigma point, each from
its own state, one after another, with the same solver instance.
- **Named state fields.** The state is a mapping from field names to numpy arrays, local to
the MPI rank. A field is ``distributed`` (each entry owned by one rank, e.g., the owned
degrees of freedom of a finite element function) or ``replicated`` (every rank holds all
of it, e.g., the state of a lumped-parameter model). Measurements refer to fields by name.
- **Named parameters.** The solver lists what can be estimated, with nominal values. The study
selects parameters by name and sets their prior; parameters it does not select keep their
nominal values.
- **Backend capabilities.** A backend may need more from the solver: FEniCSx samplers need the
function space of a field (:class:`kipe.fenicsx.forward_solver.SupportsFunctionSpaces`).
Such a capability is a separate protocol, so a solver without it still works with the core
samplers.
Measurements
------------
A :class:`~kipe.measurements.Measurement` is data at measurement times, with a noise level,
and the observation operator that predicts those data from a model state. The observation
operator :math:`\mathcal{H} = M \circ S` has two parts:
- the **spatial sampler** :math:`S` (:mod:`kipe.sampling`) evaluates the observed fields at
the measurement locations: interpolation onto a measurement mesh, a projection onto a
direction or the surface normal, all entries of a field;
- the **measurement model** :math:`M` (:mod:`kipe.measurements`) predicts the data from the
sampled values and compares the prediction with the measured data, giving the innovation.
The diagram shows one observation operator of the overview, with its two parts, and how its
result enters the filter.
.. mermaid::
flowchart LR
state["model state χ"] --> sampler["SpatialSampler S
.sample"]
sampler -- "sampled values y" --> predict["MeasurementModel M
.predict"]
predict -- "predicted data ẑ" --> innovation["MeasurementModel M
.innovation"]
data[/"measured data z"/] --> innovation
innovation -- "innovation Γ(z, ẑ)" --> filter["ROUKF correction
kipe.roukf"]
Synthesis and estimation use the same operator, so the model that generates synthetic data
is the one that inverts them; a sensitivity analysis would use it too.
**Why two parts.** Samplers depend on the discretization, models on the acquisition physics
(e.g., the phase of an MRI signal, a Fourier transform, a mask). Models act on arrays only,
so they work with every backend. Split this way, N samplers and M models give N × M
combinations from N + M classes.
**Why "observation".** In the data-assimilation literature, the observation operator is the
map from the model state to predicted data. kipe keeps the word for that composition and
calls its parts by what they do.
Parallel layout
---------------
Everything in kipe that communicates takes the MPI communicator as an argument (``comm``,
``MPI.COMM_WORLD`` by default); only the command line uses ``MPI.COMM_WORLD`` directly. The
same code runs serially and in parallel, and sigma points can later run in parallel in groups
of ranks, each group with its own communicator.
**Each entry on one rank.** A sampler knows the size of the sampled vector over all ranks
and, on each rank, the ``global_indices`` of the entries that rank returns. Every entry is
returned by exactly one rank. The filter's reductions (sums over entries) are then plain
``allreduce`` operations, with nothing counted twice.
**Data independent of the rank count.** Measurement data are stored in the order of the
global indices, which the sampler defines independently of the partition (e.g.,
:class:`~kipe.fenicsx.sampling.MeshSampler` uses the row of each vertex or cell in the mesh
file). Synthetic noise depends only on an entry's position. So data synthesized on 1 rank are
the same as on 8, and either can be used with any number of ranks.
Extending kipe
--------------
Each kind of component follows the same pattern:
1. **The class**, in the module of its kind: a subclass of the abstract base class.
2. **Its options**, a frozen dataclass in :mod:`kipe.options` with a literal ``type`` field,
added to the union of its kind. The ``type`` selects it in the study file. Backend
components carry the backend in the name (``fenicsx.mesh``); core components have plain
names (``array``, ``difference``).
3. **One case** in the build function of its kind, which constructs the class from its
options. A backend case imports the backend inside the case, so the core never imports it.
4. **Tests**, with the FitzHugh–Nagumo example for core components, with the heat example
(:mod:`kipe.fenicsx.examples.heat`) for FEniCSx components.
========================= ================================================ =====================================================
component base class build function
========================= ================================================ =====================================================
spatial sampler :class:`kipe.sampling.SpatialSampler` :func:`kipe.sampling.build_sampler`
measurement model :class:`kipe.measurements.MeasurementModel` :func:`kipe.measurements.build_model`
========================= ================================================ =====================================================
**A spatial sampler**: :doc:`write_a_sampler` walks through a complete one. It sets ``size``
and ``global_indices`` and implements
:meth:`~kipe.sampling.SpatialSampler.sample`, which takes the observed fields (rank-local
arrays) and returns this rank's sampled entries. It may take what it needs from the solver at
construction, e.g., function spaces, through a backend capability.
**A measurement model** implements :meth:`~kipe.measurements.MeasurementModel.predict` and
:meth:`~kipe.measurements.MeasurementModel.innovation`. It sees this rank's entries as arrays.
A model that needs all entries at once (e.g., a Fourier transform) will also need the
communicator; none does so far.
**A data format** is read where the data are read
(:func:`kipe.estimation.estimate` and :func:`kipe.synthesis.synthesize`, through
:func:`~kipe.measurements.read_numpy` and :func:`~kipe.measurements.write_numpy`). kipe has
one format so far, ``numpy``; the second one will add a build function for readers, following
the pattern above.
**A forward solver** lives outside kipe. It implements
:class:`~kipe.forward_solver.ForwardSolver`, plus the capabilities of the backend it uses.
:mod:`kipe.examples.fitzhugh_nagumo` is the reference for a solver without a backend,
:mod:`kipe.fenicsx.examples.heat` for a FEniCSx solver; both are tested against the protocol.