step 6: add animation and high rate update stress tests

This commit is contained in:
2026-07-02 15:06:10 +02:00
parent 1a476bc91d
commit f4716e0d19
9 changed files with 352 additions and 21 deletions

View File

@@ -2,10 +2,11 @@
Dear PyGui gauge widgets for dashboards and telemetry displays.
Current implementation status: Step 5 first catalogue rendering is implemented. Analog, digital,
bar, level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear
PyGui drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks,
Current implementation status: Step 6 high-rate update support is implemented. Analog, digital, bar,
level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear PyGui
drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks,
redline bands, markers, and needles.
Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately
for simple value/configuration changes.
for simple value/configuration changes. Animation and smoothing are configurable per gauge; high-rate
examples marshal latest producer-thread values to GUI-thread frame callbacks.

View File

@@ -2,7 +2,7 @@
## Current status
Step 5 completed and verified with automated checks. Step 5 commit is pending.
Step 6 completed and verified with automated checks. Step 6 commit is pending.
## Completed steps
@@ -28,7 +28,19 @@ Step 1 - Public API contract and pure core:
## Current step
Step 6 - High-rate updates, animation, and stress examples.
Step 7 - Documentation, hardening, and beta release.
Step 6 - High-rate updates, animation, and stress examples:
Implemented during Step 6:
- Runtime updates now start animations using monotonic time for public calls.
- Optional smoothing now progresses displayed values toward the latest target instead of snapping immediately.
- Repeated GUI-thread value updates coalesce naturally to the newest target with no explicit queue.
- Renderer frame scheduling remains guarded so continuous callbacks are scheduled only while animation or smoothing is active.
- Debug state now includes detailed animation and smoothing fields.
- Added `examples/animations.py`, `examples/live_high_rate.py`, and `examples/live_worst_case.py` using GUI-thread frame callbacks for Dear PyGui updates.
- Added Step 6 tests for animation convergence, smoothing reset behaviour, latest-target wins, and renderer scheduling.
Step 5 - Analog, segmented, compass, thermometer, battery, and multi-value gauges:
@@ -91,7 +103,7 @@ Implemented during Step 2:
## Known issues
Manual visual example checks for Steps 3, 4, and 5 were not run in this headless session.
Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in this headless session.
## Commands used
@@ -118,6 +130,14 @@ Manual visual example checks for Steps 3, 4, and 5 were not run in this headless
- Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Committed Step 5 with `git commit -m "step 5: complete first gauge catalogue"` before starting Step 6.
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 6 changes.
- Implemented Step 6 animation/smoothing scheduling refinements, high-rate examples, README status update, diagnostics fields, and tests.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 2 changes.
- Implemented Step 2 state, sizing, animation, smoothing, runtime API, diagnostics, tests, and README status update.
- Ran `uv run pytest`.
@@ -142,4 +162,4 @@ Manual visual example checks for Steps 3, 4, and 5 were not run in this headless
## Next action
Commit Step 5, then implement Step 6.
Commit Step 6, then implement Step 7.

51
examples/animations.py Normal file
View File

