Skip to content

Characterization Framework

The characterization system runs test campaigns from a single YAML file and generates plots without any Python scripting.

Quick start

uv run numen characterize test_plan.yaml          # run tests + plot
uv run numen characterize test_plan.yaml -c       # run tests only → results.json
uv run numen characterize test_plan.yaml -p       # plot only → reads results.json
uv run numen schema -o test_plan.schema.json     # generate IDE autocomplete schema

Mandatory parameter key format

All references to model parameters in a test plan must use the full three-level path:

entity_id.component_kind.field_name
sweep_param: piston.pneumatic_dashpot.orifice_area   # ✓ correct
sweep_param: osc.nl_oscillator.c1                    # ✓ correct
sweep_param: piston.orifice_area                     # ✗ INVALID

Excitation parameters use the excitation.* prefix (excitation.dc_offset, excitation.amplitude, excitation.frequency); the framework translates them internally.

The runner validates every key at campaign start (in CharacterizationRunner.__init__) and fails immediately with the full list of valid parameters if a key is wrong — before any backend opens.

IDE autocomplete + validation

numen init and numen new install test_plan.schema.json in your project root. Reference it from the top of any test plan for live VS Code/Cursor autocomplete and validation:

# yaml-language-server: $schema=test_plan.schema.json
world_module: world
tspan: [0.0, 10.0]
...

Requires the redhat.vscode-yaml extension. Regenerate the schema after upgrading Numen with numen schema -o test_plan.schema.json.

Adding an ExcitationPort

The characterization framework identifies which component fields accept excitation signals via ExcitationPort annotations.

from numen.fields import ExcitationPort

class MassComponent(Component):
    kind:     Literal["mass"] = "mass"
    position: Annotated[float, IntegratedField()] = 0.0
    velocity: Annotated[float, IntegratedField()] = 0.0
    mass:     Annotated[float, ParameterField()]  = 1.0
    force:    Annotated[float, ExcitationPort(
                  targets   = "velocity",   # derivative of this field receives F(t)
                  port_type = "effort",     # "effort" (force/pressure) or "flow" (velocity)
                  units     = "N",
              )] = 0.0

ExcitationPort fields are not compiled into p. They are pure metadata used by inject_excitation() to add a time-varying forcing system post-compilation.

Test plan YAML structure

version: "1.0"

backend:
  type: julia_server          # scipy | jax | julia | julia_server
  julia_file: dynamics.jl
  n_save_points: 2000
  n_workers: 4               # optional: parallel workers for sweep tests

model:
  module: world              # Python module with make_world factory
  factory: make_world

excitation:
  entity: osc               # entity_id that has the ExcitationPort
  port: force               # field name of the ExcitationPort
  output_state: position    # field name to observe

tests:
  - name: baseline_frf
    type: discrete_frequency_sweep
    frequencies:
      spacing: log
      f_start: 0.1
      f_end: 50.0
      n_points: 40
    amplitude: 0.01
    settle_periods: 50
    measure_periods: 10

  - name: frf_survey
    type: continuous_chirp
    f_start: 0.1
    f_end: 100.0
    amplitude: 0.01
    duration: 30.0

plots:
  - name: bode
    type: bode
    test: baseline_frf

  - name: chirp_plot
    type: chirp_timeseries
    test: frf_survey

Test types

Type Description
discrete_frequency_sweep Stepped sine; one solve per frequency; lock-in FRF detection
continuous_chirp Single chirp solve; fast frequency survey
amplitude_sweep Fixed frequency, varying amplitude; nonlinearity signature
dc_operating_point_sweep DC bias sweep; small-signal FRF at each operating point
parameter_sweep Outer loop over one parameter; inner loop is any sub-test
parameter_grid Full factorial or pairwise grid over multiple parameters
doe_sweep Space-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

DOE sweeps require pip install "numen[characterization]" (pyDOE3, SALib, pandas).

excitation.* parameter paths

parameter_sweep, parameter_grid, and doe_sweep can vary excitation inputs as the outer sweep dimension:

- name: frf_vs_amplitude
  type: parameter_sweep
  sweep_param: excitation.amplitude   # also: excitation.frequency, excitation.dc_offset
  values: [0.001, 0.01, 0.1, 1.0]
  sub_test: baseline_frf

This generates a ParameterFamilyResult rendered as a family of Bode curves coloured by amplitude.

Parallel execution

Add n_workers: N to backend: to run sweep tests in parallel:

backend:
  type: julia_server
  julia_file: dynamics.jl
  n_workers: 4
  n_save_points: 2000

CLI override: numen characterize test_plan.yaml --workers 4

Each worker precompiles all dynamics at startup so the first real solve carries no JIT latency.

Plot panel types

Type Renders
bode Magnitude + phase from discrete_frequency_sweep
chirp_timeseries Time-domain chirp with spectrogram
amplitude_sweep Peak response vs. amplitude
dc_sweep Small-signal FRF vs. DC operating point
parameter_family Overlaid curves from parameter_sweep
doe_scatter Scatter matrix from doe_sweep
parameter_grid_heatmap Heatmap from parameter_grid

Use enabled: false on any test or plot panel to skip it without deleting it.