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.
This commit is contained in:
2026-05-18 16:15:45 +08:00
parent 84598cad20
commit 1005e50be0
24 changed files with 4169 additions and 15 deletions
+211
View File
@@ -0,0 +1,211 @@
import pytest
import track_core as tc
def running_info(color=None):
info = tc.TrackInfo()
info.color = color or tc.Color.green()
info.id = 1
info.is_running = True
info.num_segments = 1
return info
def report(mileage):
value = tc.TrackReport()
value.id = 1
value.state = tc.TrackState.run
value.mileage_m = mileage
value.speed_m_s = 1.0
return value
def config(draw_kind):
value = tc.TrackConfig()
value.draw_kind = draw_kind
value.line_length_m = 10.0
value.active_line_length_m = 4.0
value.head_offset_m = 0.0
value.line_leds_num = 20
return value
def test_circular_render_wraps():
plan = tc.make_render_plan(
config(tc.TrackDrawKind.circular),
running_info(tc.Color.green()),
report(8.5),
)
assert plan.span_count == 2
assert [(span.start_led, span.led_count, span.color) for span in plan.spans] == [
(17, 3, tc.Color.green()),
(0, 5, tc.Color.green()),
]
def test_linear_render_forward_pixels():
pixels = tc.render_pixels(
config(tc.TrackDrawKind.linear),
running_info(tc.Color.red()),
report(5.0),
)
assert len(pixels) == 20
assert pixels[6] == tc.Color.cyan()
assert pixels[8] == tc.Color.red()
assert pixels[10] == tc.Color.red()
assert pixels[12] == tc.Color.blue()
assert pixels[0] == tc.Color.black()
def test_linear_render_reverse_spans():
cfg = config(tc.TrackDrawKind.linear)
cfg.active_line_length_m = 5.0
plan = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(15.0))
assert [(span.start_led, span.led_count, span.color) for span in plan.spans] == [
(5, 2, tc.Color.blue()),
(7, 3, tc.Color.red()),
(10, 3, tc.Color.red()),
(13, 2, tc.Color.cyan()),
]
def test_linear_render_boundary_sweep_does_not_raise():
cfg = config(tc.TrackDrawKind.linear)
for mileage in (
-20.0,
-10.1,
-10.0,
-9.9,
-1.0,
-0.1,
0.0,
0.1,
1.0,
9.9,
10.0,
10.1,
19.9,
20.0,
20.1,
29.9,
30.0,
30.1,
40.0,
):
pixels = tc.render_pixels(cfg, running_info(tc.Color.red()), report(mileage))
assert len(pixels) == cfg.line_leds_num
def test_not_running_renders_black_pixels():
info = running_info()
info.is_running = False
pixels = tc.render_pixels(config(tc.TrackDrawKind.circular), info, report(8.5))
assert len(pixels) == 20
assert all(pixel == tc.Color.black() for pixel in pixels)
def test_invalid_config_raises_value_error():
cfg = config(tc.TrackDrawKind.circular)
cfg.line_leds_num = 0
with pytest.raises(ValueError):
tc.render_pixels(cfg, running_info(), report(0.0))
def st_segment(speed, time_s):
segment = tc.STSegment()
segment.speed_m_s = speed
segment.time_since_start_s = time_s
return segment
def sm_segment(speed, mileage):
segment = tc.SMSegment()
segment.speed_m_s = speed
segment.mileage_from_start_m = mileage
return segment
def mt_segment(mileage, time_s):
segment = tc.MTSegment()
segment.mileage_to_travel_this_segment_m = mileage
segment.time_since_start_s = time_s
return segment
def test_pure_speed_time_runtime_ticks():
scheme = tc.make_speed_time_scheme(
7,
tc.Color.green(),
tc.AccelerationProfile.instant,
[st_segment(1.0, 0), st_segment(3.0, 5), st_segment(1.0, 10)],
)
runtime = tc.start_scheme_track(tc.make_scheme_track_runtime(scheme))
runtime = tc.tick_scheme_track(config(tc.TrackDrawKind.circular), runtime, 6.0)
state = runtime.state
report = runtime.report()
assert state.primary_segment_index == 1
assert report.state == tc.TrackState.run
assert report.speed_m_s == pytest.approx(3.0)
assert report.mileage_m == pytest.approx(6.0)
def test_training_runtime_accepts_all_scheme_kinds_and_renders():
runtime = tc.SchemeTrainingRuntime()
runtime.add_scheme(
tc.make_speed_mileage_scheme(
1,
tc.Color.red(),
tc.AccelerationProfile.instant,
[sm_segment(1.0, 0), sm_segment(2.0, 5), sm_segment(1.0, 10)],
)
)
runtime.add_scheme(
tc.make_mileage_time_scheme(
2,
tc.Color.green(),
tc.AccelerationProfile.instant,
[mt_segment(10, 0), mt_segment(20, 5), mt_segment(1, 15)],
)
)
runtime.add_scheme(
tc.make_speed_time_scheme(
3,
tc.Color.blue(),
tc.AccelerationProfile.instant,
[st_segment(1.0, 0), st_segment(1.0, 10)],
)
)
repeated = tc.RepeatedSMSegment()
repeated.time_since_start_s = 0
repeated.speed_mileage_segments = [sm_segment(1.0, 0), sm_segment(2.0, 5)]
repeated_end = tc.RepeatedSMSegment()
repeated_end.time_since_start_s = 20
repeated_end.speed_mileage_segments = [sm_segment(1.0, 0), sm_segment(1.0, 5)]
runtime.add_scheme(
tc.make_repeated_speed_mileage_time_scheme(
4,
tc.Color.white(),
tc.AccelerationProfile.instant,
[repeated, repeated_end],
)
)
runtime.start()
runtime.tick(config(tc.TrackDrawKind.circular), 1.0)
pixels = runtime.render_pixels(config(tc.TrackDrawKind.circular))
assert runtime.has_program()
assert not runtime.all_stopped()
assert len(runtime.state_collection()) == 4
assert len(runtime.scheme_status()) == 4
assert len(pixels) == 20