Files
track-core/tests/track_core_tests.cpp
T
crosstyan 1005e50be0 feat(track-core): add portable training runtimes
Move scheme and PID training runtime behavior into the pure track_core layer and expose render sinks for injected strip application.

Add ESP compatibility adapters, Python bindings/test scaffolding, and in-memory render support so app_track_bt can consume core render and runtime logic without duplicating it.

Cover circular/linear rendering boundaries, all scheme runtime types, scheme render_to parity, PID sample de-duplication, speed suppression, and live tuning in track-core tests.
2026-05-18 16:15:45 +08:00

523 lines
22 KiB
C++

#include <cmath>
#include <cstdlib>
#include <iostream>
#include <vector>
#include "track_core/memory_strip.hpp"
#include "track_core/pid_program.hpp"
#include "track_core/pid_runtime.hpp"
#include "track_core/scheme_decoder.hpp"
#include "track_core/scheme_runtime.hpp"
namespace {
void require(bool condition, const char *message) {
if (!condition) {
std::cerr << "FAIL: " << message << '\n';
std::exit(1);
}
}
void require_eq_u32(std::uint32_t actual, std::uint32_t expected, const char *message) {
if (actual != expected) {
std::cerr << "FAIL: " << message << ": actual=" << actual << " expected=" << expected << '\n';
std::exit(1);
}
}
void require_near(float actual, float expected, float tolerance, const char *message) {
if (std::fabs(actual - expected) > tolerance) {
std::cerr << "FAIL: " << message << ": actual=" << actual << " expected=" << expected << '\n';
std::exit(1);
}
}
track_core::TrackInfo running_info(track_core::Color color = track_core::Color::green()) {
return {
.kind = track_core::SchemeKind::speed_input_time_segmented_mileage_free,
.color = color,
.id = 1,
.is_running = true,
.num_segments = 1,
};
}
void test_circular_render_wraps() {
track_core::TrackConfig config{
.draw_kind = track_core::TrackDrawKind::circular,
.line_length_m = 10.0F,
.active_line_length_m = 4.0F,
.head_offset_m = 0.0F,
.line_leds_num = 20,
};
const track_core::TrackReport report{
.id = 1,
.state = track_core::TrackState::run,
.mileage_m = 8.5F,
.speed_m_s = 1.0F,
};
const auto plan = track_core::make_track_render_plan(config, running_info(), report);
require_eq_u32(static_cast<std::uint32_t>(plan.span_count), 2, "circular span count");
require_eq_u32(plan.spans[0].start_led, 17, "circular span 0 start");
require_eq_u32(plan.spans[0].led_count, 3, "circular span 0 count");
require(plan.spans[0].color == track_core::Color::green(), "circular span 0 color");
require_eq_u32(plan.spans[1].start_led, 0, "circular span 1 start");
require_eq_u32(plan.spans[1].led_count, 5, "circular span 1 count");
}
void test_linear_render_forward_and_memory_strip() {
track_core::TrackConfig config{
.draw_kind = track_core::TrackDrawKind::linear,
.line_length_m = 10.0F,
.active_line_length_m = 4.0F,
.head_offset_m = 0.0F,
.line_leds_num = 20,
};
const track_core::TrackReport report{
.id = 2,
.state = track_core::TrackState::run,
.mileage_m = 5.0F,
.speed_m_s = 2.0F,
};
track_core::MemoryStrip strip(config.line_leds_num);
const auto plan = track_core::make_track_render_plan(config, running_info(track_core::Color::red()), report);
const auto applied = track_core::apply_render_plan(strip, plan);
require(applied == track_core::TrackError::ok, "linear plan applies");
require_eq_u32(static_cast<std::uint32_t>(plan.span_count), 4, "linear span count");
require_eq_u32(plan.spans[0].start_led, 10, "linear span 0 start");
require_eq_u32(plan.spans[0].led_count, 2, "linear span 0 count");
require(plan.spans[0].color == track_core::Color::red(), "linear span 0 color");
require_eq_u32(plan.spans[1].start_led, 12, "linear span 1 start");
require(plan.spans[1].color == track_core::Color::blue(), "linear span 1 color");
require_eq_u32(plan.spans[2].start_led, 6, "linear span 2 start");
require(plan.spans[2].color == track_core::Color::cyan(), "linear span 2 color");
require_eq_u32(plan.spans[3].start_led, 8, "linear span 3 start");
require(plan.spans[3].color == track_core::Color::red(), "linear span 3 color");
}
void test_linear_render_reverse() {
track_core::TrackConfig config{
.draw_kind = track_core::TrackDrawKind::linear,
.line_length_m = 10.0F,
.active_line_length_m = 5.0F,
.head_offset_m = 0.0F,
.line_leds_num = 20,
};
const track_core::TrackReport report{
.id = 3,
.state = track_core::TrackState::run,
.mileage_m = 15.0F,
.speed_m_s = 2.0F,
};
const auto plan = track_core::make_track_render_plan(config, running_info(track_core::Color::red()), report);
require_eq_u32(static_cast<std::uint32_t>(plan.span_count), 4, "reverse span count");
require_eq_u32(plan.spans[0].start_led, 5, "reverse span 0 start");
require_eq_u32(plan.spans[0].led_count, 2, "reverse span 0 count");
require(plan.spans[0].color == track_core::Color::blue(), "reverse span 0 color");
require_eq_u32(plan.spans[1].start_led, 7, "reverse span 1 start");
require_eq_u32(plan.spans[1].led_count, 3, "reverse span 1 count");
require(plan.spans[1].color == track_core::Color::red(), "reverse span 1 color");
require_eq_u32(plan.spans[2].start_led, 10, "reverse span 2 start");
require_eq_u32(plan.spans[2].led_count, 3, "reverse span 2 count");
require(plan.spans[2].color == track_core::Color::red(), "reverse span 2 color");
require_eq_u32(plan.spans[3].start_led, 13, "reverse span 3 start");
require_eq_u32(plan.spans[3].led_count, 2, "reverse span 3 count");
require(plan.spans[3].color == track_core::Color::cyan(), "reverse span 3 color");
}
void test_linear_render_boundaries_apply() {
track_core::TrackConfig config{
.draw_kind = track_core::TrackDrawKind::linear,
.line_length_m = 10.0F,
.active_line_length_m = 4.0F,
.head_offset_m = 0.0F,
.line_leds_num = 20,
};
const auto info = running_info(track_core::Color::red());
for (const float mileage : {
-20.0F, -10.1F, -10.0F, -9.9F, -1.0F, -0.1F,
0.0F, 0.1F, 1.0F, 9.9F, 10.0F, 10.1F,
19.9F, 20.0F, 20.1F, 29.9F, 30.0F, 30.1F,
40.0F,
}) {
const track_core::TrackReport report{
.id = 4,
.state = track_core::TrackState::run,
.mileage_m = mileage,
.speed_m_s = 2.0F,
};
track_core::MemoryStrip strip(config.line_leds_num);
const auto plan = track_core::make_track_render_plan(config, info, report);
require(plan.span_count <= track_core::TrackRenderPlan::max_spans, "linear boundary span capacity");
for (std::size_t i = 0; i < plan.span_count; ++i) {
const auto &span = plan.spans[i];
require(span.start_led < config.line_leds_num, "linear boundary span start in range");
require(span.start_led + span.led_count <= config.line_leds_num, "linear boundary span end in range");
}
require(
track_core::apply_render_plan(strip, plan) == track_core::TrackError::ok,
"linear boundary plan applies");
}
}
void test_memory_strip_bounds() {
track_core::MemoryStrip strip(4);
require(strip.fill(0, 0, track_core::Color::red()) == track_core::TrackError::ok, "zero fill");
require(strip.fill(2, 8, track_core::Color::blue()) == track_core::TrackError::ok, "clamped fill");
require(strip.pixels()[2] == track_core::Color::blue(), "clamped fill pixel 2");
require(strip.pixels()[3] == track_core::Color::blue(), "clamped fill pixel 3");
require(strip.fill(4, 1, track_core::Color::red()) == track_core::TrackError::range, "out-of-range fill");
}
void test_scheme_decoder() {
const std::vector<std::uint8_t> data{
static_cast<std::uint8_t>(track_core::SchemeKind::speed_input_time_segmented_mileage_free),
static_cast<std::uint8_t>(track_core::AccelerationProfile::instant),
2,
0, 0, 0,
51, 10, 0,
};
const auto decoded = track_core::decode_scheme(7, track_core::Color::white(), data);
require(decoded.has_value(), "decode ST scheme");
require(decoded->id == 7, "decoded id");
require(decoded->kind == track_core::SchemeKind::speed_input_time_segmented_mileage_free, "decoded kind");
const auto *segments = std::get_if<std::vector<track_core::STSegment>>(&decoded->segments);
require(segments != nullptr, "decoded ST segment vector");
require_eq_u32(static_cast<std::uint32_t>(segments->size()), 2, "decoded ST segment count");
require_near((*segments)[1].speed_m_s, 2.0F, 0.01F, "decoded ST speed");
require_eq_u32((*segments)[1].time_since_start_s, 10, "decoded ST time");
}
void test_pid_program_constant() {
track_core::TrackPidConfig config;
config.schemas = {
track_core::TrackPidSchema{track_core::TrackConstantSegment{
.duration_s = 2,
.speed_m_s = 1.5F,
}},
};
const auto start = track_core::clock::time_point(std::chrono::seconds(0));
track_core::TrackPidProgramState state(start, config);
require_near(state.next(start + std::chrono::seconds(1), std::nullopt), 1.5F, 0.0001F, "constant PID speed");
require(!state.is_finished(start + std::chrono::seconds(1)), "constant PID not finished");
require(state.is_finished(start + std::chrono::seconds(2)), "constant PID finished");
}
track_core::TrackPidConfig pid_runtime_config() {
track_core::TrackPidConfig config;
config.band_id = 5;
config.target_hr_bpm = 148;
config.deadzone_bpm = 3;
config.schemas = {
track_core::TrackPidSchema{track_core::TrackPidSegment{
.duration_s = 45,
.min_speed_m_s = 0.0F,
.max_speed_m_s = 4.0F,
.kp = 1.0F,
.ki = 2.0F,
.kd = 0.0F,
.slew_rate_limit = 0.25F,
.fine_tune = std::nullopt,
}},
};
return config;
}
track_core::TrackPidConfig constant_pid_runtime_config(float speed_m_s) {
track_core::TrackPidConfig config;
config.band_id = 5;
config.target_hr_bpm = 148;
config.deadzone_bpm = 3;
config.schemas = {
track_core::TrackPidSchema{track_core::TrackConstantSegment{
.duration_s = 2,
.speed_m_s = speed_m_s,
}},
};
return config;
}
track_core::TrackPidBandSnapshot band_sample(std::uint32_t seq, std::uint8_t heart_rate = 140) {
return {
.band_id = 5,
.heart_rate = heart_rate,
.heart_rate_sample_seq = seq,
.has_heart_rate = true,
.hr_is_fresh = true,
.step_count = 0,
.has_step_count = false,
.band_is_active = true,
};
}
void test_pid_runtime_consumes_each_hr_sample_once() {
track_core::PidHrRuntime runtime(pid_runtime_config());
const auto start = track_core::clock::time_point(std::chrono::seconds(0));
runtime.start(start);
runtime.tick(nullptr, band_sample(1), start + std::chrono::milliseconds(1));
const auto first_status = runtime.pid_status(band_sample(1), start + std::chrono::milliseconds(1));
require_near(first_status.effective_speed_m_s, 0.25F, 0.0001F, "pid runtime first effective speed");
require_near(first_status.base_speed_m_s, 0.25F, 0.0001F, "pid runtime first base speed");
runtime.tick(nullptr, band_sample(1), start + std::chrono::milliseconds(2));
const auto repeated_status = runtime.pid_status(band_sample(1), start + std::chrono::milliseconds(2));
require_near(
repeated_status.effective_speed_m_s,
first_status.effective_speed_m_s,
0.0001F,
"pid runtime ignores repeated sample effective speed");
require_near(
repeated_status.base_speed_m_s,
first_status.base_speed_m_s,
0.0001F,
"pid runtime ignores repeated sample base speed");
runtime.tick(nullptr, band_sample(2), start + std::chrono::milliseconds(3));
const auto second_status = runtime.pid_status(band_sample(2), start + std::chrono::milliseconds(3));
require(second_status.effective_speed_m_s > repeated_status.effective_speed_m_s, "pid runtime consumes new sample");
require(second_status.base_speed_m_s > repeated_status.base_speed_m_s, "pid runtime updates base speed on new sample");
}
void test_pid_runtime_suppression_is_explicitly_configured() {
auto config = constant_pid_runtime_config(1.0F);
config.speed_suppression = track_core::TrackPidSpeedSuppression{
.ratio_min = 0.25F,
.sigma_m = 0.01F,
};
const track_core::TrackConfig track_config{
.draw_kind = track_core::TrackDrawKind::circular,
.line_length_m = 0.1F,
.active_line_length_m = 0.025F,
.head_offset_m = 0.0F,
.line_leds_num = 100,
};
const auto start = track_core::clock::time_point(std::chrono::seconds(0));
track_core::PidHrRuntime without_config(config);
without_config.start(start);
without_config.tick(nullptr, {}, start + std::chrono::milliseconds(1));
auto status = without_config.pid_status({}, start + std::chrono::milliseconds(1));
require_near(status.effective_speed_m_s, 1.0F, 0.0001F, "pid runtime skips suppression without track config");
require_near(status.base_speed_m_s, 1.0F, 0.0001F, "pid runtime base speed without track config");
track_core::PidHrRuntime with_config(config);
with_config.start(start);
with_config.tick(&track_config, {}, start + std::chrono::milliseconds(1));
status = with_config.pid_status({}, start + std::chrono::milliseconds(1));
require(status.active_segment_kind == track_core::TrackPidStageKind::constant, "pid runtime constant stage kind");
require_near(status.effective_speed_m_s, 0.25F, 0.0001F, "pid runtime suppresses at seam");
require_near(status.base_speed_m_s, 1.0F, 0.0001F, "pid runtime base speed at seam");
}
void test_pid_runtime_applies_live_tuning() {
track_core::PidHrRuntime runtime(pid_runtime_config());
const auto start = track_core::clock::time_point(std::chrono::seconds(0));
runtime.start(start);
runtime.tick(nullptr, band_sample(1), start + std::chrono::milliseconds(1));
const track_core::TrackPidRuntimeCommand command{
.command = track_core::TrackPidSetTuning{
.min_speed_m_s = 1.2F,
.max_speed_m_s = 3.5F,
.kp = 0.5F,
.ki = 0.25F,
.kd = 0.0F,
.slew_rate_limit = 0.25F,
.fine_tune = std::nullopt,
},
};
const auto applied = runtime.apply_pid_runtime_command(command, nullptr);
require(applied.has_value(), "pid runtime applies tuning");
require_near(
std::get<track_core::TrackPidSegment>(runtime.pid_config().schemas.front().segment).min_speed_m_s,
1.2F,
0.0001F,
"pid runtime config tracks tuning");
const auto status = runtime.pid_status(band_sample(1), start + std::chrono::milliseconds(1));
require_near(status.effective_speed_m_s, 1.2F, 0.0001F, "pid runtime tuned effective speed");
require_near(status.base_speed_m_s, 1.2F, 0.0001F, "pid runtime tuned base speed");
}
track_core::TrackConfig runtime_config() {
return {
.draw_kind = track_core::TrackDrawKind::circular,
.line_length_m = 5.0F,
.active_line_length_m = 1.0F,
.head_offset_m = 0.0F,
.line_leds_num = 50,
};
}
void test_speed_mileage_runtime_ticks() {
auto runtime = track_core::make_scheme_track_runtime(track_core::make_speed_mileage_scheme(
11,
track_core::Color::red(),
track_core::AccelerationProfile::instant,
{
track_core::SMSegment{.speed_m_s = 1.0F, .mileage_from_start_m = 0},
track_core::SMSegment{.speed_m_s = 2.0F, .mileage_from_start_m = 5},
track_core::SMSegment{.speed_m_s = 1.0F, .mileage_from_start_m = 10},
}));
require(runtime.has_value(), "SM runtime builds");
auto track = track_core::start_scheme_track(std::move(*runtime));
track = track_core::tick_scheme_track(runtime_config(), std::move(track), 6.0F);
const auto report = track_core::scheme_track_report(track);
require(report.state == track_core::TrackState::run, "SM still running before end");
require_near(report.mileage_m, 6.0F, 0.001F, "SM mileage after tick");
require_near(report.speed_m_s, 2.0F, 0.001F, "SM speed after boundary");
}
void test_mileage_time_runtime_ticks() {
auto runtime = track_core::make_scheme_track_runtime(track_core::make_mileage_time_scheme(
12,
track_core::Color::green(),
track_core::AccelerationProfile::instant,
{
track_core::MTSegment{.mileage_to_travel_this_segment_m = 10, .time_since_start_s = 0},
track_core::MTSegment{.mileage_to_travel_this_segment_m = 20, .time_since_start_s = 5},
track_core::MTSegment{.mileage_to_travel_this_segment_m = 1, .time_since_start_s = 15},
}));
require(runtime.has_value(), "MT runtime builds");
auto track = track_core::start_scheme_track(std::move(*runtime));
track = track_core::tick_scheme_track(runtime_config(), std::move(track), 2.0F);
auto report = track_core::scheme_track_report(track);
require_near(report.speed_m_s, 2.0F, 0.001F, "MT initial derived speed");
require_near(report.mileage_m, 4.0F, 0.001F, "MT mileage after first tick");
track = track_core::tick_scheme_track(runtime_config(), std::move(track), 4.0F);
report = track_core::scheme_track_report(track);
require_near(report.speed_m_s, 2.0F, 0.001F, "MT speed after first time boundary");
require_near(report.mileage_m, 12.0F, 0.001F, "MT mileage after boundary tick");
}
void test_speed_time_runtime_ticks() {
auto runtime = track_core::make_scheme_track_runtime(track_core::make_speed_time_scheme(
13,
track_core::Color::blue(),
track_core::AccelerationProfile::instant,
{
track_core::STSegment{.speed_m_s = 1.0F, .time_since_start_s = 0},
track_core::STSegment{.speed_m_s = 3.0F, .time_since_start_s = 5},
track_core::STSegment{.speed_m_s = 1.0F, .time_since_start_s = 10},
}));
require(runtime.has_value(), "ST runtime builds");
auto track = track_core::start_scheme_track(std::move(*runtime));
track = track_core::tick_scheme_track(runtime_config(), std::move(track), 6.0F);
const auto report = track_core::scheme_track_report(track);
require(report.state == track_core::TrackState::run, "ST still running before end");
require_near(report.mileage_m, 6.0F, 0.001F, "ST mileage after tick");
require_near(report.speed_m_s, 3.0F, 0.001F, "ST speed after time boundary");
}
void test_repeated_speed_mileage_time_runtime_ticks() {
auto runtime = track_core::make_scheme_track_runtime(track_core::make_repeated_speed_mileage_time_scheme(
14,
track_core::Color::white(),
track_core::AccelerationProfile::instant,
{
track_core::RepeatedSMSegment{
.speed_mileage_segments = {
track_core::SMSegment{.speed_m_s = 1.0F, .mileage_from_start_m = 0},
track_core::SMSegment{.speed_m_s = 2.0F, .mileage_from_start_m = 5},
},
.time_since_start_s = 0,
},
track_core::RepeatedSMSegment{
.speed_mileage_segments = {
track_core::SMSegment{.speed_m_s = 3.0F, .mileage_from_start_m = 0},
track_core::SMSegment{.speed_m_s = 1.0F, .mileage_from_start_m = 5},
},
.time_since_start_s = 4,
},
track_core::RepeatedSMSegment{
.speed_mileage_segments = {
track_core::SMSegment{.speed_m_s = 1.0F, .mileage_from_start_m = 0},
track_core::SMSegment{.speed_m_s = 1.0F, .mileage_from_start_m = 5},
},
.time_since_start_s = 20,
},
}));
require(runtime.has_value(), "RSMT runtime builds");
auto track = track_core::start_scheme_track(std::move(*runtime));
track = track_core::tick_scheme_track(runtime_config(), std::move(track), 3.0F);
track = track_core::tick_scheme_track(runtime_config(), std::move(track), 2.0F);
require_eq_u32(static_cast<std::uint32_t>(track.state.primary_segment_index), 0, "RSMT waits for line alignment");
track = track_core::tick_scheme_track(runtime_config(), std::move(track), 1.0F);
require_eq_u32(static_cast<std::uint32_t>(track.state.primary_segment_index), 1, "RSMT switches on alignment");
require_near(track.state.speed_m_s, 3.0F, 0.001F, "RSMT switched speed");
}
void test_scheme_training_runtime_renders() {
track_core::SchemeTrainingRuntime runtime;
const auto added = runtime.add_scheme(track_core::make_speed_time_scheme(
15,
track_core::Color::green(),
track_core::AccelerationProfile::instant,
{
track_core::STSegment{.speed_m_s = 1.0F, .time_since_start_s = 0},
track_core::STSegment{.speed_m_s = 1.0F, .time_since_start_s = 10},
}));
require(added.has_value(), "training runtime accepts scheme");
runtime.start();
runtime.tick(runtime_config(), 1.0F);
const auto pixels = runtime.render_pixels(runtime_config());
require(pixels.has_value(), "training runtime renders pixels");
require_eq_u32(static_cast<std::uint32_t>(pixels->size()), runtime_config().line_leds_num, "training runtime pixel count");
track_core::MemoryStrip strip(runtime_config().line_leds_num);
const auto rendered = runtime.render_to(runtime_config(), track_core::make_memory_strip_sink(strip));
require(rendered.has_value(), "training runtime renders to sink");
for (std::size_t i = 0; i < pixels->size(); ++i) {
require((*pixels)[i] == strip.pixels()[i], "render_to matches render_pixels");
}
require(!runtime.all_stopped(), "training runtime running");
require_eq_u32(runtime.state_collection().states[0].id, 15, "training runtime report id");
}
} // namespace
int main() {
test_circular_render_wraps();
test_linear_render_forward_and_memory_strip();
test_linear_render_reverse();
test_linear_render_boundaries_apply();
test_memory_strip_bounds();
test_scheme_decoder();
test_pid_program_constant();
test_pid_runtime_consumes_each_hr_sample_once();
test_pid_runtime_suppression_is_explicitly_configured();
test_pid_runtime_applies_live_tuning();
test_speed_mileage_runtime_ticks();
test_mileage_time_runtime_ticks();
test_speed_time_runtime_ticks();
test_repeated_speed_mileage_time_runtime_ticks();
test_scheme_training_runtime_renders();
std::cout << "track-core tests passed\n";
return 0;
}