@@ -0,0 +1,51 @@
# pyright: reportGeneralTypeIssues=false
import math
import time
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
dpg.create_context()
started = time.monotonic()
def update() -> None:
elapsed = time.monotonic() - started
dpgg.set_value("animated", 50 + math.sin(elapsed * 2.0) * 50)
dpgg.set_value("smoothed", 50 + math.sin(elapsed * 7.0) * 50)
dpg.set_frame_callback(dpg.get_frame_count() + 1, update)
try:
with dpg.window(label="Animations", width=460, height=260):
dpgg.digital_gauge(
tag="animated",
label="Animated",
value=0,
width=360,
height=90,
animation=dpgg.AnimationConfig(enabled=True, duration=0.35),
)
dpgg.bar_gauge(
tag="smoothed",
label="Smoothed",
value=0,
width=360,
height=90,
animation=False,
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.18, max_step=8),
)
dpg.create_viewport(title="dpg-gauges animations", width=500, height=300)
dpg.setup_dearpygui()
dpg.set_frame_callback(1, update)
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,73 @@
# pyright: reportGeneralTypeIssues=false
import math
import threading
import time
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
latest = {"rpm": 0.0, "speed": 0.0}
lock = threading.Lock()
stop = threading.Event()
def producer() -> None:
started = time.monotonic()
while not stop.is_set():
elapsed = time.monotonic() - started
with lock:
latest["rpm"] = 4000 + math.sin(elapsed * 9.0) * 3500
latest["speed"] = 120 + math.sin(elapsed * 3.0) * 80
time.sleep(1 / 240)
def gui_update() -> None:
with lock:
rpm = latest["rpm"]
speed = latest["speed"]
dpgg.set_value("rpm", rpm)
dpgg.set_value("speed", speed)
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
dpg.create_context()
thread = threading.Thread(target=producer, daemon=True)
try:
with dpg.window(label="High Rate", width=620, height=360):
dpgg.analog_gauge(
tag="rpm",
label="RPM",
value=0,
max_value=8000,
redline_start=6500,
width=240,
height=240,
animation=False,
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.22, max_step=500),
)
dpgg.digital_gauge(
tag="speed",
label="Speed",
value=0,
max_value=240,
unit="km/h",
width=260,
height=90,
animation=dpgg.AnimationConfig(enabled=True, duration=0.12),
)
dpg.create_viewport(title="dpg-gauges high rate", width=660, height=400)
dpg.setup_dearpygui()
thread.start()
dpg.set_frame_callback(1, gui_update)
dpg.show_viewport()
dpg.start_dearpygui()
finally:
stop.set()
thread.join(timeout=1.0)
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,58 @@
# pyright: reportGeneralTypeIssues=false
import math
import random
import threading
import time
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
latest = {"rpm": 0.0, "boost": 0.0, "fuel": 0.0, "battery": 0.0}
lock = threading.Lock()
stop = threading.Event()
def producer() -> None:
started = time.monotonic()
while not stop.is_set():
elapsed = time.monotonic() - started
with lock:
latest["rpm"] = random.uniform(0, 8000)
latest["boost"] = 15 + math.sin(elapsed * 25.0) * 15
latest["fuel"] = 50 + math.sin(elapsed * 4.0) * 50
latest["battery"] = random.uniform(0, 100)
time.sleep(1 / 500)
def gui_update() -> None:
with lock:
snapshot = dict(latest)
for tag, value in snapshot.items():
dpgg.set_value(tag, value)
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
dpg.create_context()
thread = threading.Thread(target=producer, daemon=True)
try:
with dpg.window(label="Worst Case", width=760, height=420), dpgg.gauge_grid(columns=4):
dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, width=180, height=180)
dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, width=180, height=90)
dpgg.level_gauge(tag="fuel", label="Fuel", width=120, height=180)
dpgg.battery_gauge(tag="battery", label="Battery", width=180, height=90)
dpg.create_viewport(title="dpg-gauges worst case", width=800, height=460)
dpg.setup_dearpygui()
thread.start()
dpg.set_frame_callback(1, gui_update)
dpg.show_viewport()
dpg.start_dearpygui()
finally:
stop.set()
thread.join(timeout=1.0)
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -25,6 +25,17 @@ def get_gauge_debug_state(tag: Tag) -> dict[str, Any]:
"config_revision": state.config_revision,
"animation_active": state.animation_state.active,
"smoothing_active": state.smoothing_state.active,
"animation": {
"start_value": state.animation_state.start_value,
"target_value": state.animation_state.target_value,
"started_at": state.animation_state.started_at,
"duration": state.animation_state.duration,
"active": state.animation_state.active,
},
"smoothing": {
"value": state.smoothing_state.value,
"active": state.smoothing_state.active,
},
}
if state.renderer is not None:
debug["renderer"] = state.renderer.debug_state()

View File

