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:
vinihcrosaandClaude Sonnet 4.6 committed 2026-06-27 23:49:11 -03:00
1 parent 1474b0cf4c
commit 6196f0ca0e
9 files changed
+107 -270

No files matched your search

+91
View File
@@ -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
-6
View File
@@ -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)
# ---------------------------------------------------------------------------
BIN
View File
Binary file not shown.
-2
View File
@@ -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
+3 -3
View File
@@ -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