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. Dear PyGui gauge widgets for dashboards and telemetry displays.
Current implementation status: Step 5 first catalogue rendering is implemented. Analog, digital, Current implementation status: Step 6 high-rate update support is implemented. Analog, digital, bar,
bar, level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear PyGui
PyGui drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks, drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks,
redline bands, markers, and needles. redline bands, markers, and needles.
Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately 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 ## 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 ## Completed steps
@@ -28,7 +28,19 @@ Step 1 - Public API contract and pure core:
## Current step ## 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: Step 5 - Analog, segmented, compass, thermometer, battery, and multi-value gauges:
@@ -91,7 +103,7 @@ Implemented during Step 2:
## Known issues ## 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 ## 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 .`.
- Ran `uv run ruff format --check .`. - Ran `uv run ruff format --check .`.
- Ran `uv run pyright`. - 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. - 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. - Implemented Step 2 state, sizing, animation, smoothing, runtime API, diagnostics, tests, and README status update.
- Ran `uv run pytest`. - 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 ## 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, "config_revision": state.config_revision,
"animation_active": state.animation_state.active, "animation_active": state.animation_state.active,
"smoothing_active": state.smoothing_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: if state.renderer is not None:
debug["renderer"] = state.renderer.debug_state() 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 .sizing import effective_size, square_gauge_rect, update_measured_size
from .smoothing import advance_smoothing 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 .themes import GaugeStyle
from .types import SmoothingConfig, StatusState, Tag from .types import StatusState, Tag
Rect = tuple[float, float, float, float] Rect = tuple[float, float, float, float]
@@ -97,7 +104,7 @@ class GaugeRenderer:
if state.animation_state.active: if state.animation_state.active:
state.displayed_value, active = advance_animation(state.animation_state, config, now) state.displayed_value, active = advance_animation(state.animation_state, config, now)
state.animation_state.active = active 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: if not state.animation_state.active and smoothing is not None and smoothing.enabled:
state.displayed_value, _ = advance_smoothing( state.displayed_value, _ = advance_smoothing(
state.smoothing_state, state.clamped_value, smoothing state.smoothing_state, state.clamped_value, smoothing
@@ -640,13 +647,6 @@ def render_attached_gauge(state: GaugeState) -> None:
state.renderer.render() 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: def _drawlist_tag(state: GaugeState) -> Tag:
return cast(Tag, state.drawlist_tag) return cast(Tag, state.drawlist_tag)

View File

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