Files
Ymir/core/tests/simulation/TestNavalDomain.cpp
T
vinihcrosaandClaude Sonnet 4.6 6295d540a4 feat: migrate to monorepo with WASM core, Fastify API, and React frontend
- Move C++ sources to core/ subdirectory (T05)
- Add pnpm workspaces + Turborepo pipeline (T06)
- Add Emscripten CMake target and build-wasm.sh (T07)
- Implement Embind bindings via YmirBindings.cpp (T08)
- Add packages/types with TypeBox schemas for VesselDTO, ScenarioDTO, SimulationStateDTO (T09)
- Create apps/api: Fastify v4 + Drizzle ORM + SQLite with vessels/scenarios CRUD (T10-T13)
- Create apps/web: Vite + React 18 + COOP/COEP headers (T14)
- Add simulation.worker.ts for WASM loop in Web Worker (T15)
- Add Zustand simulationStore (T16)
- Add TelemetryPanel component (T17)
- Verified: pnpm build 3/3 tasks, 21ms cached; ctest 211/211 pass (T18-T19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 00:08:28 -03:00

333 lines
10 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <ymir/simulation/NavalDomain.h>
#include <ymir/simulation/CouplingForceModel.h>
#include <ymir/world/Environment.h>
#include <ymir/physics/RigidBody6DOF.h>
#include <ymir/common/Types.h>
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
#include <ymir/simulation/NavalSimulation.h>
#pragma clang diagnostic pop
#include <cmath>
#include <memory>
using Catch::Approx;
static std::unique_ptr<ymir::RigidBody6DOF> makeBody(
int id = 0, double m = 1e6,
ymir::Vector6 q = {}, ymir::Vector6 qdot = {},
const ymir::CvodeConfig& cfg = {})
{
ymir::Matrix6x6 mass{};
for (int i = 0; i < 6; ++i) mass[i][i] = m;
ymir::Matrix6x6 added{};
return std::make_unique<ymir::RigidBody6DOF>(id, mass, added, q, qdot, cfg);
}
static ymir::CvodeConfig defaultCfg()
{
ymir::CvodeConfig cfg{};
cfg.reltol = 1e-6;
cfg.abstol = 1e-9;
return cfg;
}
// -----------------------------------------------------------------------
// name()
// -----------------------------------------------------------------------
TEST_CASE("NavalDomain: name() returns string passed to constructor")
{
ymir::NavalDomain d("my_domain");
REQUIRE(d.name() == "my_domain");
}
TEST_CASE("NavalDomain: name() default is 'naval'")
{
ymir::NavalDomain d;
REQUIRE(d.name() == "naval");
}
// -----------------------------------------------------------------------
// onAddedToWorld / step without crash
// -----------------------------------------------------------------------
TEST_CASE("NavalDomain: step(0.1) completes without crash after onAddedToWorld (zero-force body)")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, {}, {}, defaultCfg()));
domain.onAddedToWorld(env, coupling);
domain.initialize();
domain.step(0.1);
REQUIRE(domain.time() == Approx(0.1).margin(1e-10));
}
// -----------------------------------------------------------------------
// allBodyPositions
// -----------------------------------------------------------------------
TEST_CASE("NavalDomain: allBodyPositions returns 2 entries for 2 bodies")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, {}, {}, defaultCfg()));
domain.addBody(1, makeBody(1, 1e6, {}, {}, defaultCfg()));
domain.onAddedToWorld(env, coupling);
domain.initialize();
auto positions = domain.allBodyPositions();
REQUIRE(positions.size() == 2);
}
TEST_CASE("NavalDomain: allBodyPositions ids match registered body ids")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::NavalDomain domain;
domain.addBody(3, makeBody(3, 1e6, {}, {}, defaultCfg()));
domain.addBody(7, makeBody(7, 1e6, {}, {}, defaultCfg()));
domain.onAddedToWorld(env, coupling);
domain.initialize();
auto positions = domain.allBodyPositions();
REQUIRE(positions.size() == 2);
bool found3 = false, found7 = false;
for (const auto& bp : positions)
{
if (bp.id == 3) found3 = true;
if (bp.id == 7) found7 = true;
}
REQUIRE(found3);
REQUIRE(found7);
}
TEST_CASE("NavalDomain: allBodyPositions xyz match state(id).q()[0..2]")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::Vector6 q0{}; q0[0] = 10.0; q0[1] = 20.0; q0[2] = -5.0;
ymir::Vector6 q1{}; q1[0] = -3.0; q1[1] = 7.0; q1[2] = 2.0;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, q0, {}, defaultCfg()));
domain.addBody(1, makeBody(1, 1e6, q1, {}, defaultCfg()));
domain.onAddedToWorld(env, coupling);
domain.initialize();
auto positions = domain.allBodyPositions();
for (const auto& bp : positions)
{
auto st = domain.state(bp.id);
REQUIRE(bp.x == Approx(st.q()[0]));
REQUIRE(bp.y == Approx(st.q()[1]));
REQUIRE(bp.z == Approx(st.q()[2]));
}
}
// -----------------------------------------------------------------------
// distanceBetween
// -----------------------------------------------------------------------
TEST_CASE("NavalDomain: distanceBetween returns correct Euclidean distance")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::Vector6 q0{}; q0[0] = 0.0; q0[1] = 0.0; q0[2] = 0.0;
ymir::Vector6 q1{}; q1[0] = 3.0; q1[1] = 4.0; q1[2] = 0.0;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, q0, {}, defaultCfg()));
domain.addBody(1, makeBody(1, 1e6, q1, {}, defaultCfg()));
domain.onAddedToWorld(env, coupling);
domain.initialize();
// Pythagorean triple: 3-4-5
REQUIRE(domain.distanceBetween(0, 1) == Approx(5.0).margin(1e-10));
}
TEST_CASE("NavalDomain: distanceBetween 3D distance matches expected")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::Vector6 q0{}; q0[0] = 1.0; q0[1] = 2.0; q0[2] = 3.0;
ymir::Vector6 q1{}; q1[0] = 4.0; q1[1] = 6.0; q1[2] = 3.0;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, q0, {}, defaultCfg()));
domain.addBody(1, makeBody(1, 1e6, q1, {}, defaultCfg()));
domain.onAddedToWorld(env, coupling);
domain.initialize();
// distance = sqrt(9 + 16 + 0) = 5
REQUIRE(domain.distanceBetween(0, 1) == Approx(5.0).margin(1e-10));
}
// -----------------------------------------------------------------------
// bodyState / state
// -----------------------------------------------------------------------
TEST_CASE("NavalDomain: bodyState matches state for registered body")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, {}, {}, defaultCfg()));
domain.onAddedToWorld(env, coupling);
domain.initialize();
auto s1 = domain.state(0);
auto s2 = domain.bodyState(0);
for (int i = 0; i < 6; ++i)
{
REQUIRE(s1.q()[i] == Approx(s2.q()[i]));
REQUIRE(s1.qdot()[i] == Approx(s2.qdot()[i]));
}
}
TEST_CASE("NavalDomain: state throws for unknown bodyId")
{
ymir::NavalDomain domain;
REQUIRE_THROWS_AS(domain.state(999), std::out_of_range);
}
// -----------------------------------------------------------------------
// Coupling force injection
// -----------------------------------------------------------------------
TEST_CASE("NavalDomain: body with CouplingForceModel receives resolved force after step")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
coupling.addLink(1, 0); // producer=1, consumer=0
const ymir::Forces injected = []() {
ymir::Forces f = ymir::Forces::zero();
f.f[0] = 500.0; // surge force
return f;
}();
coupling.writeForce(1, injected);
coupling.resolve();
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, {}, {}, defaultCfg()));
domain.addNavalForceModel(0, std::make_unique<ymir::CouplingForceModel>(0, coupling));
domain.onAddedToWorld(env, coupling);
domain.initialize();
domain.step(0.1);
// Body 0 received a surge force of 500 N, mass = 1e6 kg -> tiny but nonzero acceleration
// After step(0.1), surge velocity should be slightly above 0
auto st = domain.state(0);
REQUIRE(st.u() > 0.0);
}
// -----------------------------------------------------------------------
// Regression: NavalDomain vs NavalSimulation
// -----------------------------------------------------------------------
TEST_CASE("NavalDomain regression: matches NavalSimulation within 1e-8 over 1000 steps")
{
const int STEPS = 1000;
const double DT = 0.1;
ymir::CvodeConfig cfg = defaultCfg();
// --- NavalSimulation setup ---
ymir::naval::NavalSimulation sim;
sim.addBody(0, makeBody(0, 1e6, {}, {}, cfg));
sim.initialize();
// --- NavalDomain setup ---
ymir::Environment env; // default: zero wind/current, waterDepth=100
ymir::CouplingRegistry coupling;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0, 1e6, {}, {}, cfg));
domain.onAddedToWorld(env, coupling);
domain.initialize();
for (int i = 0; i < STEPS; ++i)
{
sim.step(DT);
domain.step(DT);
auto qs = sim.state(0).q();
auto qd = domain.state(0).q();
auto qds = sim.state(0).qdot();
auto qdd = domain.state(0).qdot();
for (int dof = 0; dof < 6; ++dof)
{
REQUIRE(std::abs(qd[dof] - qs[dof]) < 1e-8);
REQUIRE(std::abs(qdd[dof] - qds[dof]) < 1e-8);
}
}
}
// ---------------------------------------------------------------------------
// serializeStateJson
// ---------------------------------------------------------------------------
TEST_CASE("NavalDomain: serializeStateJson produces valid JSON with expected keys", "[naval_domain]")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::NavalDomain domain;
ymir::Vector6 q0{}, qd0{};
q0[0] = 5.0; q0[5] = 0.1;
domain.addBody(0, makeBody(0, 1e6, q0, qd0));
domain.onAddedToWorld(env, coupling);
domain.initialize();
domain.step(0.1);
const std::string json = domain.serializeStateJson();
REQUIRE(json.find("\"t\":") != std::string::npos);
REQUIRE(json.find("\"vessels\":") != std::string::npos);
REQUIRE(json.find("\"id\":0") != std::string::npos);
REQUIRE(json.find("\"x\":") != std::string::npos);
REQUIRE(json.find("\"psi\":") != std::string::npos);
REQUIRE(json.find("\"u\":") != std::string::npos);
REQUIRE(json.find("\"r\":") != std::string::npos);
REQUIRE(json.front() == '{');
REQUIRE(json.back() == '}');
}
TEST_CASE("NavalDomain: serializeStateJson t advances with steps", "[naval_domain]")
{
ymir::Environment env;
ymir::CouplingRegistry coupling;
ymir::NavalDomain domain;
domain.addBody(0, makeBody(0));
domain.onAddedToWorld(env, coupling);
domain.initialize();
domain.step(0.5);
domain.step(0.5);
const std::string json = domain.serializeStateJson();
const std::size_t tpos = json.find("\"t\":") + 4;
const double t = std::stod(json.substr(tpos));
REQUIRE(t == Approx(1.0).margin(1e-9));
}