@@ -31,9 +31,16 @@ from .scales import (
)
from .sizing import effective_size, square_gauge_rect, update_measured_size
from .smoothing import advance_smoothing
from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size
from .state import (
DirtyFlags,
GaugeState,
animation_config,
size_state_for_gauge,
smoothing_config,
update_state_size,
)
from .themes import GaugeStyle
from .types import SmoothingConfig, StatusState, Tag
from .types import StatusState, Tag
Rect = tuple[float, float, float, float]
@@ -97,7 +104,7 @@ class GaugeRenderer:
if state.animation_state.active:
state.displayed_value, active = advance_animation(state.animation_state, config, now)
state.animation_state.active = active
smoothing = _smoothing_config(state)
smoothing = smoothing_config(state.config)
if not state.animation_state.active and smoothing is not None and smoothing.enabled:
state.displayed_value, _ = advance_smoothing(
state.smoothing_state, state.clamped_value, smoothing
@@ -640,13 +647,6 @@ def render_attached_gauge(state: GaugeState) -> None:
state.renderer.render()
def _smoothing_config(state: GaugeState) -> SmoothingConfig | None:
smoothing = state.config.smoothing
if isinstance(smoothing, bool):
return SmoothingConfig(enabled=smoothing)
return smoothing
def _drawlist_tag(state: GaugeState) -> Tag:
return cast(Tag, state.drawlist_tag)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field, replace
from enum import IntFlag, auto
from typing import Any
@@ -144,6 +145,13 @@ def animation_config(config: GaugeConfig) -> AnimationConfig | None:
return animation
def smoothing_config(config: GaugeConfig) -> SmoothingConfig | None:
smoothing = config.smoothing
if isinstance(smoothing, bool):
return SmoothingConfig(enabled=smoothing)
return smoothing
def clamp_runtime_value(value: GaugeValue, config: GaugeConfig) -> tuple[float | None, bool]:
numeric = coerce_numeric_value(value)
if numeric is None:
@@ -203,8 +211,9 @@ def get_gauge_state(tag: Tag) -> GaugeState:
raise GaugeNotFoundError(msg) from exc
def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None:
def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float | None = None) -> None:
state = get_gauge_state(tag)
now = time.monotonic() if now is None else now
clamped, invalid = clamp_runtime_value(value, state.config)
state.target_value = value
state.clamped_value = clamped
@@ -215,8 +224,15 @@ def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None:
state.displayed_value, clamped, animation_config(state.config), now
)
if not state.animation_state.active:
state.displayed_value = clamped
state.smoothing_state.value = clamped
smoothing = smoothing_config(state.config)
if smoothing is not None and smoothing.enabled and clamped is not None and not invalid:
if state.smoothing_state.value is None:
state.smoothing_state.value = state.previous_displayed_value
state.smoothing_state.active = state.smoothing_state.value != clamped
else:
state.displayed_value = clamped
state.smoothing_state.value = clamped
state.smoothing_state.active = False
state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
render_runtime_gauge(state)

View File

@@ -0,0 +1,101 @@
# pyright: reportGeneralTypeIssues=false
from collections.abc import Generator
import dearpygui.dearpygui as dpg
import pytest
import dpg_gauges as dpgg
from dpg_gauges.animation import advance_animation
from dpg_gauges.state import clear_registry, get_gauge_state, set_runtime_value
from dpg_gauges.types import AnimationConfig, SmoothingConfig
@pytest.fixture
def dpg_context() -> Generator[None]:
clear_registry()
dpgg.configure()
dpg.create_context()
try:
yield
finally:
clear_registry()
dpg.destroy_context()
@pytest.fixture(autouse=True)
def clean_registry() -> Generator[None]:
clear_registry()
dpgg.configure()
yield
clear_registry()
def test_animation_converges_to_latest_target() -> None:
dpgg.digital_gauge(tag="anim", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
set_runtime_value("anim", 100, now=10.0)
state = get_gauge_state("anim")
config = state.config.animation
assert isinstance(config, AnimationConfig)
value, active = advance_animation(state.animation_state, config, now=10.5)
assert value == 50
assert active is True
value, active = advance_animation(state.animation_state, config, now=11.0)
assert value == 100
assert active is False
def test_smoothing_progresses_and_resets_on_range_change() -> None:
dpgg.digital_gauge(
tag="smooth",
value=0,
smoothing=SmoothingConfig(enabled=True, alpha=0.5),
)
set_runtime_value("smooth", 100, now=1.0)
state = get_gauge_state("smooth")
assert state.displayed_value == 0
assert state.smoothing_state.active is True
dpgg.configure_gauge("smooth", max_value=50)
assert state.displayed_value == 50
assert state.smoothing_state.value == 50
def test_latest_target_wins_without_queue_growth() -> None:
dpgg.digital_gauge(tag="latest", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
set_runtime_value("latest", 25, now=1.0)
set_runtime_value("latest", 75, now=1.1)
set_runtime_value("latest", 50, now=1.2)
state = get_gauge_state("latest")
assert state.target_value == 50
assert state.clamped_value == 50
assert state.animation_state.target_value == 50
assert state.value_revision == 3
def test_attached_renderer_schedules_only_while_animation_active(dpg_context: None) -> None:
with dpg.window(label="Animation"):
dpgg.digital_gauge(
tag="attached",
value=0,
width=180,
height=80,
animation=AnimationConfig(enabled=True, duration=1.0),
)
dpgg.set_value("attached", 100)
debug = dpgg.get_gauge_debug_state("attached")
assert debug["renderer"]["scheduled"] is True
assert debug["animation"]["target_value"] == 100
state = get_gauge_state("attached")
state.animation_state.active = False
state.smoothing_state.active = False
state.renderer._scheduled = False
state.renderer.render()
assert dpgg.get_gauge_debug_state("attached")["renderer"]["scheduled"] is False