Files
Ymir/core/tests/simulation/TestActuatorIntegration.cpp
vinihcrosaandClaude Opus 4.8 90006bc246 feat(physics): match rudder forces to MATLAB Dynamics (table + slipstream)
Replace analytic thin-airfoil Cl/Cd and simplified actuator-disk inflow in
RudderForces with the MATLAB rudder.m model:

- Interpolate Cl/Cd from the rudder coefficient table vs inflow incidence beta,
  computed from actual longitudinal + transversal inflow (current, sway,
  yaw-rate lever).
- Propeller slipstream: p1 geometric factor x p2 actuator-disk term with hull
  wake efficiency; degrades to free-stream when the propeller is unloaded.
- RudderConfig carries coefficients table, thrusterDiameter, hullEfficiency,
  p1 (drops unused aspectRatio).
- Wire a representative balanced-rudder table + computed p1 through the WASM
  binding.

Add Catch2 tests: Cl/Cd table lookup at known beta (exact node + interpolated
midpoint) and slipstream amplification with an associated thruster. Update
existing rudder/integration test configs for the new config shape. Full ctest
green (257/257).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:34:14 -03:00

455 lines
17 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TestActuatorIntegration.cpp
*
* Integration tests verifying that thruster and rudder actuator commands produce
* real motion in NavalDomain. These tests exist because the WASM bindings had
* stub setThrusterCommand / setRudderAngle methods and vessels were not moving.
*/
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <ymir/simulation/NavalDomain.h>
#include <ymir/world/World.h>
#include <ymir/physics/RigidBody6DOF.h>
#include <ymir/physics/forces/ThrustForces.h>
#include <ymir/physics/forces/RudderForces.h>
#include <ymir/physics/forces/DampingForces.h>
#include <ymir/physics/forces/CurrentForces.h>
#include <ymir/physics/forces/InertialForces.h>
#include <ymir/physics/forces/RestoringForces.h>
#include <ymir/common/Types.h>
#include <cmath>
#include <memory>
using Catch::Approx;
using namespace ymir::naval;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static ymir::CvodeConfig defaultCfg()
{
ymir::CvodeConfig cfg{};
cfg.reltol = 1e-4;
cfg.abstol = 1e-6;
cfg.maxStep = 0.005; // 5ms max internal step
cfg.maxSteps = 100000;
return cfg;
}
static std::unique_ptr<ymir::RigidBody6DOF> makeBody(int id, double mass = 1e5)
{
ymir::Matrix6x6 M{};
for (int i = 0; i < 6; ++i) M[i][i] = mass;
ymir::Matrix6x6 A{};
ymir::Vector6 q{};
ymir::Vector6 qdot{};
return std::make_unique<ymir::RigidBody6DOF>(id, M, A, q, qdot, defaultCfg());
}
// Body with realistic rotational inertia for a 100m vessel — prevents stiff dynamics
// when CurrentForces (Obokata) applies yaw/pitch moments via Cdz coefficients.
static std::unique_ptr<ymir::RigidBody6DOF> makeRealisticBody(int id, double mass = 1e5)
{
ymir::Matrix6x6 M{};
M[0][0] = mass;
M[1][1] = mass;
M[2][2] = mass;
M[3][3] = mass * 5.0; // roll ~ I = m*B²/12 for B=25m
M[4][4] = mass * 833.3; // pitch ~ I = m*L²/12 for L=100m
M[5][5] = mass * 867.0; // yaw ~ I = m*(L²+B²)/12
ymir::Matrix6x6 A{};
ymir::Vector6 q{};
ymir::Vector6 qdot{};
return std::make_unique<ymir::RigidBody6DOF>(id, M, A, q, qdot, defaultCfg());
}
static ThrustForces::Config singleThrusterCfg()
{
ThrustForces::Config c{};
ThrustForces::ThrusterConfig tc{};
tc.position = {-50.0, 0.0, -3.0};
tc.diameter = 5.0;
tc.pitchRatio = 0.8;
tc.nominalRPM = 120.0;
tc.azimuth_deg = 0.0;
tc.initialRPM = 0.0;
c.thrusters.push_back(tc);
return c;
}
static CurrentForces::Config simpleCurrentCfg(double length = 100.0, double beam = 20.0)
{
CurrentForces::Config c{};
c.model = ymir::naval::CurrentModel::OBOKATA;
c.length_BP = length;
c.beam = beam;
c.frontalHeight = 5.0;
c.lateralHeight = 5.0;
c.n_sections = 10;
c.angles = {0.0, 90.0, 180.0, 270.0, 360.0};
// Surge: positive resistance from ahead (0°) and astern (180°)
c.cdx = {0.05, 0.0, -0.05, 0.0, 0.05};
c.cdy = {0.0, 1.0, 0.0, -1.0, 0.0};
c.cdz = {0.0, 0.0, 0.0, 0.0, 0.0};
return c;
}
// Representative balanced-rudder coefficient table {angle_deg, Cl, Cd} over the
// full inflow range. Cl = 1.2·sin 2β (Cl_max ≈ 1.2), Cd = 0.05 + 0.3·(1 − cos 2β)
// (Cd ≈ 0.25 at 35°) — realistic magnitudes for a ship rudder.
static std::vector<std::array<double, 3>> foilTable()
{
std::vector<std::array<double, 3>> t;
for (int a = 0; a <= 360; a += 5)
{
const double ar = a * M_PI / 180.0;
t.push_back({static_cast<double>(a),
1.3 * std::sin(2.0 * ar),
0.04 + 0.10 * (1.0 - std::cos(2.0 * ar))});
}
return t;
}
static RudderForces::Config singleRudderCfg()
{
RudderForces::Config c{};
RudderForces::RudderConfig rc{};
rc.position = {-52.0, 0.0, -3.0};
rc.area = 20.0;
rc.thrusterIdx = 0;
rc.coefficients = foilTable();
c.rudders.push_back(rc);
return c;
}
// ---------------------------------------------------------------------------
// Test: thruster at full RPM produces surge (forward motion)
// ---------------------------------------------------------------------------
TEST_CASE("Actuator integration: full-RPM thruster produces positive surge",
"[actuator][integration]")
{
ymir::World world;
auto domainOwned = std::make_unique<ymir::NavalDomain>("naval");
ymir::NavalDomain* domain = domainOwned.get();
world.addDomain(std::move(domainOwned));
domain->addBody(0, makeBody(0));
auto thrust = std::make_unique<ThrustForces>(singleThrusterCfg());
ThrustForces* thrustPtr = thrust.get();
domain->addNavalForceModel(0, std::move(thrust));
domain->initialize();
// Set thruster to 100% → 120 RPM
thrustPtr->setActuatorState(0, ThrustForces::ThrusterCommand{120.0, 0.0, 0.8});
// Step 10 s
for (int i = 0; i < 100; ++i)
world.step(0.1);
auto st = domain->state(0);
REQUIRE(st.u() > 0.0); // positive surge velocity
REQUIRE(st.q()[0] > 0.0); // positive surge position
}
// ---------------------------------------------------------------------------
// Test: zero RPM → vessel stays at rest
// ---------------------------------------------------------------------------
TEST_CASE("Actuator integration: zero-RPM thruster leaves vessel at rest",
"[actuator][integration]")
{
ymir::World world;
auto domainOwned = std::make_unique<ymir::NavalDomain>("naval");
ymir::NavalDomain* domain = domainOwned.get();
world.addDomain(std::move(domainOwned));
domain->addBody(0, makeBody(0));
auto thrust = std::make_unique<ThrustForces>(singleThrusterCfg());
ThrustForces* thrustPtr = thrust.get();
domain->addNavalForceModel(0, std::move(thrust));
domain->initialize();
thrustPtr->setActuatorState(0, ThrustForces::ThrusterCommand{0.0, 0.0, 0.8});
for (int i = 0; i < 50; ++i)
world.step(0.1);
auto st = domain->state(0);
REQUIRE(st.u() == Approx(0.0).margin(1e-6));
REQUIRE(st.q()[0] == Approx(0.0).margin(1e-6));
}
// ---------------------------------------------------------------------------
// Test: rudder at +20° with forward speed produces positive yaw rate
// ---------------------------------------------------------------------------
TEST_CASE("Actuator integration: rudder deflection with forward speed produces yaw",
"[actuator][integration]")
{
ymir::World world;
auto domainOwned = std::make_unique<ymir::NavalDomain>("naval");
ymir::NavalDomain* domain = domainOwned.get();
world.addDomain(std::move(domainOwned));
domain->addBody(0, makeBody(0));
auto thrust = std::make_unique<ThrustForces>(singleThrusterCfg());
ThrustForces* thrustPtr = thrust.get();
domain->addNavalForceModel(0, std::move(thrust));
auto rudder = std::make_unique<RudderForces>(singleRudderCfg(), thrustPtr);
RudderForces* rudderPtr = rudder.get();
domain->addNavalForceModel(0, std::move(rudder));
domain->initialize();
// Build up forward speed first
thrustPtr->setActuatorState(0, ThrustForces::ThrusterCommand{120.0, 0.0, 0.8});
for (int i = 0; i < 50; ++i)
world.step(0.1);
REQUIRE(domain->state(0).u() > 0.5); // vessel must be moving before rudder takes effect
// Now apply +20° rudder (starboard)
constexpr double kDegToRad = M_PI / 180.0;
rudderPtr->setActuatorState(0, RudderForces::RudderCommand{20.0 * kDegToRad});
// Step another 5 s
for (int i = 0; i < 50; ++i)
world.step(0.1);
auto st = domain->state(0);
REQUIRE(st.r() != Approx(0.0).margin(1e-6)); // non-zero yaw rate
REQUIRE(st.q()[5] != Approx(0.0).margin(1e-6)); // heading changed
}
// ---------------------------------------------------------------------------
// Test: powerPct→RPM conversion used in YmirBindings produces same result
// ---------------------------------------------------------------------------
TEST_CASE("Actuator integration: 50% power produces measurable thrust",
"[actuator][integration]")
{
ymir::World world;
auto domainOwned = std::make_unique<ymir::NavalDomain>("naval");
ymir::NavalDomain* domain = domainOwned.get();
world.addDomain(std::move(domainOwned));
domain->addBody(0, makeBody(0));
auto thrust = std::make_unique<ThrustForces>(singleThrusterCfg());
ThrustForces* thrustPtr = thrust.get();
domain->addNavalForceModel(0, std::move(thrust));
domain->initialize();
// Replicate YmirBindings power% → RPM conversion
constexpr double kNominalRPM = 120.0;
const double rpm = (50.0 / 100.0) * kNominalRPM; // 60 RPM
thrustPtr->setActuatorState(0, ThrustForces::ThrusterCommand{rpm, 0.0, 0.8});
for (int i = 0; i < 100; ++i)
world.step(0.1);
REQUIRE(domain->state(0).u() > 0.0);
REQUIRE(domain->state(0).q()[0] > 0.0);
}
// ---------------------------------------------------------------------------
// Test: vessel decelerates when thruster cut to zero (CurrentForces provides drag)
// ---------------------------------------------------------------------------
TEST_CASE("Actuator integration: vessel decelerates after thruster cut to zero",
"[actuator][integration]")
{
ymir::World world;
auto domainOwned = std::make_unique<ymir::NavalDomain>("naval");
ymir::NavalDomain* domain = domainOwned.get();
world.addDomain(std::move(domainOwned));
domain->addBody(0, makeRealisticBody(0));
// Hull drag (Obokata) — provides surge resistance proportional to velocity²
domain->addNavalForceModel(0, std::make_unique<CurrentForces>(simpleCurrentCfg()));
auto thrust = std::make_unique<ThrustForces>(singleThrusterCfg());
ThrustForces* thrustPtr = thrust.get();
domain->addNavalForceModel(0, std::move(thrust));
domain->initialize();
// Build up speed with moderate thrust (low RPM avoids unrealistically high velocities)
thrustPtr->setActuatorState(0, ThrustForces::ThrusterCommand{40.0, 0.0, 0.8});
for (int i = 0; i < 20; ++i)
world.step(0.1);
const double uAfterThrust = domain->state(0).u();
REQUIRE(uAfterThrust > 0.01); // must be moving
// Cut thruster to zero
thrustPtr->setActuatorState(0, ThrustForces::ThrusterCommand{0.0, 0.0, 0.8});
// Step 5 more seconds — hull drag must reduce velocity
for (int i = 0; i < 50; ++i)
world.step(0.1);
const double uAfterCoast = domain->state(0).u();
REQUIRE(uAfterCoast < uAfterThrust); // vessel must have slowed down
REQUIRE(uAfterCoast >= 0.0); // must not reverse or blow up
}
// ---------------------------------------------------------------------------
// Test: max thrust + max rudder stays stable (regression for WASM integrator crash)
// ---------------------------------------------------------------------------
static std::unique_ptr<ymir::RigidBody6DOF> makeVlccBody(int id)
{
// Full VLCC mass + added mass (diagonal only, sufficient for stability test)
ymir::Matrix6x6 M{};
M[0][0] = 450150.0; M[1][1] = 450150.0; M[2][2] = 450150.0;
M[3][3] = 232980000.0; M[4][4] = 3505800000.0; M[5][5] = 3738800000.0;
ymir::Matrix6x6 A{};
A[0][0] = 53272.7; A[1][1] = 223849.0; A[2][2] = 3618741.0;
A[3][3] = 67394070.0; A[4][4] = 9546841000.0; A[5][5] = 2809140000.0;
ymir::Vector6 q{}, qdot{};
return std::make_unique<ymir::RigidBody6DOF>(id, M, A, q, qdot, defaultCfg());
}
static InertialConfig vlccInertialCfg()
{
InertialConfig cfg{};
ymir::Matrix6x6 M{};
M[0][0]=450150.0; M[1][1]=450150.0; M[2][2]=450150.0;
M[3][3]=232980000.0; M[4][4]=3505800000.0; M[5][5]=3738800000.0;
ymir::Matrix6x6 A{};
A[0][0]=53272.7; A[1][1]=223849.0; A[2][2]=3618741.0;
A[3][3]=67394070.0; A[4][4]=9546841000.0; A[5][5]=2809140000.0;
for (int i = 0; i < 6; ++i)
for (int j = 0; j < 6; ++j)
cfg.totalMass[i][j] = M[i][j] + A[i][j];
cfg.addedMass = A;
cfg.cg = {0.691, 0.0, 19.2};
cfg.mass = 450150.0;
return cfg;
}
static DampingForces::Config vlccDampingCfg()
{
DampingForces::Config cfg{};
cfg.linearDampingCoeff = 0.5;
// Full potential damping matrix from vessel1.json (WAMIT)
cfg.potential[0][0] = 1.96952; cfg.potential[0][2] = -2.76792; cfg.potential[0][4] = 823.503;
cfg.potential[1][1] = 8.02247; cfg.potential[1][3] = -51.0285; cfg.potential[1][5] = 1.80281;
cfg.potential[2][0] = -1.47476; cfg.potential[2][2] = 201088.9; cfg.potential[2][4] = 2779191.0;
cfg.potential[3][1] = -50.7984; cfg.potential[3][3] = 3291989.0; cfg.potential[3][5] = -11.0002;
cfg.potential[4][0] = 836.414; cfg.potential[4][2] = 2779092.0; cfg.potential[4][4] = 1393379000.0;
cfg.potential[5][1] = 2.05521; cfg.potential[5][3] = -12.6653; cfg.potential[5][5] = 20.7451;
cfg.linear[0][0] = 3.0;
cfg.linear[3][3] = 12854400.0;
cfg.quadratic[5][5] = 16446722225.0;
return cfg;
}
static RestoringConfig vlccRestoringCfg()
{
RestoringConfig cfg{};
cfg.hydro_rest[2][2] = 213018.0;
cfg.hydro_rest[3][3] = 36249900.0;
cfg.hydro_rest[4][4] = 1952870000.0;
cfg.draft = 23.0;
cfg.mass = 450150.0;
cfg.volumetricWeight = 4355971.5;
cfg.cg = {0.691, 0.0, 19.2};
cfg.cf = {0.6909, 0.0, -7.09715};
return cfg;
}
static CurrentForces::Config vlccCurrentCfg()
{
CurrentForces::Config c{};
c.model = ymir::naval::CurrentModel::OBOKATA;
c.length_BP = 350.0;
c.beam = 63.0;
c.frontalHeight = 11.5;
c.lateralHeight = 11.5;
c.n_sections = 50;
c.angles = { 0, 10, 20, 30, 40, 50, 60, 70, 80, 90,
100,110,120,130,140,150,160,170,180,190,
200,210,220,230,240,250,260,270,280,290,
300,310,320,330,340,350,360};
c.cdx = { 0.0100, 0.0069,-0.0016,-0.0130,-0.0239,-0.0312,-0.0324,-0.0267,-0.0151, 0.0000,
0.0151, 0.0267, 0.0324, 0.0312, 0.0239, 0.0130, 0.0016,-0.0069,-0.0100,-0.0069,
0.0016, 0.0130, 0.0239, 0.0312, 0.0324, 0.0267, 0.0151, 0.0000,-0.0151,-0.0267,
-0.0324,-0.0312,-0.0239,-0.0130,-0.0016, 0.0069, 0.0100};
c.cdy = { 0.0000, 0.1223, 0.3055, 0.5298, 0.7697, 0.9966, 1.1826, 1.3041, 1.3452, 1.3000,
1.3452, 1.3041, 1.1826, 0.9966, 0.7697, 0.5298, 0.3055, 0.1223, 0.0000,-0.1223,
-0.3055,-0.5298,-0.7697,-0.9966,-1.1826,-1.3041,-1.3452,-1.3000,-1.3452,-1.3041,
-1.1826,-0.9966,-0.7697,-0.5298,-0.3055,-0.1223, 0.0000};
return c;
}
TEST_CASE("Actuator integration: VLCC max thrust + max rudder stays stable (no integrator crash)",
"[actuator][integration][vlcc]")
{
ymir::World world;
auto domainOwned = std::make_unique<ymir::NavalDomain>("naval");
ymir::NavalDomain* domain = domainOwned.get();
world.addDomain(std::move(domainOwned));
domain->addBody(0, makeVlccBody(0));
domain->addNavalForceModel(0, std::make_unique<InertialForces>(vlccInertialCfg()));
domain->addNavalForceModel(0, std::make_unique<DampingForces>(vlccDampingCfg()));
domain->addNavalForceModel(0, std::make_unique<RestoringForces>(vlccRestoringCfg()));
domain->addNavalForceModel(0, std::make_unique<CurrentForces>(vlccCurrentCfg()));
// Thruster: VLCC propeller
ThrustForces::Config tCfg{};
ThrustForces::ThrusterConfig tc{};
tc.position = {-170.0, 0.0, 4.5}; tc.diameter = 9.2;
tc.pitchRatio = 0.6145; tc.nominalRPM = 81.0; tc.azimuth_deg = 0.0; tc.initialRPM = 0.0;
tCfg.thrusters.push_back(tc);
auto thrust = std::make_unique<ThrustForces>(tCfg);
ThrustForces* thrustPtr = thrust.get();
domain->addNavalForceModel(0, std::move(thrust));
// Rudder: VLCC balanced rudder
RudderForces::Config rCfg{};
RudderForces::RudderConfig rc{};
rc.position = {-171.0, 0.0, 6.0}; rc.area = 60.0; rc.thrusterIdx = 0; rc.coefficients = foilTable();
rCfg.rudders.push_back(rc);
auto rudder = std::make_unique<RudderForces>(rCfg, thrustPtr);
RudderForces* rudderPtr = rudder.get();
domain->addNavalForceModel(0, std::move(rudder));
domain->initialize();
// MAX thrust (81 RPM) + MAX rudder (35°)
constexpr double kMaxRPM = 81.0;
constexpr double kMaxRudder = 35.0 * M_PI / 180.0;
thrustPtr->setActuatorState(0, ThrustForces::ThrusterCommand{kMaxRPM, 0.0, 0.6145});
rudderPtr->setActuatorState(0, RudderForces::RudderCommand{kMaxRudder});
// Step 30 s — must NOT throw; velocities must remain finite
REQUIRE_NOTHROW([&](){
for (int i = 0; i < 600; ++i)
world.step(0.05);
}());
auto st = domain->state(0);
REQUIRE(std::isfinite(st.u()));
REQUIRE(std::isfinite(st.v()));
REQUIRE(std::isfinite(st.r()));
REQUIRE(st.u() > 0.0); // vessel moving forward
}