Language-agnostic coupling of user-written force / PTO / controller models (Python, MATLAB, or later FMI) into a running SEA-Stack simulation.
SEA-Stack owns a C++ abstract interface IExternalForceModel
(libs/external/include/seastack/external/external_force_model.h).
Transport backends implement that interface. Higher-level bridges reuse the
existing PTO and hydro force paths:
| Bridge | Base | Role |
|---|---|---|
ExternalPtoModel |
IPTOModel |
1-DOF link force/torque via ChLinkTSDA or ChLinkRSDA |
ExternalForceComponent |
IHydroForceComponent |
6-DOF body forces in the hydro pipeline |
Chrono functors:
| Functor | Link | Role |
|---|---|---|
PTOForceFunctor |
TSDA | Lean IPTOModel (disp, vel) |
PTOTorqueFunctor |
RSDA | Lean IPTOModel (angle/omega as disp/vel) |
ExternalPtoForceFunctor |
TSDA | Rich kinematics → ExternalPtoState |
ExternalPtoTorqueFunctor |
RSDA | Rich kinematics → ExternalPtoState |
YAML external_pto.link may name either a ChLinkTSDA or a ChLinkRSDA;
attach discovers the type and registers the matching functor. Absorbed power
export uses -(F·v) / -(T·ω) when an IPTO / external functor is attached.
The first transport is out-of-process IPC over TCP loopback with
length-prefixed JSON (IpcExternalForceModel). Python and MATLAB speak the
same protocol; only thin client helpers differ.
- One C++ side serves every language (no embedded CPython / MATLAB Engine).
- Crash isolation: a bad user script does not corrupt the Chrono heap.
- No interpreter is bundled in the release package.
- Latency of one loopback round-trip per accepted step is negligible vs HHT.
Chrono's HHT integrator re-queries forces during Newton iterations at the same simulation time. SEA-Stack already freezes hydro/mooring/hydraulic-PTO forces within a time level. External bridges follow the same rule:
- Round-trip to the module only when
timeadvances. - Repeated / backward evaluations return the cached output.
v1 therefore uses explicit-within-step coupling. Stiff controllers should
use a small time_step. The protocol also defines optional commit /
rollback operations for future FMI / true co-simulation.
Transport: TCP to 127.0.0.1 on an ephemeral port chosen by SEA-Stack.
The listen port is passed to the child as --seastack-port <N>.
Framing: each message is a 4-byte big-endian unsigned length followed by that many UTF-8 JSON bytes (no newline required).
Protocol version: integer kProtocolVersion = 1, sent in initialize.
{"op":"initialize","protocol":1,"kind":"pto_tsda","n_inputs":17,"n_outputs":1,"dt":0.01,"in_names":["displacement","velocity","length", "..."],"config":{"damping":1200000}}
{"op":"evaluate","t":1.23,"dt":0.01,"in":[0.05,-0.12, ...]}
{"op":"reset"}
{"op":"commit"}
{"op":"rollback"}
{"op":"shutdown"}The optional additive in_names array labels the positional in channels
(protocol v1, no version bump). Older modules may ignore it. Lean replay /
unit-test paths still use kind:"pto" with n_inputs:2 and
in_names:["displacement","velocity"]. Chrono attach defaults to rich state
(kind:"pto_tsda" or "pto_rsda", 17 channels); set rich_state: false in
YAML to keep the lean 2-channel contract.
{"status":"ok","name":"MyPTO","version":"1.0","n_states":0}
{"status":"ok","out":[-144000.0]}
{"status":"ok"}
{"status":"error","message":"division by zero"}initializereply must includename,version, andn_states.evaluatereply must includeout(array of doubles, length =n_outputs).- Any
statusother than"ok", malformed JSON, wrong array length, or IPC timeout raises a C++ exception and aborts the run. The child process is terminated on failure.
Channels 0/1 are universal for both link types:
| Index | Input | TSDA unit | RSDA unit | Convention |
|---|---|---|---|---|
| 0 | displacement | m | rad | extension positive (length − rest_length or angle − rest_angle) |
| 1 | velocity | m/s | rad/s | extending / opening positive |
| Index | Output | TSDA unit | RSDA unit | Convention |
|---|---|---|---|---|
| 0 | force | N | N·m | resistive / opposes motion (same as IPTOModel) |
Rich Chrono attach also publishes (indexes 2–16):
| Names (TSDA) | Names (RSDA) | Notes |
|---|---|---|
length, rest_length |
angle, rest_angle |
Link-native absolute measure |
rel_accel |
rel_accel |
Finite difference of relative velocity across accepted steps (0 on first step) |
body1_pos_{x,y,z}, body1_vel_{x,y,z} |
same | World-frame body 1 |
body2_pos_{x,y,z}, body2_vel_{x,y,z} |
same | World-frame body 2 |
Inputs (per body, concatenated): position(3), orientation_rpy(3),
linear_velocity(3), angular_velocity(3) — world frame, matching BodyState.
Outputs (per body): force(3) [N], moment(3) [N·m] — world frame, matching
GeneralizedForce.
spawn child → accept TCP → initialize → evaluate* → shutdown → join
Reset clears module state without tearing down the connection.
Commit / Rollback are no-ops for the IPC v1 transport (commit is implied
by advancing time); FMI backends should implement them.
The same script works on a TSDA or an RSDA. Units follow the link: the value you return is force [N] on a TSDA or torque [N·m] on an RSDA.
Subclass PtoModule, implement force(state), and call run(...). Config
goes in optional setup(cfg); stateful controllers also override reset
(and commit / rollback when needed). SEA-Stack's state arrives as a
PtoState; your return value is the actuator force (or torque).
from seastack_external import PtoModule, PtoState, run
class MyPTO(PtoModule):
def setup(self, cfg):
self.c = float(cfg.get("damping", 1.2e6))
return {"name": "MyPTO", "version": "1.0", "n_states": 0}
def force(self, state: PtoState) -> float:
return -self.c * state.velocity
# optional lifecycle: reset(self) / commit(self) / rollback(self)
if __name__ == "__main__":
run(MyPTO())The same skeleton scales across the three demos:
- Linear — only
force(F = -c v). - Adaptive — same law, plus adapting
c(t), force saturation, andreset. - Hydraulic — internal physics states with
commit/rollback.
PtoState always has time, dt, displacement, velocity. With rich state
enabled (default), it also carries link extras (length/rest_length or
angle/rest_angle), rel_accel, and body kinematics. Use state.get(name)
or state.raw for additional channels.
For non-PTO / multi-output modules, subclass ExternalForceModule and
implement initialize / evaluate with positional arrays:
from seastack_external import ExternalForceModule, run
class MyPTO(ExternalForceModule):
def initialize(self, cfg):
self.c = cfg.get("damping", 1.2e6)
return {"name": "MyPTO", "version": "1.0", "n_states": 0}
def evaluate(self, t, dt, inputs):
return [-self.c * inputs[1]]
if __name__ == "__main__":
run(MyPTO())YAML (*.setup.yaml), when SEASTACK_ENABLE_EXTERNAL is on. Prefer a dedicated
attach file (peer of hydro_file); this path is for link actuators
(ChLinkTSDA / ChLinkRSDA) only — not a custom 6-DOF body wrench:
# setup.yaml
external_pto_file: my_case.external_pto.yaml# my_case.external_pto.yaml
link: PTO # name of a ChLinkTSDA or ChLinkRSDA
command: ["python", "my_pto.py"]
# rich_state: false # optional; default true (17-channel kinematics)
# combine_native: true # optional; layer model YAML k/c on top of external force
timeout_ms: 20000
config:
damping: 1200000Attaching an external PTO replaces Chrono’s built-in TSDA/RSDA force
functor. Model YAML spring_coefficient / damping_coefficient /
preload are ignored unless you set combine_native: true, which applies
Chrono’s linear spring-damper law (F = P - k Δx - c v) in addition to
the external module force. Link coefficients stay at zero so HDF5 absorbed
power uses -(F·v) on the total force (external + native); in combined
mode that total includes the passive term. Non-zero model YAML k/c with
combine_native left at its default (false) produces a warning at attach.
An inline external_pto: map in setup is still accepted for small one-offs;
do not set both external_pto_file and external_pto: together.
The command array is the child's argv (SEA-Stack appends --seastack-port <N>). ["python", "my_pto.py"] is the portable convention: on Windows it needs
python on PATH, and on macOS/Linux the host retries python3 automatically
if python is not found, so a stock Homebrew/apt Python 3 works unmodified. To
pin a specific interpreter, set command[0] to an absolute path (e.g. a venv's
python). Relative .py paths in command are resolved against the setup
directory.
| Module | What it shows | TSDA (heave) | RSDA (pitch) |
|---|---|---|---|
linear_damper_pto.py |
Stateless F = -c v / τ = -c ω |
rm3/external_pto/ |
oswec/external_pto/ |
adaptive_damping_pto.py |
Adapting c(t) + saturation + reset |
rm3/external_pto_adaptive/ |
oswec/external_pto_adaptive/ |
hydraulic_accumulator_pto.py |
Internal states + commit/rollback |
rm3/external_pto_hydraulic/ |
oswec/external_pto_hydraulic/ |
Start with the linear damper, then open the adaptive file — it is the same
skeleton with three small additions. The hydraulic demo is the advanced case.
The same Python scripts attach to either link kind; only YAML config units
change (N / m/s vs N·m / rad/s).
RM3 (TSDA) and OSWEC (RSDA) each share one irregular sea state within their
trio (JONSWAP Hs = 2 m, Tp = 8 s, seed = 42, 600 s) so absorbed power and
controller behaviour can be compared. Side-by-side plots (source checkout or
release ZIP — packaging installs these scripts under examples/external_pto/
and enables -DSEASTACK_ENABLE_EXTERNAL=ON):
python examples/external_pto/run_visual_verification.py # RM3
python examples/external_pto/run_visual_verification.py --platform oswecexamples/external_pto/replay_harness.py replays a (time, displacement, velocity) trace through any module in-process and reports force,
mechanical power P_abs = -F v, cumulative absorbed energy and saturation/limit
events. examples/external_pto/verify_examples.py runs the prescribed-input
golden checks for all three cases (ctest test_external_pto_examples_golden).
Use the harness to check a new module against your own reference before wiring
it into an RM3 run.
Release ZIPs install the IPC helper at python/seastack_external.py. Demo
scripts under demos/ search parents for that folder (and, in a source
checkout, libs/external/python/). Keep interesting model code in the demo
script itself; do not put physics into seastack_external.py.
-DSEASTACK_ENABLE_EXTERNAL=ON
Default is OFF. Enables SEAStack::External, unit tests, and YAML wiring
in run_seastack.
The protocol is language-agnostic, so the same C++ side works with a MATLAB module — no C++ changes are required. Only the worked examples above are Python today; a MATLAB port is a documented extension point, not a shipped example.
To add one:
-
Reuse the reference IPC helper
libs/external/matlab/seastack_external.m, which already implements the v1 framing (4-byte big-endian length + JSON) and theinitialize/evaluate/reset/commit/rollback/shutdowndispatch loop against127.0.0.1on--seastack-port. -
Write your model as
initialize/evaluatehandles (see the header comment in that file). Keep the physics in your model file; the helper stays generic. -
Point the demo at it:
# setup.yaml external_pto_file: my_case.external_pto.yaml
# my_case.external_pto.yaml link: PTO command: ["matlab", "-batch", "run_my_pto"]
The same displacement/velocity replay approach used by replay_harness.py
can validate a MATLAB module by feeding it a CSV trace and comparing forces
against the Python reference.
- Explicit-within-step coupling is not fully implicit (see above).
- Parallel campaign cells each need their own child and ephemeral port.
- MATLAB startup cost can dominate short campaign cells.
- Stateful external models can break regression determinism; use
resetand seed RNGs for reproducible cases.