refactor(physics): replace CVODE with RK45, remove SUNDIALS dependency
CvodeConfig kept as backward-compat alias for RK45Config. CvodeIntegrator removed. All 207 tests pass without SUNDIALS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
1474b0cf4c
commit
6196f0ca0e
9 files changed
+107
-270
No files matched your search
@@ -0,0 +1,91 @@
|
||||
# ADR-001: Visualization Session API MVP for Phase 3
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-27
|
||||
|
||||
## Context
|
||||
|
||||
Phase 3 must expose Ymir's `World` simulation state to external clients. The roadmap
|
||||
identifies WebSocket + Protobuf, scenario loading, basic control commands, and real-time
|
||||
state transmission as the intended control API. Codebase research shows that Phase 2
|
||||
already provides the required simulation foundation through `World::step(dt)`,
|
||||
`World::snapshot()`, `Environment`, `NavalDomain`, and `CouplingRegistry`, while
|
||||
`apps/server` remains an empty application target.
|
||||
|
||||
The selected primary user is a visualization client. The MVP must provide body
|
||||
kinematics, vessel operational state, and environment state. The client needs basic
|
||||
session control: load scenario, start, pause, resume, stop, and observe state. The
|
||||
preferred stream behavior is event-first updates rather than periodic full snapshots,
|
||||
and the main success criterion is integration completeness.
|
||||
|
||||
## Decision
|
||||
|
||||
Phase 3 will use a Visualization Session API MVP. The product will let a visualization
|
||||
client load a scenario, control the simulation session lifecycle, receive an initial
|
||||
full session snapshot, and then receive event-first updates covering body kinematics,
|
||||
vessel operational state, and environment state.
|
||||
|
||||
The MVP will avoid live scenario editing and detailed operator controls. It will
|
||||
prioritize one complete end-to-end integration path over breadth.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative 1: Control-Rich Operator API
|
||||
|
||||
- **Description**: Include session control plus live changes to environment, waypoints,
|
||||
vessel modes, rudder commands, and thruster commands.
|
||||
- **Pros**: More useful for training operators and interactive maneuver experiments.
|
||||
- **Cons**: Expands scope into operational control semantics before the visualization
|
||||
contract is stable.
|
||||
- **Why rejected**: It risks turning Phase 3 into a broad command product instead of
|
||||
the smallest complete external integration layer.
|
||||
|
||||
### Alternative 2: Stream-Only Viewer API
|
||||
|
||||
- **Description**: Let external code create and run simulations while the server only
|
||||
streams state/events to visualization clients.
|
||||
- **Pros**: Fastest path to rendering live state.
|
||||
- **Cons**: Does not let the client load or control a full World session.
|
||||
- **Why rejected**: It fails the selected success criterion of integration completeness.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Delivers a complete external client workflow with limited product scope.
|
||||
- Gives visualization clients enough state to render bodies, vessel status, and environment.
|
||||
- Establishes the message contract future operator controls can extend.
|
||||
- Keeps Phase 3 aligned with the existing Phase 2 `World` snapshot model.
|
||||
|
||||
### Negative
|
||||
|
||||
- Does not yet support live operator editing of environment, waypoints, or actuator commands.
|
||||
- Visualization clients may still need client-side interpolation when event cadence is lower
|
||||
than render cadence.
|
||||
- Event-first behavior requires clear baseline snapshot semantics so clients can recover
|
||||
from missed or out-of-order updates.
|
||||
|
||||
### Risks
|
||||
|
||||
- **Risk**: Stakeholders may expect direct vessel control in Phase 3.
|
||||
**Mitigation**: Document live scenario and vessel command editing as post-MVP scope.
|
||||
- **Risk**: Event-first updates may be insufficient for early visualizers expecting
|
||||
fixed-rate snapshots.
|
||||
**Mitigation**: Require an initial full snapshot and allow clients to request current
|
||||
state on demand.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This ADR records product scope only. Implementation details belong in the Phase 3
|
||||
TechSpec.
|
||||
|
||||
## References
|
||||
|
||||
- `.prds/roadmap.md` — Phase 3 scope
|
||||
- `.compozy/tasks/phase-02-world/_prd.md` — World snapshot and Phase 3 readiness
|
||||
- `docs/architecture/data-flow.md` — real-time server data flow context
|
||||
@@ -22,12 +22,6 @@ FetchContent_Declare(
|
||||
|
||||
FetchContent_MakeAvailable(Catch2 nlohmann_json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SUNDIALS CVODE — ODE integrator (system package, required from M1)
|
||||
# ---------------------------------------------------------------------------
|
||||
find_package(SUNDIALS 6.0 REQUIRED)
|
||||
message(STATUS "SUNDIALS found: ${SUNDIALS_VERSION}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CGAL — mesh and geometry library (system package, required from M2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,6 @@ add_library(ymir_physics STATIC
|
||||
src/naval_physics.cpp
|
||||
src/RigidBody6DOF.cpp
|
||||
src/NavalForceModel.cpp
|
||||
src/integrator/CvodeIntegrator.cpp
|
||||
src/integrator/RK45Integrator.cpp
|
||||
src/forces/CurrentForces.cpp
|
||||
src/forces/DampingForces.cpp
|
||||
@@ -28,7 +27,6 @@ target_include_directories(ymir_physics
|
||||
target_link_libraries(ymir_physics
|
||||
PUBLIC
|
||||
ymir_common
|
||||
SUNDIALS::cvode
|
||||
)
|
||||
|
||||
target_set_warnings(ymir_physics)
|
||||
@@ -3,7 +3,9 @@
|
||||
#include <ymir/physics/AbstractBody.h>
|
||||
#include <ymir/physics/Forces.h>
|
||||
#include <ymir/common/Types.h>
|
||||
#include <ymir/physics/integrator/CvodeConfig.h>
|
||||
#include <ymir/physics/integrator/RK45Integrator.h>
|
||||
#include <ymir/physics/integrator/CvodeConfig.h> // backward compat alias
|
||||
#include <ymir/physics/integrator/IIntegrator.h>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
@@ -11,17 +13,11 @@
|
||||
namespace ymir
|
||||
{
|
||||
|
||||
class CvodeIntegrator;
|
||||
class RK45Integrator;
|
||||
|
||||
/**
|
||||
* Concrete 6-DOF rigid body with CVODE/BDF integration.
|
||||
* Concrete 6-DOF rigid body with RK45 adaptive integration.
|
||||
*
|
||||
* Owns its mass matrices, kinematic state, force models, and integrator.
|
||||
* CVODE is initialized lazily on the first step() call.
|
||||
*
|
||||
* Non-copyable and non-movable: CvodeIntegrator::user_data holds an
|
||||
* address-stable pointer to its internal RhsContext (see D-10).
|
||||
* Integrator is initialized lazily on the first step() call.
|
||||
*/
|
||||
class RigidBody6DOF final : public AbstractBody
|
||||
{
|
||||
@@ -31,7 +27,7 @@ public:
|
||||
const Matrix6x6& addedMass,
|
||||
const Vector6& initialQ,
|
||||
const Vector6& initialQdot,
|
||||
const CvodeConfig& config = {});
|
||||
const RK45Config& config = {});
|
||||
|
||||
~RigidBody6DOF();
|
||||
|
||||
@@ -47,7 +43,7 @@ public:
|
||||
BodyState state() const noexcept override;
|
||||
int id() const noexcept override { return id_; }
|
||||
|
||||
/** Reset CVODE time to 0, reinitialise from current body state. */
|
||||
/** Reset integrator time to 0, reinitialise from current body state. */
|
||||
void reset();
|
||||
|
||||
// Read-only access (used by CvodeIntegrator and tests)
|
||||
@@ -65,7 +61,6 @@ public:
|
||||
Vector6 computeAcceleration(const Forces& totalForce) const noexcept;
|
||||
|
||||
private:
|
||||
friend class CvodeIntegrator;
|
||||
friend class RK45Integrator;
|
||||
|
||||
void setState(const Vector6& q, const Vector6& qdot) noexcept;
|
||||
@@ -85,7 +80,7 @@ private:
|
||||
std::vector<std::unique_ptr<ForceModel>> ownedModels_;
|
||||
std::vector<ForceModel*> modelPtrs_;
|
||||
|
||||
std::unique_ptr<CvodeIntegrator> integrator_;
|
||||
std::unique_ptr<IIntegrator> integrator_;
|
||||
double t_ = 0.0;
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
// CvodeConfig is now an alias for RK45Config.
|
||||
// Kept for backward compatibility — tests and existing code can still use CvodeConfig.
|
||||
#include <ymir/physics/integrator/RK45Integrator.h>
|
||||
|
||||
namespace ymir
|
||||
{
|
||||
|
||||
struct CvodeConfig
|
||||
{
|
||||
double reltol = 1e-8;
|
||||
double abstol = 1e-10;
|
||||
long maxSteps = 10000;
|
||||
double maxStep = 0.0; // 0 = no limit (CVODE decides)
|
||||
};
|
||||
|
||||
using CvodeConfig = RK45Config;
|
||||
} // namespace ymir
|
||||
@@ -1,70 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <ymir/physics/integrator/IIntegrator.h>
|
||||
#include <ymir/physics/integrator/CvodeConfig.h>
|
||||
|
||||
#include <sundials/sundials_context.h>
|
||||
#include <nvector/nvector_serial.h>
|
||||
#include <sunmatrix/sunmatrix_dense.h>
|
||||
#include <sunlinsol/sunlinsol_dense.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace ymir
|
||||
{
|
||||
|
||||
/**
|
||||
* CVODE/BDF integrator for 6-DOF rigid body dynamics.
|
||||
*
|
||||
* State vector (12 doubles):
|
||||
* y[0..5] = q (position in inertial frame)
|
||||
* y[6..11] = qdot (velocity in body frame)
|
||||
*
|
||||
* Non-copyable and non-movable: CVODE's user_data pointer references
|
||||
* internal context by address (see D-10 in STATE.md).
|
||||
*/
|
||||
class CvodeIntegrator : public IIntegrator
|
||||
{
|
||||
public:
|
||||
explicit CvodeIntegrator(const CvodeConfig& config = {});
|
||||
~CvodeIntegrator();
|
||||
|
||||
CvodeIntegrator(const CvodeIntegrator&) = delete;
|
||||
CvodeIntegrator& operator=(const CvodeIntegrator&) = delete;
|
||||
CvodeIntegrator(CvodeIntegrator&&) = delete;
|
||||
CvodeIntegrator& operator=(CvodeIntegrator&&) = delete;
|
||||
|
||||
/** Allocate CVODE memory and set initial conditions from body. */
|
||||
void initialize(RigidBody6DOF& body, std::vector<ForceModel*>& models) override;
|
||||
|
||||
/** Advance body state by dt seconds. Throws on CVODE error. */
|
||||
void step(RigidBody6DOF& body, double dt) override;
|
||||
|
||||
/** Reinitialize CVODE with the body's current state (no reallocation). */
|
||||
void reset(RigidBody6DOF& body) override;
|
||||
|
||||
double time() const noexcept override { return t_; }
|
||||
|
||||
// Public so the static rhs() callback can access it — not user API.
|
||||
struct RhsContext
|
||||
{
|
||||
RigidBody6DOF* body = nullptr;
|
||||
std::vector<ForceModel*>* models = nullptr;
|
||||
};
|
||||
|
||||
private:
|
||||
CvodeConfig config_;
|
||||
double t_ = 0.0;
|
||||
|
||||
SUNContext sunCtx_ = nullptr;
|
||||
void* mem_ = nullptr;
|
||||
N_Vector y_ = nullptr;
|
||||
SUNMatrix A_ = nullptr;
|
||||
SUNLinearSolver LS_ = nullptr;
|
||||
|
||||
RhsContext ctx_{};
|
||||
|
||||
static int rhsCallback(sunrealtype t, N_Vector y, N_Vector ydot, void* user_data);
|
||||
};
|
||||
|
||||
} // namespace ymir
|
||||
@@ -1,5 +1,5 @@
|
||||
#include <ymir/physics/RigidBody6DOF.h>
|
||||
#include <ymir/physics/integrator/CvodeIntegrator.h>
|
||||
#include <ymir/physics/integrator/RK45Integrator.h>
|
||||
#include <ymir/common/math/LinearAlgebra.h>
|
||||
|
||||
namespace ymir
|
||||
@@ -10,7 +10,7 @@ RigidBody6DOF::RigidBody6DOF(int id,
|
||||
const Matrix6x6& addedMass,
|
||||
const Vector6& initialQ,
|
||||
const Vector6& initialQdot,
|
||||
const CvodeConfig& config)
|
||||
const RK45Config& config)
|
||||
: id_(id)
|
||||
, massMatrix_(massMatrix)
|
||||
, addedMass_(addedMass)
|
||||
@@ -19,7 +19,7 @@ RigidBody6DOF::RigidBody6DOF(int id,
|
||||
, q_(initialQ)
|
||||
, qdot_(initialQdot)
|
||||
, qddot_{}
|
||||
, integrator_(std::make_unique<CvodeIntegrator>(config))
|
||||
, integrator_(std::make_unique<RK45Integrator>(config))
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
#include <ymir/physics/integrator/CvodeIntegrator.h>
|
||||
|
||||
#include <ymir/physics/RigidBody6DOF.h>
|
||||
#include <ymir/physics/ForceModel.h>
|
||||
#include <ymir/physics/Forces.h>
|
||||
|
||||
#include <sundials/sundials_context.h>
|
||||
#include <cvode/cvode.h>
|
||||
#include <nvector/nvector_serial.h>
|
||||
#include <sunmatrix/sunmatrix_dense.h>
|
||||
#include <sunlinsol/sunlinsol_dense.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace ymir
|
||||
{
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RHS — CVODE callback (static member → has friend access to RigidBody6DOF)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int CvodeIntegrator::rhsCallback(sunrealtype t_rhs, N_Vector y, N_Vector ydot, void* user_data)
|
||||
{
|
||||
auto* ctx = static_cast<CvodeIntegrator::RhsContext*>(user_data);
|
||||
RigidBody6DOF& body = *ctx->body;
|
||||
|
||||
// 1. Extract q and qdot from CVODE state vector
|
||||
Vector6 q{}, qdot{};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
q[i] = NV_Ith_S(y, i);
|
||||
qdot[i] = NV_Ith_S(y, i + 6);
|
||||
}
|
||||
|
||||
// 2. Update body state so ForceModels read current kinematics
|
||||
body.setState(q, qdot);
|
||||
|
||||
// 3. Sum all forces (pass t_rhs as the evaluation time, not body.time())
|
||||
BodyState state{q, qdot, static_cast<double>(t_rhs), 0.0};
|
||||
Forces Ft = Forces::zero();
|
||||
for (ForceModel* model : *ctx->models)
|
||||
Ft += model->compute(state);
|
||||
|
||||
// 4. Compute acceleration: qddot = invM * Ft
|
||||
Vector6 qddot = body.computeAcceleration(Ft);
|
||||
body.setAcceleration(qddot);
|
||||
|
||||
// 5. Kinematic transformation: ydot[0..5] (body → inertial)
|
||||
const double psi = q[5]; // yaw
|
||||
NV_Ith_S(ydot, 0) = std::cos(psi) * qdot[0] - std::sin(psi) * qdot[1]; // ẋ
|
||||
NV_Ith_S(ydot, 1) = std::sin(psi) * qdot[0] + std::cos(psi) * qdot[1]; // ẏ
|
||||
NV_Ith_S(ydot, 2) = qdot[2]; // ż
|
||||
// ASSUMPTION: small-angle — Euler rates ≈ body angular rates
|
||||
NV_Ith_S(ydot, 3) = qdot[3]; // φ̇ ≈ p
|
||||
NV_Ith_S(ydot, 4) = qdot[4]; // θ̇ ≈ q
|
||||
NV_Ith_S(ydot, 5) = qdot[5]; // ψ̇ = r
|
||||
|
||||
// 6. ydot[6..11] = qddot
|
||||
for (int i = 0; i < 6; ++i)
|
||||
NV_Ith_S(ydot, 6 + i) = qddot[i];
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CvodeIntegrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
CvodeIntegrator::CvodeIntegrator(const CvodeConfig& config)
|
||||
: config_(config)
|
||||
{
|
||||
if (SUNContext_Create(SUN_COMM_NULL, &sunCtx_) != 0)
|
||||
throw std::runtime_error("CvodeIntegrator: SUNContext_Create failed");
|
||||
|
||||
mem_ = CVodeCreate(CV_BDF, sunCtx_);
|
||||
if (!mem_)
|
||||
throw std::runtime_error("CvodeIntegrator: CVodeCreate failed");
|
||||
}
|
||||
|
||||
CvodeIntegrator::~CvodeIntegrator()
|
||||
{
|
||||
if (LS_) SUNLinSolFree(LS_);
|
||||
if (A_) SUNMatDestroy(A_);
|
||||
if (y_) N_VDestroy(y_);
|
||||
if (mem_) CVodeFree(&mem_);
|
||||
if (sunCtx_) SUNContext_Free(&sunCtx_);
|
||||
}
|
||||
|
||||
void CvodeIntegrator::initialize(RigidBody6DOF& body, std::vector<ForceModel*>& models)
|
||||
{
|
||||
ctx_ = RhsContext{&body, &models};
|
||||
t_ = 0.0;
|
||||
|
||||
y_ = N_VNew_Serial(12, sunCtx_);
|
||||
if (!y_)
|
||||
throw std::runtime_error("CvodeIntegrator: N_VNew_Serial failed");
|
||||
|
||||
const Vector6& q = body.q();
|
||||
const Vector6& qdot = body.qdot();
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
NV_Ith_S(y_, i) = q[i];
|
||||
NV_Ith_S(y_, i + 6) = qdot[i];
|
||||
}
|
||||
|
||||
if (CVodeInit(mem_, CvodeIntegrator::rhsCallback, t_, y_) != CV_SUCCESS)
|
||||
throw std::runtime_error("CvodeIntegrator: CVodeInit failed");
|
||||
|
||||
if (CVodeSStolerances(mem_, config_.reltol, config_.abstol) != CV_SUCCESS)
|
||||
throw std::runtime_error("CvodeIntegrator: CVodeSStolerances failed");
|
||||
|
||||
if (CVodeSetMaxNumSteps(mem_, config_.maxSteps) != CV_SUCCESS)
|
||||
throw std::runtime_error("CvodeIntegrator: CVodeSetMaxNumSteps failed");
|
||||
|
||||
if (config_.maxStep > 0.0)
|
||||
CVodeSetMaxStep(mem_, config_.maxStep);
|
||||
|
||||
CVodeSetUserData(mem_, &ctx_);
|
||||
|
||||
A_ = SUNDenseMatrix(12, 12, sunCtx_);
|
||||
if (!A_)
|
||||
throw std::runtime_error("CvodeIntegrator: SUNDenseMatrix failed");
|
||||
|
||||
LS_ = SUNLinSol_Dense(y_, A_, sunCtx_);
|
||||
if (!LS_)
|
||||
throw std::runtime_error("CvodeIntegrator: SUNLinSol_Dense failed");
|
||||
|
||||
if (CVodeSetLinearSolver(mem_, LS_, A_) != CVLS_SUCCESS)
|
||||
throw std::runtime_error("CvodeIntegrator: CVodeSetLinearSolver failed");
|
||||
}
|
||||
|
||||
void CvodeIntegrator::step(RigidBody6DOF& body, double dt)
|
||||
{
|
||||
double t_target = t_ + dt;
|
||||
|
||||
int flag = CVode(mem_, t_target, y_, &t_, CV_NORMAL);
|
||||
if (flag < 0)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
"CvodeIntegrator: CVode failed with flag " + std::to_string(flag));
|
||||
}
|
||||
|
||||
Vector6 q{}, qdot{};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
q[i] = NV_Ith_S(y_, i);
|
||||
qdot[i] = NV_Ith_S(y_, i + 6);
|
||||
}
|
||||
body.setState(q, qdot);
|
||||
}
|
||||
|
||||
void CvodeIntegrator::reset(RigidBody6DOF& body)
|
||||
{
|
||||
const Vector6& q = body.q();
|
||||
const Vector6& qdot = body.qdot();
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
NV_Ith_S(y_, i) = q[i];
|
||||
NV_Ith_S(y_, i + 6) = qdot[i];
|
||||
}
|
||||
t_ = 0.0;
|
||||
CVodeReInit(mem_, t_, y_);
|
||||
}
|
||||
|
||||
} // namespace ymir
|
||||
Reference in new issue
Block a user