feat(emulator): add editable training schema panel
Redesign the DearPyGui emulator into a two-column layout with an interactive training schema editor and runtime preview. Add JSON load/save support for ST, SM, MT, and RSMT schemes, including draft normalization, conversion to core schemes, and explicit Apply validation so intermediate edits do not restart or block the preview. Update manual mode to clamp negative mileage, remove speed from manual positioning, preserve stopped runtime state when reloading schemas, and keep linear rendering visible with span-based drawing. Fix the core linear pingpong render plan so reverse travel remains visible near endpoints and prefix/postfix colors stay tied to physical sides instead of reversing with heading. Add C++ and Python regressions for schema conversion, emulator edit behavior, manual mode, linear endpoint visibility, and fixed prefix/postfix colors.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
|
||||
import track_core as tc
|
||||
from track_core import emulator
|
||||
from track_core import emulator_scheme
|
||||
|
||||
|
||||
def running_info(color=None):
|
||||
@@ -31,6 +33,42 @@ def config(draw_kind):
|
||||
return value
|
||||
|
||||
|
||||
class FakeDpg:
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
|
||||
def get_value(self, tag):
|
||||
return self.values[tag]
|
||||
|
||||
def set_value(self, tag, value):
|
||||
self.values[tag] = value
|
||||
|
||||
def does_item_exist(self, tag):
|
||||
return False
|
||||
|
||||
def configure_item(self, tag, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def emulator_values(**overrides):
|
||||
values = {
|
||||
"line_length": 40.0,
|
||||
"active_length": 10.0,
|
||||
"head_offset": 0.0,
|
||||
"led_count": 160,
|
||||
"draw_kind": "linear",
|
||||
"runner_color": (0, 255, 0, 255),
|
||||
"manual_running": True,
|
||||
"manual_mileage": 8.5,
|
||||
"schema_id": 1,
|
||||
"schema_kind": "ST",
|
||||
"schema_accel": "smooth",
|
||||
"schema_color": (0, 255, 0, 255),
|
||||
}
|
||||
values.update(overrides)
|
||||
return values
|
||||
|
||||
|
||||
def test_circular_render_wraps():
|
||||
plan = tc.make_render_plan(
|
||||
config(tc.TrackDrawKind.circular),
|
||||
@@ -66,10 +104,22 @@ def test_linear_render_reverse_spans():
|
||||
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()),
|
||||
(13, 2, tc.Color.blue()),
|
||||
(5, 2, tc.Color.cyan()),
|
||||
(7, 3, tc.Color.red()),
|
||||
]
|
||||
|
||||
|
||||
def test_linear_render_prefix_postfix_colors_do_not_reverse():
|
||||
cfg = config(tc.TrackDrawKind.linear)
|
||||
cfg.active_line_length_m = 5.0
|
||||
|
||||
forward = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(5.0))
|
||||
reverse = 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 reverse.spans] == [
|
||||
(span.start_led, span.led_count, span.color) for span in forward.spans
|
||||
]
|
||||
|
||||
|
||||
@@ -100,6 +150,114 @@ def test_linear_render_boundary_sweep_does_not_raise():
|
||||
assert len(pixels) == cfg.line_leds_num
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mileage", [10.1, 19.9])
|
||||
def test_linear_render_reverse_near_ends_stays_visible(mileage):
|
||||
cfg = config(tc.TrackDrawKind.linear)
|
||||
|
||||
pixels = tc.render_pixels(cfg, running_info(tc.Color.red()), report(mileage))
|
||||
|
||||
assert any(pixel != tc.Color.black() for pixel in pixels)
|
||||
|
||||
|
||||
def test_manual_emulator_report_clamps_negative_mileage_and_ignores_speed():
|
||||
dpg = FakeDpg(emulator_values(manual_mileage=-12.0))
|
||||
|
||||
_, info, state_report = emulator._manual_state_to_core(dpg)
|
||||
|
||||
assert info.is_running
|
||||
assert state_report.mileage_m == 0.0
|
||||
assert state_report.speed_m_s == 0.0
|
||||
|
||||
|
||||
def test_emulator_track_model_toggle():
|
||||
dpg = FakeDpg(emulator_values(draw_kind="circular"))
|
||||
|
||||
emulator._toggle_draw_kind(dpg)
|
||||
assert dpg.get_value("draw_kind") == "linear"
|
||||
|
||||
emulator._toggle_draw_kind(dpg)
|
||||
assert dpg.get_value("draw_kind") == "circular"
|
||||
|
||||
|
||||
def test_emulator_schema_reload_preserves_stopped_runtime():
|
||||
app = emulator.TrackRuntimeApp()
|
||||
dpg = FakeDpg(emulator_values())
|
||||
|
||||
assert app.load_scheme(dpg)
|
||||
assert not app.runtime.all_stopped()
|
||||
|
||||
app.stop()
|
||||
assert app.runtime.all_stopped()
|
||||
|
||||
app.draft["segments"][1]["speed_m_s"] = 2.5
|
||||
assert app.load_scheme(dpg)
|
||||
|
||||
assert app.runtime.all_stopped()
|
||||
|
||||
|
||||
def test_emulator_editor_changes_wait_for_explicit_apply():
|
||||
app = emulator.TrackRuntimeApp()
|
||||
dpg = FakeDpg(emulator_values())
|
||||
|
||||
assert app.load_scheme(dpg)
|
||||
previous_valid = emulator_scheme.clone_draft(app.last_valid_draft)
|
||||
|
||||
emulator._set_simple_segment_value(dpg, app, 0, "time_s", 3)
|
||||
|
||||
assert app.draft["segments"][0]["time_s"] == 3
|
||||
assert app.last_valid_draft == previous_valid
|
||||
assert app.has_unapplied_edits
|
||||
assert "Edited" in app.status
|
||||
|
||||
assert not app.apply_schema(dpg)
|
||||
assert app.last_valid_draft == previous_valid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["ST", "SM", "MT", "RSMT"])
|
||||
def test_emulator_scheme_json_round_trips_all_kinds(tmp_path, kind):
|
||||
draft = emulator_scheme.default_draft(kind)
|
||||
path = tmp_path / f"{kind.lower()}.json"
|
||||
|
||||
saved = emulator_scheme.save_draft(path, draft)
|
||||
loaded = emulator_scheme.load_draft(path)
|
||||
|
||||
assert loaded == saved
|
||||
assert loaded["version"] == 1
|
||||
assert loaded["kind"] == kind
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["ST", "SM", "MT", "RSMT"])
|
||||
def test_emulator_scheme_draft_converts_to_core_scheme(kind):
|
||||
scheme = emulator_scheme.draft_to_scheme(emulator_scheme.default_draft(kind))
|
||||
|
||||
runtime = tc.make_scheme_track_runtime(scheme)
|
||||
runtime = tc.start_scheme_track(runtime)
|
||||
|
||||
assert runtime.info().id == 1
|
||||
assert runtime.report().state == tc.TrackState.run
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,segments,error",
|
||||
[
|
||||
("ST", [{"time_s": 1, "speed_m_s": 1.0}], "first ST time_s"),
|
||||
("SM", [{"mileage_m": 1, "speed_m_s": 1.0}], "first SM mileage_m"),
|
||||
("MT", [{"time_s": 0, "mileage_m": 1}], "MT needs at least two segments"),
|
||||
(
|
||||
"RSMT",
|
||||
[{"time_s": 0, "sub_segments": [{"mileage_m": 1, "speed_m_s": 1.0}]}],
|
||||
"first RSMT segment 0 mileage_m",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_emulator_scheme_rejects_invalid_segments(kind, segments, error):
|
||||
draft = emulator_scheme.default_draft(kind)
|
||||
draft["segments"] = segments
|
||||
|
||||
with pytest.raises(ValueError, match=error):
|
||||
emulator_scheme.normalize_draft(draft)
|
||||
|
||||
|
||||
def test_not_running_renders_black_pixels():
|
||||
info = running_info()
|
||||
info.is_running = False
|
||||
|
||||
+568
-120
@@ -2,29 +2,36 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import (
|
||||
AccelerationProfile,
|
||||
Color,
|
||||
MTSegment,
|
||||
RepeatedSMSegment,
|
||||
SMSegment,
|
||||
STSegment,
|
||||
SchemeTrainingRuntime,
|
||||
TrackConfig,
|
||||
TrackDrawKind,
|
||||
TrackInfo,
|
||||
TrackReport,
|
||||
TrackState,
|
||||
make_mileage_time_scheme,
|
||||
make_repeated_speed_mileage_time_scheme,
|
||||
make_speed_mileage_scheme,
|
||||
make_speed_time_scheme,
|
||||
render_pixels,
|
||||
)
|
||||
from .emulator_scheme import (
|
||||
ACCELERATION_PROFILES,
|
||||
SCHEME_KINDS,
|
||||
clone_draft,
|
||||
default_draft,
|
||||
draft_to_scheme,
|
||||
load_draft,
|
||||
normalize_draft,
|
||||
save_draft,
|
||||
)
|
||||
|
||||
|
||||
DRAWLIST_TAG = "track_core_drawlist"
|
||||
EDITOR_TAG = "scheme_editor"
|
||||
STATUS_TAG = "schema_status"
|
||||
FILE_PATH_TAG = "schema_file_path"
|
||||
LOAD_DIALOG_TAG = "schema_load_dialog"
|
||||
SAVE_DIALOG_TAG = "schema_save_dialog"
|
||||
|
||||
|
||||
def _load_dearpygui():
|
||||
@@ -42,44 +49,8 @@ def _rgba(color: Color) -> tuple[int, int, int, int]:
|
||||
return (color.r, color.g, color.b, 255)
|
||||
|
||||
|
||||
def _sm(speed: float, mileage_m: int) -> SMSegment:
|
||||
segment = SMSegment()
|
||||
segment.speed_m_s = speed
|
||||
segment.mileage_from_start_m = mileage_m
|
||||
return segment
|
||||
|
||||
|
||||
def _mt(mileage_m: int, time_s: int) -> MTSegment:
|
||||
segment = MTSegment()
|
||||
segment.mileage_to_travel_this_segment_m = mileage_m
|
||||
segment.time_since_start_s = time_s
|
||||
return segment
|
||||
|
||||
|
||||
def _st(speed: float, time_s: int) -> STSegment:
|
||||
segment = STSegment()
|
||||
segment.speed_m_s = speed
|
||||
segment.time_since_start_s = time_s
|
||||
return segment
|
||||
|
||||
|
||||
def _rsmt(time_s: int, segments: list[SMSegment]) -> RepeatedSMSegment:
|
||||
segment = RepeatedSMSegment()
|
||||
segment.time_since_start_s = time_s
|
||||
segment.speed_mileage_segments = segments
|
||||
return segment
|
||||
|
||||
|
||||
def _profile_from_ui(dpg) -> AccelerationProfile:
|
||||
return (
|
||||
AccelerationProfile.smooth
|
||||
if dpg.get_value("accel_profile") == "smooth"
|
||||
else AccelerationProfile.instant
|
||||
)
|
||||
|
||||
|
||||
def _color_from_ui(dpg) -> Color:
|
||||
rgb = dpg.get_value("runner_color")
|
||||
def _color_from_draft(draft: dict) -> Color:
|
||||
rgb = draft.get("color", [0, 255, 0])
|
||||
return Color(int(rgb[0]), int(rgb[1]), int(rgb[2]))
|
||||
|
||||
|
||||
@@ -100,43 +71,73 @@ def _config_from_ui(dpg) -> TrackConfig:
|
||||
return config
|
||||
|
||||
|
||||
def _manual_state_to_core(dpg) -> tuple[TrackConfig, TrackInfo, TrackReport]:
|
||||
def _toggle_draw_kind(dpg) -> None:
|
||||
next_kind = "linear" if dpg.get_value("draw_kind") == "circular" else "circular"
|
||||
dpg.set_value("draw_kind", next_kind)
|
||||
|
||||
|
||||
def _manual_state_to_core(dpg, draft: dict | None = None) -> tuple[TrackConfig, TrackInfo, TrackReport]:
|
||||
config = _config_from_ui(dpg)
|
||||
color = _color_from_ui(dpg)
|
||||
color = _color_from_draft(draft or default_draft())
|
||||
|
||||
info = TrackInfo()
|
||||
info.color = color
|
||||
info.id = 1
|
||||
info.id = int((draft or {}).get("id", 1))
|
||||
info.is_running = dpg.get_value("manual_running")
|
||||
info.num_segments = 1
|
||||
|
||||
report = TrackReport()
|
||||
report.id = 1
|
||||
report.id = info.id
|
||||
report.state = TrackState.run if info.is_running else TrackState.stop
|
||||
report.mileage_m = dpg.get_value("manual_mileage")
|
||||
report.speed_m_s = dpg.get_value("manual_speed")
|
||||
report.mileage_m = max(0.0, float(dpg.get_value("manual_mileage")))
|
||||
report.speed_m_s = 0.0
|
||||
report.time_elapsed_ms = 0
|
||||
return config, info, report
|
||||
|
||||
|
||||
def _draw_linear(dpg, pixels: list[Color]) -> None:
|
||||
width = 840
|
||||
x0 = 30
|
||||
y0 = 180
|
||||
width = 560
|
||||
x0 = 24
|
||||
y0 = 174
|
||||
gap = 1 if len(pixels) <= 240 else 0
|
||||
cell_w = max(1, int((width - gap * max(0, len(pixels) - 1)) / max(1, len(pixels))))
|
||||
cell_h = 34
|
||||
pitch = cell_w + gap
|
||||
|
||||
for index, color in enumerate(pixels):
|
||||
x = x0 + index * (cell_w + gap)
|
||||
dpg.draw_rectangle(
|
||||
(x0, y0),
|
||||
(x0 + width, y0 + cell_h),
|
||||
color=(55, 58, 64, 255),
|
||||
fill=(16, 18, 22, 255),
|
||||
parent=DRAWLIST_TAG,
|
||||
)
|
||||
|
||||
index = 0
|
||||
while index < len(pixels):
|
||||
color = pixels[index]
|
||||
if color == Color.black():
|
||||
index += 1
|
||||
continue
|
||||
|
||||
start = index
|
||||
while index < len(pixels) and pixels[index] == color:
|
||||
index += 1
|
||||
|
||||
count = index - start
|
||||
x = x0 + start * pitch
|
||||
span_width = count * cell_w + max(0, count - 1) * gap
|
||||
dpg.draw_rectangle(
|
||||
(x, y0),
|
||||
(x + cell_w, y0 + cell_h),
|
||||
color=(55, 58, 64, 255),
|
||||
(x + span_width, y0 + cell_h),
|
||||
color=_rgba(color),
|
||||
fill=_rgba(color),
|
||||
parent=DRAWLIST_TAG,
|
||||
)
|
||||
|
||||
for index in range(len(pixels) + 1):
|
||||
x = x0 + index * pitch - (gap if index == len(pixels) and gap else 0)
|
||||
dpg.draw_line((x, y0), (x, y0 + cell_h), color=(70, 74, 84, 255), parent=DRAWLIST_TAG)
|
||||
|
||||
dpg.draw_rectangle(
|
||||
(x0 - 1, y0 - 1),
|
||||
(x0 + width + 1, y0 + cell_h + 1),
|
||||
@@ -147,7 +148,7 @@ def _draw_linear(dpg, pixels: list[Color]) -> None:
|
||||
|
||||
|
||||
def _draw_circular(dpg, pixels: list[Color]) -> None:
|
||||
center = (440, 158)
|
||||
center = (304, 158)
|
||||
radius = 122
|
||||
led_radius = 5 if len(pixels) <= 160 else 3
|
||||
|
||||
@@ -166,56 +167,42 @@ def _draw_circular(dpg, pixels: list[Color]) -> None:
|
||||
parent=DRAWLIST_TAG,
|
||||
)
|
||||
|
||||
dpg.draw_text((30, 300), f"{len(pixels)} LEDs", color=(190, 195, 205, 255), parent=DRAWLIST_TAG)
|
||||
dpg.draw_text((24, 300), f"{len(pixels)} LEDs", color=(190, 195, 205, 255), parent=DRAWLIST_TAG)
|
||||
|
||||
|
||||
class TrackRuntimeApp:
|
||||
def __init__(self) -> None:
|
||||
self.runtime = SchemeTrainingRuntime()
|
||||
self.draft = default_draft("ST")
|
||||
self.file_path: Path | None = None
|
||||
self.last_frame_s = time.perf_counter()
|
||||
self.last_valid_draft = clone_draft(self.draft)
|
||||
self.status = "Ready"
|
||||
self.has_unapplied_edits = False
|
||||
|
||||
def build_scheme(self, dpg):
|
||||
kind = dpg.get_value("scheme_kind")
|
||||
color = _color_from_ui(dpg)
|
||||
profile = _profile_from_ui(dpg)
|
||||
def load_scheme(self, dpg, run: bool | None = None) -> bool:
|
||||
if run is None:
|
||||
run = True if not self.runtime.has_program() else not self.runtime.all_stopped()
|
||||
|
||||
if kind == "SM":
|
||||
return make_speed_mileage_scheme(
|
||||
1,
|
||||
color,
|
||||
profile,
|
||||
[_sm(1.0, 0), _sm(2.8, 10), _sm(1.2, 24), _sm(2.0, 40)],
|
||||
)
|
||||
if kind == "MT":
|
||||
return make_mileage_time_scheme(
|
||||
1,
|
||||
color,
|
||||
profile,
|
||||
[_mt(12, 0), _mt(20, 6), _mt(10, 18), _mt(1, 28)],
|
||||
)
|
||||
if kind == "RSMT":
|
||||
return make_repeated_speed_mileage_time_scheme(
|
||||
1,
|
||||
color,
|
||||
profile,
|
||||
[
|
||||
_rsmt(0, [_sm(1.0, 0), _sm(3.0, 10), _sm(1.2, 24)]),
|
||||
_rsmt(18, [_sm(2.2, 0), _sm(4.0, 8), _sm(1.0, 20)]),
|
||||
_rsmt(42, [_sm(1.4, 0), _sm(2.4, 12), _sm(1.4, 24)]),
|
||||
],
|
||||
)
|
||||
return make_speed_time_scheme(
|
||||
1,
|
||||
color,
|
||||
profile,
|
||||
[_st(1.0, 0), _st(3.2, 8), _st(1.4, 18), _st(2.4, 30), _st(0.8, 42)],
|
||||
)
|
||||
try:
|
||||
normalized = normalize_draft(self.draft)
|
||||
scheme = draft_to_scheme(normalized)
|
||||
except Exception as exc:
|
||||
self.set_status(dpg, f"Invalid scheme: {exc}", error=True)
|
||||
return False
|
||||
|
||||
def load_scheme(self, dpg) -> None:
|
||||
self.draft = normalized
|
||||
self.runtime.clear()
|
||||
self.runtime.add_scheme(self.build_scheme(dpg))
|
||||
self.runtime.add_scheme(scheme)
|
||||
if run:
|
||||
self.runtime.start()
|
||||
else:
|
||||
self.runtime.stop()
|
||||
self.last_frame_s = time.perf_counter()
|
||||
self.last_valid_draft = clone_draft(normalized)
|
||||
self.has_unapplied_edits = False
|
||||
self.set_status(dpg, "Loaded into runtime" if run else "Loaded into runtime (stopped)")
|
||||
return True
|
||||
|
||||
def start(self) -> None:
|
||||
self.runtime.start()
|
||||
@@ -224,6 +211,99 @@ class TrackRuntimeApp:
|
||||
def stop(self) -> None:
|
||||
self.runtime.stop()
|
||||
|
||||
def set_status(self, dpg, message: str, error: bool = False) -> None:
|
||||
self.status = message
|
||||
if dpg.does_item_exist(STATUS_TAG):
|
||||
dpg.set_value(STATUS_TAG, message)
|
||||
dpg.configure_item(STATUS_TAG, color=(255, 110, 90) if error else (170, 220, 170))
|
||||
|
||||
def refresh_file_path(self, dpg) -> None:
|
||||
if dpg.does_item_exist(FILE_PATH_TAG):
|
||||
dpg.set_value(FILE_PATH_TAG, str(self.file_path) if self.file_path else "")
|
||||
|
||||
def mark_dirty(self, dpg) -> None:
|
||||
self.has_unapplied_edits = True
|
||||
self.set_status(dpg, "Edited; click Apply to validate and reload")
|
||||
|
||||
def apply_editor_values(self, dpg) -> None:
|
||||
self.draft["id"] = int(dpg.get_value("schema_id"))
|
||||
self.draft["kind"] = dpg.get_value("schema_kind")
|
||||
self.draft["acceleration_profile"] = dpg.get_value("schema_accel")
|
||||
color = dpg.get_value("schema_color")
|
||||
self.draft["color"] = [int(color[0]), int(color[1]), int(color[2])]
|
||||
|
||||
def apply_schema(self, dpg) -> bool:
|
||||
self.apply_editor_values(dpg)
|
||||
return self.load_scheme(dpg)
|
||||
|
||||
def reset_template(self, dpg, kind: str | None = None) -> None:
|
||||
self.draft = default_draft(kind or self.draft.get("kind", "ST"))
|
||||
self.file_path = None
|
||||
self.refresh_file_path(dpg)
|
||||
rebuild_editor(dpg, self)
|
||||
self.mark_dirty(dpg)
|
||||
|
||||
def save_current(self, dpg) -> None:
|
||||
if self.file_path is None:
|
||||
dpg.show_item(SAVE_DIALOG_TAG)
|
||||
return
|
||||
try:
|
||||
self.draft = save_draft(self.file_path, self.draft)
|
||||
self.set_status(dpg, f"Saved {self.file_path}")
|
||||
rebuild_editor(dpg, self)
|
||||
except Exception as exc:
|
||||
self.set_status(dpg, f"Save failed: {exc}", error=True)
|
||||
|
||||
def save_as(self, dpg, path: Path) -> None:
|
||||
if path.suffix == "":
|
||||
path = path.with_suffix(".json")
|
||||
try:
|
||||
self.draft = save_draft(path, self.draft)
|
||||
self.file_path = path
|
||||
self.refresh_file_path(dpg)
|
||||
self.set_status(dpg, f"Saved {path}")
|
||||
rebuild_editor(dpg, self)
|
||||
except Exception as exc:
|
||||
self.set_status(dpg, f"Save failed: {exc}", error=True)
|
||||
|
||||
def load_from_file(self, dpg, path: Path) -> None:
|
||||
try:
|
||||
draft = load_draft(path)
|
||||
draft_to_scheme(draft)
|
||||
except Exception as exc:
|
||||
self.set_status(dpg, f"Load failed: {exc}", error=True)
|
||||
return
|
||||
|
||||
self.draft = draft
|
||||
self.file_path = path
|
||||
self.refresh_file_path(dpg)
|
||||
rebuild_editor(dpg, self)
|
||||
self.mark_dirty(dpg)
|
||||
self.set_status(dpg, f"Loaded {path}; click Apply to validate and reload")
|
||||
|
||||
def _draw_status(self, dpg) -> None:
|
||||
if dpg.get_value("source_mode") == "manual":
|
||||
_, info, report = _manual_state_to_core(dpg, self.last_valid_draft)
|
||||
state = "run" if info.is_running else "stop"
|
||||
dpg.draw_text(
|
||||
(24, 18),
|
||||
f"manual {state} {report.mileage_m:6.2f}m {dpg.get_value('draw_kind')} model",
|
||||
color=(220, 225, 235, 255),
|
||||
parent=DRAWLIST_TAG,
|
||||
)
|
||||
return
|
||||
|
||||
states = self.runtime.state_collection()
|
||||
if states:
|
||||
state = states[0]
|
||||
dpg.draw_text(
|
||||
(24, 18),
|
||||
f"id {state.id} {state.time_elapsed_ms / 1000.0:5.1f}s "
|
||||
f"{state.mileage_m:6.2f}m {state.speed_m_s:4.2f}m/s",
|
||||
color=(220, 225, 235, 255),
|
||||
parent=DRAWLIST_TAG,
|
||||
)
|
||||
|
||||
def _runtime_pixels(self, dpg, config: TrackConfig) -> list[Color]:
|
||||
now_s = time.perf_counter()
|
||||
delta_s = min(0.1, now_s - self.last_frame_s)
|
||||
@@ -233,7 +313,7 @@ class TrackRuntimeApp:
|
||||
self.runtime.tick(config, delta_s * float(dpg.get_value("time_scale")))
|
||||
return self.runtime.render_pixels(config)
|
||||
|
||||
manual_config, info, report = _manual_state_to_core(dpg)
|
||||
manual_config, info, report = _manual_state_to_core(dpg, self.last_valid_draft)
|
||||
return render_pixels(manual_config, info, report)
|
||||
|
||||
def render(self, dpg) -> None:
|
||||
@@ -245,37 +325,404 @@ class TrackRuntimeApp:
|
||||
else:
|
||||
_draw_linear(dpg, pixels)
|
||||
|
||||
states = self.runtime.state_collection()
|
||||
if states:
|
||||
state = states[0]
|
||||
dpg.draw_text(
|
||||
(30, 18),
|
||||
f"id {state.id} {state.time_elapsed_ms / 1000.0:5.1f}s "
|
||||
f"{state.mileage_m:6.2f}m {state.speed_m_s:4.2f}m/s",
|
||||
color=(220, 225, 235, 255),
|
||||
parent=DRAWLIST_TAG,
|
||||
self._draw_status(dpg)
|
||||
|
||||
|
||||
def _segment_input_float(dpg, tag: str, value: float, callback) -> None:
|
||||
dpg.add_input_float(
|
||||
tag=tag,
|
||||
default_value=float(value),
|
||||
width=92,
|
||||
min_value=0.0,
|
||||
min_clamped=True,
|
||||
step=0.1,
|
||||
format="%.2f",
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
|
||||
def _segment_input_int(dpg, tag: str, value: int, callback) -> None:
|
||||
dpg.add_input_int(
|
||||
tag=tag,
|
||||
default_value=int(value),
|
||||
width=82,
|
||||
min_value=0,
|
||||
min_clamped=True,
|
||||
step=1,
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
|
||||
def _editor_changed(dpg, app: TrackRuntimeApp) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def _set_simple_segment_value(dpg, app: TrackRuntimeApp, index: int, field: str, value) -> None:
|
||||
app.draft["segments"][index][field] = value
|
||||
_editor_changed(dpg, app)
|
||||
|
||||
|
||||
def _set_rsmt_time(dpg, app: TrackRuntimeApp, index: int, value: int) -> None:
|
||||
app.draft["segments"][index]["time_s"] = value
|
||||
_editor_changed(dpg, app)
|
||||
|
||||
|
||||
def _set_rsmt_sub_value(
|
||||
dpg,
|
||||
app: TrackRuntimeApp,
|
||||
segment_index: int,
|
||||
sub_index: int,
|
||||
field: str,
|
||||
value,
|
||||
) -> None:
|
||||
app.draft["segments"][segment_index]["sub_segments"][sub_index][field] = value
|
||||
_editor_changed(dpg, app)
|
||||
|
||||
|
||||
def _add_simple_segment(dpg, app: TrackRuntimeApp) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
kind = app.draft["kind"]
|
||||
segments = app.draft["segments"]
|
||||
if kind == "SM":
|
||||
last = segments[-1] if segments else {"mileage_m": 0, "speed_m_s": 1.0}
|
||||
segments.append({"mileage_m": int(last["mileage_m"]) + 5, "speed_m_s": float(last["speed_m_s"])})
|
||||
elif kind == "MT":
|
||||
last = segments[-1] if segments else {"time_s": 0, "mileage_m": 5}
|
||||
segments.append({"time_s": int(last["time_s"]) + 5, "mileage_m": max(1, int(last["mileage_m"]))})
|
||||
else:
|
||||
last = segments[-1] if segments else {"time_s": 0, "speed_m_s": 1.0}
|
||||
segments.append({"time_s": int(last["time_s"]) + 5, "speed_m_s": float(last["speed_m_s"])})
|
||||
rebuild_editor(dpg, app)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def _remove_simple_segment(dpg, app: TrackRuntimeApp, index: int) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
if len(app.draft["segments"]) > 1:
|
||||
app.draft["segments"].pop(index)
|
||||
rebuild_editor(dpg, app)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def _duplicate_simple_segment(dpg, app: TrackRuntimeApp, index: int) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
source = clone_draft(app.draft["segments"][index])
|
||||
if app.draft["kind"] == "SM":
|
||||
source["mileage_m"] = int(source["mileage_m"]) + 5
|
||||
else:
|
||||
source["time_s"] = int(source["time_s"]) + 5
|
||||
app.draft["segments"].insert(index + 1, source)
|
||||
rebuild_editor(dpg, app)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def _add_rsmt_segment(dpg, app: TrackRuntimeApp) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
segments = app.draft["segments"]
|
||||
last = segments[-1] if segments else {"time_s": 0}
|
||||
segments.append(
|
||||
{
|
||||
"time_s": int(last["time_s"]) + 10,
|
||||
"sub_segments": [
|
||||
{"mileage_m": 0, "speed_m_s": 1.0},
|
||||
{"mileage_m": 8, "speed_m_s": 1.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
rebuild_editor(dpg, app)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def _remove_rsmt_segment(dpg, app: TrackRuntimeApp, index: int) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
if len(app.draft["segments"]) > 1:
|
||||
app.draft["segments"].pop(index)
|
||||
rebuild_editor(dpg, app)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def _add_rsmt_sub_segment(dpg, app: TrackRuntimeApp, segment_index: int) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
sub_segments = app.draft["segments"][segment_index]["sub_segments"]
|
||||
last = sub_segments[-1] if sub_segments else {"mileage_m": 0, "speed_m_s": 1.0}
|
||||
sub_segments.append({"mileage_m": int(last["mileage_m"]) + 5, "speed_m_s": float(last["speed_m_s"])})
|
||||
rebuild_editor(dpg, app)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def _remove_rsmt_sub_segment(dpg, app: TrackRuntimeApp, segment_index: int, sub_index: int) -> None:
|
||||
app.apply_editor_values(dpg)
|
||||
sub_segments = app.draft["segments"][segment_index]["sub_segments"]
|
||||
if len(sub_segments) > 1:
|
||||
sub_segments.pop(sub_index)
|
||||
rebuild_editor(dpg, app)
|
||||
app.mark_dirty(dpg)
|
||||
|
||||
|
||||
def rebuild_editor(dpg, app: TrackRuntimeApp) -> None:
|
||||
dpg.delete_item(EDITOR_TAG, children_only=True)
|
||||
draft = app.draft
|
||||
|
||||
with dpg.group(parent=EDITOR_TAG):
|
||||
dpg.add_text("Training Scheme")
|
||||
dpg.add_input_text(
|
||||
label="file",
|
||||
tag=FILE_PATH_TAG,
|
||||
default_value=str(app.file_path) if app.file_path else "",
|
||||
readonly=True,
|
||||
width=410,
|
||||
)
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_button(label="Load JSON", callback=lambda *_: dpg.show_item(LOAD_DIALOG_TAG))
|
||||
dpg.add_button(label="Apply", callback=lambda *_: app.apply_schema(dpg))
|
||||
dpg.add_button(label="Save", callback=lambda *_: app.save_current(dpg))
|
||||
dpg.add_button(label="Save As", callback=lambda *_: dpg.show_item(SAVE_DIALOG_TAG))
|
||||
dpg.add_button(label="Reset", callback=lambda *_: app.reset_template(dpg))
|
||||
|
||||
dpg.add_separator()
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_input_int(
|
||||
label="id",
|
||||
tag="schema_id",
|
||||
default_value=int(draft["id"]),
|
||||
width=90,
|
||||
min_value=0,
|
||||
max_value=255,
|
||||
min_clamped=True,
|
||||
max_clamped=True,
|
||||
callback=lambda *_: _editor_changed(dpg, app),
|
||||
)
|
||||
dpg.add_combo(
|
||||
SCHEME_KINDS,
|
||||
label="kind",
|
||||
tag="schema_kind",
|
||||
default_value=draft["kind"],
|
||||
width=96,
|
||||
callback=lambda *_: app.reset_template(dpg, dpg.get_value("schema_kind")),
|
||||
)
|
||||
dpg.add_combo(
|
||||
ACCELERATION_PROFILES,
|
||||
label="accel",
|
||||
tag="schema_accel",
|
||||
default_value=draft["acceleration_profile"],
|
||||
width=110,
|
||||
callback=lambda *_: _editor_changed(dpg, app),
|
||||
)
|
||||
dpg.add_color_edit(
|
||||
default_value=(*draft["color"], 255),
|
||||
label="color",
|
||||
tag="schema_color",
|
||||
no_alpha=True,
|
||||
width=260,
|
||||
callback=lambda *_: _editor_changed(dpg, app),
|
||||
)
|
||||
|
||||
dpg.add_separator()
|
||||
if draft["kind"] == "RSMT":
|
||||
_build_rsmt_editor(dpg, app)
|
||||
else:
|
||||
_build_simple_editor(dpg, app)
|
||||
|
||||
dpg.add_separator()
|
||||
dpg.add_text(app.status, tag=STATUS_TAG, color=(170, 220, 170))
|
||||
|
||||
|
||||
def _build_simple_editor(dpg, app: TrackRuntimeApp) -> None:
|
||||
kind = app.draft["kind"]
|
||||
dpg.add_button(label="Add Segment", callback=lambda *_: _add_simple_segment(dpg, app))
|
||||
|
||||
with dpg.table(header_row=True, borders_innerH=True, borders_outerH=True, borders_innerV=True):
|
||||
dpg.add_table_column(label="#", width_fixed=True)
|
||||
if kind == "SM":
|
||||
dpg.add_table_column(label="mileage m")
|
||||
dpg.add_table_column(label="speed m/s")
|
||||
elif kind == "MT":
|
||||
dpg.add_table_column(label="time s")
|
||||
dpg.add_table_column(label="mileage m")
|
||||
else:
|
||||
dpg.add_table_column(label="time s")
|
||||
dpg.add_table_column(label="speed m/s")
|
||||
dpg.add_table_column(label="actions")
|
||||
|
||||
for index, segment in enumerate(app.draft["segments"]):
|
||||
with dpg.table_row():
|
||||
dpg.add_text(str(index))
|
||||
if kind == "SM":
|
||||
_segment_input_int(
|
||||
dpg,
|
||||
f"segment_{index}_mileage",
|
||||
int(segment["mileage_m"]),
|
||||
lambda sender, value, user_data: _set_simple_segment_value(
|
||||
dpg, app, user_data, "mileage_m", int(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"segment_{index}_mileage", index)
|
||||
_segment_input_float(
|
||||
dpg,
|
||||
f"segment_{index}_speed",
|
||||
float(segment["speed_m_s"]),
|
||||
lambda sender, value, user_data: _set_simple_segment_value(
|
||||
dpg, app, user_data, "speed_m_s", float(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"segment_{index}_speed", index)
|
||||
elif kind == "MT":
|
||||
_segment_input_int(
|
||||
dpg,
|
||||
f"segment_{index}_time",
|
||||
int(segment["time_s"]),
|
||||
lambda sender, value, user_data: _set_simple_segment_value(
|
||||
dpg, app, user_data, "time_s", int(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"segment_{index}_time", index)
|
||||
_segment_input_int(
|
||||
dpg,
|
||||
f"segment_{index}_mileage",
|
||||
int(segment["mileage_m"]),
|
||||
lambda sender, value, user_data: _set_simple_segment_value(
|
||||
dpg, app, user_data, "mileage_m", int(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"segment_{index}_mileage", index)
|
||||
else:
|
||||
_segment_input_int(
|
||||
dpg,
|
||||
f"segment_{index}_time",
|
||||
int(segment["time_s"]),
|
||||
lambda sender, value, user_data: _set_simple_segment_value(
|
||||
dpg, app, user_data, "time_s", int(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"segment_{index}_time", index)
|
||||
_segment_input_float(
|
||||
dpg,
|
||||
f"segment_{index}_speed",
|
||||
float(segment["speed_m_s"]),
|
||||
lambda sender, value, user_data: _set_simple_segment_value(
|
||||
dpg, app, user_data, "speed_m_s", float(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"segment_{index}_speed", index)
|
||||
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_button(
|
||||
label="copy",
|
||||
callback=lambda _, __, ___=None, i=index: _duplicate_simple_segment(dpg, app, i),
|
||||
)
|
||||
dpg.add_button(
|
||||
label="del",
|
||||
callback=lambda _, __, ___=None, i=index: _remove_simple_segment(dpg, app, i),
|
||||
)
|
||||
|
||||
|
||||
def _build_rsmt_editor(dpg, app: TrackRuntimeApp) -> None:
|
||||
dpg.add_button(label="Add Time Segment", callback=lambda *_: _add_rsmt_segment(dpg, app))
|
||||
for segment_index, segment in enumerate(app.draft["segments"]):
|
||||
with dpg.collapsing_header(label=f"time segment {segment_index}", default_open=True):
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_text("time s")
|
||||
_segment_input_int(
|
||||
dpg,
|
||||
f"rsmt_{segment_index}_time",
|
||||
int(segment["time_s"]),
|
||||
lambda sender, value, user_data: _set_rsmt_time(dpg, app, user_data, int(value)),
|
||||
)
|
||||
dpg.set_item_user_data(f"rsmt_{segment_index}_time", segment_index)
|
||||
dpg.add_button(
|
||||
label="Add Sub-Segment",
|
||||
callback=lambda _, __, ___=None, i=segment_index: _add_rsmt_sub_segment(dpg, app, i),
|
||||
)
|
||||
dpg.add_button(
|
||||
label="Delete Time Segment",
|
||||
callback=lambda _, __, ___=None, i=segment_index: _remove_rsmt_segment(dpg, app, i),
|
||||
)
|
||||
|
||||
with dpg.table(header_row=True, borders_innerH=True, borders_outerH=True, borders_innerV=True):
|
||||
dpg.add_table_column(label="#")
|
||||
dpg.add_table_column(label="mileage m")
|
||||
dpg.add_table_column(label="speed m/s")
|
||||
dpg.add_table_column(label="actions")
|
||||
for sub_index, sub_segment in enumerate(segment["sub_segments"]):
|
||||
with dpg.table_row():
|
||||
dpg.add_text(str(sub_index))
|
||||
_segment_input_int(
|
||||
dpg,
|
||||
f"rsmt_{segment_index}_{sub_index}_mileage",
|
||||
int(sub_segment["mileage_m"]),
|
||||
lambda sender, value, user_data: _set_rsmt_sub_value(
|
||||
dpg, app, user_data[0], user_data[1], "mileage_m", int(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"rsmt_{segment_index}_{sub_index}_mileage", (segment_index, sub_index))
|
||||
_segment_input_float(
|
||||
dpg,
|
||||
f"rsmt_{segment_index}_{sub_index}_speed",
|
||||
float(sub_segment["speed_m_s"]),
|
||||
lambda sender, value, user_data: _set_rsmt_sub_value(
|
||||
dpg, app, user_data[0], user_data[1], "speed_m_s", float(value)
|
||||
),
|
||||
)
|
||||
dpg.set_item_user_data(f"rsmt_{segment_index}_{sub_index}_speed", (segment_index, sub_index))
|
||||
dpg.add_button(
|
||||
label="del",
|
||||
callback=lambda _, __, ___=None, i=segment_index, j=sub_index: _remove_rsmt_sub_segment(
|
||||
dpg, app, i, j
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _dialog_path(app_data) -> Path:
|
||||
if "file_path_name" in app_data:
|
||||
return Path(app_data["file_path_name"])
|
||||
return Path(app_data["current_path"]) / app_data["file_name"]
|
||||
|
||||
|
||||
def _build_file_dialogs(dpg, app: TrackRuntimeApp) -> None:
|
||||
with dpg.file_dialog(
|
||||
directory_selector=False,
|
||||
show=False,
|
||||
callback=lambda _, app_data, ___=None: app.load_from_file(dpg, _dialog_path(app_data)),
|
||||
tag=LOAD_DIALOG_TAG,
|
||||
width=700,
|
||||
height=420,
|
||||
):
|
||||
dpg.add_file_extension(".json", color=(120, 190, 255, 255))
|
||||
|
||||
with dpg.file_dialog(
|
||||
directory_selector=False,
|
||||
show=False,
|
||||
callback=lambda _, app_data, ___=None: app.save_as(dpg, _dialog_path(app_data)),
|
||||
tag=SAVE_DIALOG_TAG,
|
||||
width=700,
|
||||
height=420,
|
||||
):
|
||||
dpg.add_file_extension(".json", color=(120, 190, 255, 255))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dpg = _load_dearpygui()
|
||||
app = TrackRuntimeApp()
|
||||
|
||||
dpg.create_context()
|
||||
dpg.create_viewport(title="track-core emulator", width=920, height=560)
|
||||
dpg.create_viewport(title="track-core emulator", width=1160, height=690)
|
||||
|
||||
with dpg.window(label="track-core emulator", tag="main_window", width=900, height=540):
|
||||
with dpg.window(label="track-core emulator", tag="main_window", width=1140, height=660):
|
||||
with dpg.group(horizontal=True):
|
||||
with dpg.child_window(width=500, height=630, border=True):
|
||||
dpg.add_child_window(tag=EDITOR_TAG, width=480, height=610, border=False)
|
||||
with dpg.child_window(width=610, height=630, border=True):
|
||||
dpg.add_text("Runtime Preview")
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_combo(("runtime", "manual"), default_value="runtime", label="source", tag="source_mode")
|
||||
dpg.add_combo(("ST", "SM", "MT", "RSMT"), default_value="ST", label="scheme", tag="scheme_kind", callback=lambda *_: app.load_scheme(dpg))
|
||||
dpg.add_combo(("instant", "smooth"), default_value="smooth", label="accel", tag="accel_profile", callback=lambda *_: app.load_scheme(dpg))
|
||||
dpg.add_combo(("circular", "linear"), default_value="circular", label="draw", tag="draw_kind")
|
||||
dpg.add_combo(("circular", "linear"), default_value="circular", label="track model", tag="draw_kind")
|
||||
dpg.add_button(label="toggle model", callback=lambda *_: _toggle_draw_kind(dpg))
|
||||
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_button(label="load", callback=lambda *_: app.load_scheme(dpg))
|
||||
dpg.add_button(label="reload schema", callback=lambda *_: app.load_scheme(dpg))
|
||||
dpg.add_button(label="start", callback=lambda *_: app.start())
|
||||
dpg.add_button(label="stop", callback=lambda *_: app.stop())
|
||||
dpg.add_color_edit(default_value=(0, 255, 0, 255), label="color", tag="runner_color", no_alpha=True, callback=lambda *_: app.load_scheme(dpg))
|
||||
|
||||
dpg.add_slider_float(label="time scale", tag="time_scale", default_value=1.0, min_value=0.0, max_value=8.0)
|
||||
dpg.add_slider_float(label="line length m", tag="line_length", default_value=40.0, min_value=5.0, max_value=80.0)
|
||||
@@ -283,12 +730,13 @@ def main() -> None:
|
||||
dpg.add_slider_float(label="head offset m", tag="head_offset", default_value=0.0, min_value=-40.0, max_value=40.0)
|
||||
dpg.add_slider_int(label="LEDs", tag="led_count", default_value=160, min_value=8, max_value=400)
|
||||
dpg.add_checkbox(label="manual running", default_value=True, tag="manual_running")
|
||||
dpg.add_slider_float(label="manual mileage m", tag="manual_mileage", default_value=8.5, min_value=-80.0, max_value=120.0)
|
||||
dpg.add_slider_float(label="manual speed m/s", tag="manual_speed", default_value=1.0, min_value=0.0, max_value=10.0)
|
||||
|
||||
dpg.add_drawlist(width=880, height=330, tag=DRAWLIST_TAG)
|
||||
dpg.add_slider_float(label="manual position m", tag="manual_mileage", default_value=8.5, min_value=0.0, max_value=120.0)
|
||||
dpg.add_drawlist(width=590, height=330, tag=DRAWLIST_TAG)
|
||||
|
||||
_build_file_dialogs(dpg, app)
|
||||
rebuild_editor(dpg, app)
|
||||
app.load_scheme(dpg)
|
||||
|
||||
dpg.setup_dearpygui()
|
||||
dpg.set_primary_window("main_window", True)
|
||||
dpg.show_viewport()
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import (
|
||||
AccelerationProfile,
|
||||
Color,
|
||||
MTSegment,
|
||||
RepeatedSMSegment,
|
||||
SMSegment,
|
||||
STSegment,
|
||||
make_mileage_time_scheme,
|
||||
make_repeated_speed_mileage_time_scheme,
|
||||
make_speed_mileage_scheme,
|
||||
make_speed_time_scheme,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEME_KINDS = ("ST", "SM", "MT", "RSMT")
|
||||
ACCELERATION_PROFILES = ("instant", "smooth")
|
||||
|
||||
|
||||
def _sm(speed: float, mileage_m: int) -> SMSegment:
|
||||
segment = SMSegment()
|
||||
segment.speed_m_s = speed
|
||||
segment.mileage_from_start_m = mileage_m
|
||||
return segment
|
||||
|
||||
|
||||
def _mt(mileage_m: int, time_s: int) -> MTSegment:
|
||||
segment = MTSegment()
|
||||
segment.mileage_to_travel_this_segment_m = mileage_m
|
||||
segment.time_since_start_s = time_s
|
||||
return segment
|
||||
|
||||
|
||||
def _st(speed: float, time_s: int) -> STSegment:
|
||||
segment = STSegment()
|
||||
segment.speed_m_s = speed
|
||||
segment.time_since_start_s = time_s
|
||||
return segment
|
||||
|
||||
|
||||
def _rsmt(time_s: int, segments: list[SMSegment]) -> RepeatedSMSegment:
|
||||
segment = RepeatedSMSegment()
|
||||
segment.time_since_start_s = time_s
|
||||
segment.speed_mileage_segments = segments
|
||||
return segment
|
||||
|
||||
|
||||
def default_draft(kind: str = "ST") -> dict[str, Any]:
|
||||
if kind == "SM":
|
||||
segments: list[dict[str, Any]] = [
|
||||
{"mileage_m": 0, "speed_m_s": 1.0},
|
||||
{"mileage_m": 10, "speed_m_s": 2.8},
|
||||
{"mileage_m": 24, "speed_m_s": 1.2},
|
||||
{"mileage_m": 40, "speed_m_s": 2.0},
|
||||
]
|
||||
elif kind == "MT":
|
||||
segments = [
|
||||
{"time_s": 0, "mileage_m": 12},
|
||||
{"time_s": 6, "mileage_m": 20},
|
||||
{"time_s": 18, "mileage_m": 10},
|
||||
{"time_s": 28, "mileage_m": 1},
|
||||
]
|
||||
elif kind == "RSMT":
|
||||
segments = [
|
||||
{
|
||||
"time_s": 0,
|
||||
"sub_segments": [
|
||||
{"mileage_m": 0, "speed_m_s": 1.0},
|
||||
{"mileage_m": 10, "speed_m_s": 3.0},
|
||||
{"mileage_m": 24, "speed_m_s": 1.2},
|
||||
],
|
||||
},
|
||||
{
|
||||
"time_s": 18,
|
||||
"sub_segments": [
|
||||
{"mileage_m": 0, "speed_m_s": 2.2},
|
||||
{"mileage_m": 8, "speed_m_s": 4.0},
|
||||
{"mileage_m": 20, "speed_m_s": 1.0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"time_s": 42,
|
||||
"sub_segments": [
|
||||
{"mileage_m": 0, "speed_m_s": 1.4},
|
||||
{"mileage_m": 12, "speed_m_s": 2.4},
|
||||
{"mileage_m": 24, "speed_m_s": 1.4},
|
||||
],
|
||||
},
|
||||
]
|
||||
else:
|
||||
kind = "ST"
|
||||
segments = [
|
||||
{"time_s": 0, "speed_m_s": 1.0},
|
||||
{"time_s": 8, "speed_m_s": 3.2},
|
||||
{"time_s": 18, "speed_m_s": 1.4},
|
||||
{"time_s": 30, "speed_m_s": 2.4},
|
||||
{"time_s": 42, "speed_m_s": 0.8},
|
||||
]
|
||||
|
||||
return {
|
||||
"version": SCHEMA_VERSION,
|
||||
"id": 1,
|
||||
"kind": kind,
|
||||
"acceleration_profile": "smooth",
|
||||
"color": [0, 255, 0],
|
||||
"segments": segments,
|
||||
}
|
||||
|
||||
|
||||
def validation_error(draft: dict[str, Any]) -> str | None:
|
||||
try:
|
||||
normalize_draft(draft)
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_draft(draft: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(draft, dict):
|
||||
raise ValueError("scheme JSON must be an object")
|
||||
|
||||
version = _integer(draft.get("version", SCHEMA_VERSION), "version")
|
||||
if version != SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported scheme version {version}")
|
||||
|
||||
kind = str(draft.get("kind", "ST"))
|
||||
if kind not in SCHEME_KINDS:
|
||||
raise ValueError(f"unsupported scheme kind {kind!r}")
|
||||
|
||||
profile = str(draft.get("acceleration_profile", "smooth"))
|
||||
if profile not in ACCELERATION_PROFILES:
|
||||
raise ValueError(f"unsupported acceleration profile {profile!r}")
|
||||
|
||||
normalized = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"id": _bounded_int(draft.get("id", 1), "id", 0, 255),
|
||||
"kind": kind,
|
||||
"acceleration_profile": profile,
|
||||
"color": _color_list(draft.get("color", [0, 255, 0])),
|
||||
"segments": _normalize_segments(kind, draft.get("segments", [])),
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
def clone_draft(draft: dict[str, Any]) -> dict[str, Any]:
|
||||
return copy.deepcopy(draft)
|
||||
|
||||
|
||||
def load_draft(path: str | Path) -> dict[str, Any]:
|
||||
with Path(path).open("r", encoding="utf-8") as stream:
|
||||
return normalize_draft(json.load(stream))
|
||||
|
||||
|
||||
def save_draft(path: str | Path, draft: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = normalize_draft(draft)
|
||||
with Path(path).open("w", encoding="utf-8") as stream:
|
||||
json.dump(normalized, stream, indent=2)
|
||||
stream.write("\n")
|
||||
return normalized
|
||||
|
||||
|
||||
def draft_to_scheme(draft: dict[str, Any]):
|
||||
normalized = normalize_draft(draft)
|
||||
scheme_id = int(normalized["id"])
|
||||
color = Color(*normalized["color"])
|
||||
profile = (
|
||||
AccelerationProfile.smooth
|
||||
if normalized["acceleration_profile"] == "smooth"
|
||||
else AccelerationProfile.instant
|
||||
)
|
||||
|
||||
kind = normalized["kind"]
|
||||
segments = normalized["segments"]
|
||||
if kind == "SM":
|
||||
return make_speed_mileage_scheme(
|
||||
scheme_id,
|
||||
color,
|
||||
profile,
|
||||
[_sm(float(segment["speed_m_s"]), int(segment["mileage_m"])) for segment in segments],
|
||||
)
|
||||
if kind == "MT":
|
||||
return make_mileage_time_scheme(
|
||||
scheme_id,
|
||||
color,
|
||||
profile,
|
||||
[_mt(int(segment["mileage_m"]), int(segment["time_s"])) for segment in segments],
|
||||
)
|
||||
if kind == "RSMT":
|
||||
return make_repeated_speed_mileage_time_scheme(
|
||||
scheme_id,
|
||||
color,
|
||||
profile,
|
||||
[
|
||||
_rsmt(
|
||||
int(segment["time_s"]),
|
||||
[
|
||||
_sm(float(sub_segment["speed_m_s"]), int(sub_segment["mileage_m"]))
|
||||
for sub_segment in segment["sub_segments"]
|
||||
],
|
||||
)
|
||||
for segment in segments
|
||||
],
|
||||
)
|
||||
return make_speed_time_scheme(
|
||||
scheme_id,
|
||||
color,
|
||||
profile,
|
||||
[_st(float(segment["speed_m_s"]), int(segment["time_s"])) for segment in segments],
|
||||
)
|
||||
|
||||
|
||||
def _normalize_segments(kind: str, value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("segments must be a list")
|
||||
|
||||
if kind == "ST":
|
||||
segments = [
|
||||
{
|
||||
"time_s": _bounded_int(segment.get("time_s"), "time_s", 0, 65535),
|
||||
"speed_m_s": _nonnegative_float(segment.get("speed_m_s"), "speed_m_s"),
|
||||
}
|
||||
for segment in _objects(value, "segments")
|
||||
]
|
||||
_require_nonempty(segments, "ST")
|
||||
segments.sort(key=lambda segment: segment["time_s"])
|
||||
_require_first_zero(segments[0]["time_s"], "first ST time_s")
|
||||
_require_strictly_increasing(segments, "time_s")
|
||||
return segments
|
||||
|
||||
if kind == "SM":
|
||||
segments = [
|
||||
{
|
||||
"mileage_m": _bounded_int(segment.get("mileage_m"), "mileage_m", 0, 65535),
|
||||
"speed_m_s": _nonnegative_float(segment.get("speed_m_s"), "speed_m_s"),
|
||||
}
|
||||
for segment in _objects(value, "segments")
|
||||
]
|
||||
_require_nonempty(segments, "SM")
|
||||
segments.sort(key=lambda segment: segment["mileage_m"])
|
||||
_require_first_zero(segments[0]["mileage_m"], "first SM mileage_m")
|
||||
_require_strictly_increasing(segments, "mileage_m")
|
||||
return segments
|
||||
|
||||
if kind == "MT":
|
||||
segments = [
|
||||
{
|
||||
"time_s": _bounded_int(segment.get("time_s"), "time_s", 0, 65535),
|
||||
"mileage_m": _bounded_int(segment.get("mileage_m"), "mileage_m", 0, 65535),
|
||||
}
|
||||
for segment in _objects(value, "segments")
|
||||
]
|
||||
if len(segments) < 2:
|
||||
raise ValueError("MT needs at least two segments")
|
||||
segments.sort(key=lambda segment: segment["time_s"])
|
||||
_require_first_zero(segments[0]["time_s"], "first MT time_s")
|
||||
_require_strictly_increasing(segments, "time_s")
|
||||
for index, segment in enumerate(segments[:-1]):
|
||||
if segment["mileage_m"] <= 0:
|
||||
raise ValueError(f"MT segment {index} mileage_m must be greater than 0")
|
||||
return segments
|
||||
|
||||
segments = []
|
||||
for index, segment in enumerate(_objects(value, "segments")):
|
||||
sub_segments = [
|
||||
{
|
||||
"mileage_m": _bounded_int(sub_segment.get("mileage_m"), "mileage_m", 0, 65535),
|
||||
"speed_m_s": _nonnegative_float(sub_segment.get("speed_m_s"), "speed_m_s"),
|
||||
}
|
||||
for sub_segment in _objects(segment.get("sub_segments", []), f"RSMT segment {index} sub_segments")
|
||||
]
|
||||
_require_nonempty(sub_segments, f"RSMT segment {index}")
|
||||
sub_segments.sort(key=lambda sub_segment: sub_segment["mileage_m"])
|
||||
_require_first_zero(sub_segments[0]["mileage_m"], f"first RSMT segment {index} mileage_m")
|
||||
_require_strictly_increasing(sub_segments, "mileage_m")
|
||||
segments.append(
|
||||
{
|
||||
"time_s": _bounded_int(segment.get("time_s"), "time_s", 0, 65535),
|
||||
"sub_segments": sub_segments,
|
||||
}
|
||||
)
|
||||
|
||||
_require_nonempty(segments, "RSMT")
|
||||
segments.sort(key=lambda segment: segment["time_s"])
|
||||
_require_first_zero(segments[0]["time_s"], "first RSMT time_s")
|
||||
_require_strictly_increasing(segments, "time_s")
|
||||
return segments
|
||||
|
||||
|
||||
def _objects(value: Any, name: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"{name} must be a list")
|
||||
if not all(isinstance(item, dict) for item in value):
|
||||
raise ValueError(f"{name} entries must be objects")
|
||||
return value
|
||||
|
||||
|
||||
def _require_nonempty(segments: list[dict[str, Any]], kind: str) -> None:
|
||||
if not segments:
|
||||
raise ValueError(f"{kind} needs at least one segment")
|
||||
|
||||
|
||||
def _require_first_zero(value: int, name: str) -> None:
|
||||
if value != 0:
|
||||
raise ValueError(f"{name} must be 0")
|
||||
|
||||
|
||||
def _require_strictly_increasing(segments: list[dict[str, Any]], field: str) -> None:
|
||||
for index in range(1, len(segments)):
|
||||
if int(segments[index][field]) <= int(segments[index - 1][field]):
|
||||
raise ValueError(f"{field} must be strictly increasing")
|
||||
|
||||
|
||||
def _integer(value: Any, name: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"{name} must be an integer")
|
||||
try:
|
||||
result = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{name} must be an integer") from exc
|
||||
if result != value and not (isinstance(value, float) and value.is_integer()):
|
||||
raise ValueError(f"{name} must be an integer")
|
||||
return result
|
||||
|
||||
|
||||
def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
||||
result = _integer(value, name)
|
||||
if result < minimum or result > maximum:
|
||||
raise ValueError(f"{name} must be between {minimum} and {maximum}")
|
||||
return result
|
||||
|
||||
|
||||
def _nonnegative_float(value: Any, name: str) -> float:
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{name} must be a number") from exc
|
||||
if not math.isfinite(result) or result < 0.0:
|
||||
raise ValueError(f"{name} must be a finite non-negative number")
|
||||
return result
|
||||
|
||||
|
||||
def _color_list(value: Any) -> list[int]:
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 3:
|
||||
raise ValueError("color must contain red, green, and blue values")
|
||||
return [
|
||||
_bounded_int(value[0], "color red", 0, 255),
|
||||
_bounded_int(value[1], "color green", 0, 255),
|
||||
_bounded_int(value[2], "color blue", 0, 255),
|
||||
]
|
||||
+4
-12
@@ -249,8 +249,8 @@ TrackRenderPlan make_track_render_plan(
|
||||
}
|
||||
case TrackDrawKind::linear: {
|
||||
const auto drawer = LinearLineDrawer::from_report(config, report);
|
||||
const auto magic_color_ahead = Color::blue();
|
||||
const auto magic_color_behind = Color::cyan();
|
||||
const auto prefix_color = Color::cyan();
|
||||
const auto postfix_color = Color::blue();
|
||||
const auto center_offset = static_cast<int>(drawer.center_offset_leds_num());
|
||||
const auto line_leds_num = static_cast<int>(config.line_leds_num);
|
||||
const auto fill_positive_side = [&](std::uint16_t count, Color near_center, Color far_end) {
|
||||
@@ -281,16 +281,8 @@ TrackRenderPlan make_track_render_plan(
|
||||
};
|
||||
const auto ahead = drawer.center_ahead_leds_num();
|
||||
const auto behind = drawer.center_behind_leds_num();
|
||||
switch (drawer.direction) {
|
||||
case LinearLineDrawer::head_to_tail:
|
||||
fill_positive_side(ahead, info.color, magic_color_ahead);
|
||||
fill_negative_side(behind, info.color, magic_color_behind);
|
||||
break;
|
||||
case LinearLineDrawer::tail_to_head:
|
||||
fill_negative_side(ahead, info.color, magic_color_ahead);
|
||||
fill_positive_side(behind, info.color, magic_color_behind);
|
||||
break;
|
||||
}
|
||||
fill_positive_side(ahead, info.color, postfix_color);
|
||||
fill_negative_side(behind, info.color, prefix_color);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-12
@@ -117,18 +117,81 @@ void test_linear_render_reverse() {
|
||||
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");
|
||||
require_eq_u32(plan.spans[0].start_led, 10, "reverse span 0 start");
|
||||
require_eq_u32(plan.spans[0].led_count, 3, "reverse span 0 count");
|
||||
require(plan.spans[0].color == track_core::Color::red(), "reverse span 0 color");
|
||||
require_eq_u32(plan.spans[1].start_led, 13, "reverse span 1 start");
|
||||
require_eq_u32(plan.spans[1].led_count, 2, "reverse span 1 count");
|
||||
require(plan.spans[1].color == track_core::Color::blue(), "reverse span 1 color");
|
||||
require_eq_u32(plan.spans[2].start_led, 5, "reverse span 2 start");
|
||||
require_eq_u32(plan.spans[2].led_count, 2, "reverse span 2 count");
|
||||
require(plan.spans[2].color == track_core::Color::cyan(), "reverse span 2 color");
|
||||
require_eq_u32(plan.spans[3].start_led, 7, "reverse span 3 start");
|
||||
require_eq_u32(plan.spans[3].led_count, 3, "reverse span 3 count");
|
||||
require(plan.spans[3].color == track_core::Color::red(), "reverse span 3 color");
|
||||
}
|
||||
|
||||
void test_linear_render_prefix_postfix_colors_do_not_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 forward_report{
|
||||
.id = 3,
|
||||
.state = track_core::TrackState::run,
|
||||
.mileage_m = 5.0F,
|
||||
.speed_m_s = 2.0F,
|
||||
};
|
||||
const track_core::TrackReport reverse_report{
|
||||
.id = 3,
|
||||
.state = track_core::TrackState::run,
|
||||
.mileage_m = 15.0F,
|
||||
.speed_m_s = 2.0F,
|
||||
};
|
||||
|
||||
const auto forward_plan = track_core::make_track_render_plan(config, running_info(track_core::Color::red()), forward_report);
|
||||
const auto reverse_plan = track_core::make_track_render_plan(config, running_info(track_core::Color::red()), reverse_report);
|
||||
|
||||
require_eq_u32(static_cast<std::uint32_t>(forward_plan.span_count), static_cast<std::uint32_t>(reverse_plan.span_count), "linear same-position span count");
|
||||
for (std::size_t i = 0; i < forward_plan.span_count; ++i) {
|
||||
require_eq_u32(forward_plan.spans[i].start_led, reverse_plan.spans[i].start_led, "linear same-position span start");
|
||||
require_eq_u32(forward_plan.spans[i].led_count, reverse_plan.spans[i].led_count, "linear same-position span count");
|
||||
require(forward_plan.spans[i].color == reverse_plan.spans[i].color, "linear same-position span color");
|
||||
}
|
||||
}
|
||||
|
||||
void test_linear_render_reverse_near_ends_stays_visible() {
|
||||
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());
|
||||
|
||||
const auto require_visible = [&](float mileage_m, const char *message) {
|
||||
const track_core::TrackReport report{
|
||||
.id = 4,
|
||||
.state = track_core::TrackState::run,
|
||||
.mileage_m = mileage_m,
|
||||
.speed_m_s = 2.0F,
|
||||
};
|
||||
const auto plan = track_core::make_track_render_plan(config, info, report);
|
||||
|
||||
std::uint32_t total_leds = 0;
|
||||
for (std::size_t i = 0; i < plan.span_count; ++i) {
|
||||
total_leds += plan.spans[i].led_count;
|
||||
}
|
||||
|
||||
require(total_leds > 0, message);
|
||||
};
|
||||
|
||||
require_visible(10.1F, "reverse render visible just after right-end bounce");
|
||||
require_visible(19.9F, "reverse render visible just before left-end return");
|
||||
}
|
||||
|
||||
void test_linear_render_boundaries_apply() {
|
||||
@@ -505,6 +568,8 @@ int main() {
|
||||
test_circular_render_wraps();
|
||||
test_linear_render_forward_and_memory_strip();
|
||||
test_linear_render_reverse();
|
||||
test_linear_render_prefix_postfix_colors_do_not_reverse();
|
||||
test_linear_render_reverse_near_ends_stays_visible();
|
||||
test_linear_render_boundaries_apply();
|
||||
test_memory_strip_bounds();
|
||||
test_scheme_decoder();
|
||||
|
||||
Reference in New Issue
Block a user