Refactor CANveyor dashboard architecture

- Replace `stats.utils.extractor` with dedicated `loader` and
  `converter`
  modules to improve code organization.
- Implement explicit pipeline stages for ingestion, decoding, and
  precomputation with caching.
- Standardize data loading and J1939 parsing logic across sub-modules.
- Enhance dashboard responsiveness by pre-calculating figures and
  downsampling ID-grouped data.
- Enforce strict typing and add docstrings to public components.
This commit is contained in:
2026-07-23 16:01:34 +02:00
parent 89a9124a83
commit 4f280da033
8 changed files with 575 additions and 440 deletions
+45 -30
View File
@@ -2,17 +2,28 @@
# Copyright (C) 2026 Erick Ahmed
# SPDX-License-Identifier: AGPL-3.0-or-later
import plotly.express as px
import pandas as pd
from dataclasses import dataclass
from typing import Callable
"""Komatsu-specific CAN frame decoder rules and custom plot definitions."""
from copy import deepcopy
from dataclasses import dataclass
from typing import Callable, Dict
import pandas as pd
import plotly.express as px
from vehicle.base import (
SignalDef, FrameDef, normalize_id, decode_dataframe as _decode_dataframe, plot_signal
FrameDef,
SignalDef,
decode_dataframe as _decode_dataframe,
normalize_id,
plot_signal,
)
@dataclass
class CustomPlotDef:
"""A non-signal entry in a FrameDef that carries its own plotting function."""
name: str
plot_func: Callable
@@ -28,7 +39,7 @@ load_state_sig = SignalDef(
)
load_state_sig.skip_plot = True
DECODER_RULES = {
DECODER_RULES: Dict[str, FrameDef] = {
normalize_id("0x011F"): FrameDef(
can_id="0x011F",
description="Engine ECM Main Broadcast",
@@ -84,7 +95,7 @@ DECODER_RULES = {
load_state_sig,
CustomPlotDef(
name="Engine Load State",
plot_func=lambda decoded, color: plot_load_state_pie(decoded, color)
plot_func=lambda decoded, color: plot_load_state_pie(decoded, color),
),
],
),
@@ -93,54 +104,58 @@ DECODER_RULES = {
LOAD_STATE_MAP = {
0: "Boot up",
16: "Normal load",
32: "High load"
32: "High load",
}
_LOAD_STATE_COLORS = {
"Boot up": "#ff9900",
"Normal load": "#00cc00",
"High load": "#cc0000",
"Unknown": "#808080",
}
def plot_load_state_pie(decoded_df, color):
"""Render a pie chart showing the distribution of engine load states."""
if decoded_df is None or decoded_df.empty or "Engine Load State" not in decoded_df.columns:
return px.pie(title="No data for Engine Load State")
states = pd.to_numeric(decoded_df["Engine Load State"], errors='coerce').dropna().astype(int)
states = pd.to_numeric(decoded_df["Engine Load State"], errors="coerce").dropna().astype(int)
labels = states.map(LOAD_STATE_MAP).fillna("Unknown")
counts = labels.value_counts().reset_index()
counts.columns = ['State', 'Count']
counts.columns = ["State", "Count"]
total = counts['Count'].sum()
counts['Percentage'] = (counts['Count'] / total * 100).round(1)
counts['Legend'] = counts['State'] + " (" + counts['Percentage'].astype(str) + "%)"
total = counts["Count"].sum()
counts["Percentage"] = (counts["Count"] / total * 100).round(1)
counts["Legend"] = counts["State"] + " (" + counts["Percentage"].astype(str) + "%)"
fig = px.pie(
counts,
values='Count',
names='Legend',
color='State',
title='Engine Load State Distribution',
color_discrete_map={
"Boot up": "#ff9900",
"Normal load": "#00cc00",
"High load": "#cc0000",
"Unknown": "#808080"
}
values="Count",
names="Legend",
color="State",
title="Engine Load State Distribution",
color_discrete_map=_LOAD_STATE_COLORS,
)
fig.update_traces(
textinfo='none',
hoverinfo='label+percent+value',
domain={'x': [0.05, 0.55], 'y': [0.05, 0.95]}
textinfo="none",
hoverinfo="label+percent+value",
domain={"x": [0.05, 0.55], "y": [0.05, 0.95]},
)
fig.update_layout(
margin=dict(l=0, r=10, t=40, b=0),
legend=dict(x=0.6, y=0.5)
legend=dict(x=0.6, y=0.5),
)
return fig
def decode_dataframe(df, can_id):
filtered_rules = {}
"""Decode *can_id* from *df*, filtering out non-SignalDef entries first."""
filtered_rules: Dict[str, FrameDef] = {}
for nid, frame in DECODER_RULES.items():
filtered_signals = [sig for sig in frame.signals if isinstance(sig, SignalDef)]
new_frame = deepcopy(frame)
new_frame.signals = filtered_signals
new_frame.signals = [s for s in frame.signals if isinstance(s, SignalDef)]
filtered_rules[nid] = new_frame
return _decode_dataframe(df, can_id, filtered_rules)