11 Commits

Author SHA1 Message Date
eeeck 89a9124a83 Add custom plotting support for vehicle signal
- Plot pie chart for engine load state
2026-07-23 15:37:50 +02:00
eeeck 0eac7a571f Add color configuration and extra signals to vehicle frames 2026-07-23 13:26:37 +02:00
eeeck be7cf9cb6a Minor text box tweak 2026-07-23 00:39:52 +02:00
eeeck 9dbf50d1c5 Simplify UI labels for vehicle and bus selectors 2026-07-23 00:38:33 +02:00
eeeck 7da427dd09 Implement a modular vehicle decoding system
- Add Komatsu specific rules
- Possibility to expand to any brand
2026-07-23 00:34:18 +02:00
eeeck fab448785b Replace infinite scroll with paginated log view 2026-07-22 23:09:47 +02:00
eeeck 3b703e845f Increase chunk size from 1000 to 50000 2026-07-22 22:00:39 +02:00
eeeck 54fd8ce74c Implement infinite scroll for logs table 2026-07-22 21:39:47 +02:00
eeeck e0a4d098d9 Replace Plotly graph-based tables with Dash DataTable 2026-07-22 21:23:58 +02:00
eeeck 42f8b844d9 Add log visualization tab to dashboard 2026-07-22 21:01:19 +02:00
eeeck 27998ff879 Add multi-vehicle support to dashboard and pipeline 2026-07-22 20:30:57 +02:00
7 changed files with 703 additions and 61 deletions
+83
View File
@@ -0,0 +1,83 @@
# File: logs/view.py
# Copyright (C) 2026 Erick Ahmed
# SPDX-License-Identifier: AGPL-3.0-or-later
import pandas as pd
from dash import html, dash_table, dcc
import dash_bootstrap_components as dbc
PAGE_SIZE = 25000
def prepare_logs_data(df: pd.DataFrame) -> pd.DataFrame:
if df is None or df.empty:
return pd.DataFrame()
df = df.copy()
if 'j1939_metadata' in df.columns:
df['Priority'] = df['j1939_metadata'].apply(lambda x: x.get('Priority') if isinstance(x, dict) else None)
df['PF'] = df['j1939_metadata'].apply(lambda x: x.get('PF') if isinstance(x, dict) else None)
df['PS'] = df['j1939_metadata'].apply(lambda x: x.get('PS') if isinstance(x, dict) else None)
df['SA'] = df['j1939_metadata'].apply(lambda x: x.get('SA') if isinstance(x, dict) else None)
df['DA'] = df['j1939_metadata'].apply(lambda x: x.get('DA') if isinstance(x, dict) else None)
df['PGN'] = df['j1939_metadata'].apply(lambda x: x.get('PGN') if isinstance(x, dict) else None)
else:
for col in ['Priority', 'PF', 'PS', 'SA', 'DA', 'PGN']:
df[col] = None
for i in range(8):
col = f'b{i}'
if col in df.columns:
df[col] = df[col].apply(lambda x: f"{int(x):02X}" if pd.notna(x) else "")
else:
df[col] = ""
if 'ID' in df.columns:
df['ID'] = df['ID'].astype(str)
display_cols = ['Timestamp', 'ID', 'DLC', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7', 'Priority', 'PF', 'PS', 'SA', 'DA', 'PGN']
display_df = df[[c for c in display_cols if c in df.columns]]
return display_df.fillna("")
def get_logs_table_component():
return html.Div([
html.Div(id='logs-info-text', className="text-muted mb-2"),
dash_table.DataTable(
id='logs-table',
virtualization=True,
page_action='none',
style_table={'overflowX': 'auto', 'height': '70vh', 'overflowY': 'auto'},
style_header={
'backgroundColor': '#1a1a1a',
'color': 'white',
'fontWeight': 'bold',
'textAlign': 'center',
'position': 'sticky',
'top': 0
},
style_data={
'backgroundColor': '#f8f9fa',
'color': '#2a2a2a',
'textAlign': 'center'
},
style_data_conditional=[
{
'if': {'row_index': 'odd'},
'backgroundColor': 'rgb(240, 240, 240)'
}
],
style_cell={
'minWidth': '80px',
'padding': '5px',
'textAlign': 'center',
'fontFamily': 'Segoe UI, Arial, sans-serif'
}
),
html.Div([
dbc.Button("Prev", id='logs-prev-btn', color="secondary", outline=True, size="sm", className="me-2"),
html.Div(id='logs-page-nav', className="d-inline-block", style={'verticalAlign': 'middle'}),
dbc.Button("Next", id='logs-next-btn', color="secondary", outline=True, size="sm", className="ms-2"),
], className="d-flex justify-content-center align-items-center mt-3"),
dcc.Store(id='logs-current-page', data=0),
])
+304 -57
View File
@@ -8,9 +8,10 @@ from concurrent.futures import ThreadPoolExecutor
import polars as pl import polars as pl
import dash import dash
from dash import dcc, html, Input, Output from dash import dcc, html, Input, Output, State
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import numpy as np import numpy as np
import pandas as pd
from parser import parse_log, parse_csv from parser import parse_log, parse_csv
from decoder import decode_j1939_frames from decoder import decode_j1939_frames
@@ -19,56 +20,85 @@ from stats.id_viewer import _format_can_id_vec, plot_bits
from stats.frequency import calculate_frequency, plot_frequency from stats.frequency import calculate_frequency, plot_frequency
from stats.correlation import calculate_correlation, plot_correlation_heatmap from stats.correlation import calculate_correlation, plot_correlation_heatmap
from stats.entropy import calculate_byte_entropy, plot_entropy_heatmap from stats.entropy import calculate_byte_entropy, plot_entropy_heatmap
from logs.view import get_logs_table_component, prepare_logs_data
from vehicle import get_vehicle_module
RAW_LOG = "data/logs/rawlog.txt" RAW_LOG_DIR = "data/logs"
BUS1_CSV = "data/csv/bus1.csv" PAGE_SIZE = 25000
BUS2_CSV = "data/csv/bus2.csv"
BUS1_PARQUET = "data/parquet/bus1.parquet" def parse_vehicle_from_filename(filename: str):
BUS2_PARQUET = "data/parquet/bus2.parquet" stem = Path(filename).stem
BUS1_DECODED = "data/parquet/bus1_decoded.parquet" if '-' in stem:
BUS2_DECODED = "data/parquet/bus2_decoded.parquet" brand, model_part = stem.split('-', 1)
else:
brand, model_part = stem, "Unknown"
model = model_part.replace('_', ' ')
vehicle = f"{brand} {model}".strip()
return vehicle, brand, model
def run_pipeline(): def run_pipeline():
os.makedirs("data/logs", exist_ok=True) os.makedirs(RAW_LOG_DIR, exist_ok=True)
os.makedirs("data/csv", exist_ok=True) os.makedirs("data/csv", exist_ok=True)
os.makedirs("data/parquet", exist_ok=True) os.makedirs("data/parquet", exist_ok=True)
if not Path(BUS1_DECODED).exists() or not Path(BUS2_DECODED).exists():
print("Parsing raw log...")
parse_log(RAW_LOG, BUS1_CSV, BUS2_CSV)
print("Converting to parquet...") for log_file in Path(RAW_LOG_DIR).glob("*.txt"):
parse_csv(BUS1_CSV).sink_parquet(BUS1_PARQUET) vehicle, brand, model = parse_vehicle_from_filename(log_file.name)
parse_csv(BUS2_CSV).sink_parquet(BUS2_PARQUET)
print("Decoding J1939...") bus1_csv = f"data/csv/{vehicle}_bus1.csv"
df1 = pl.read_parquet(BUS1_PARQUET) bus2_csv = f"data/csv/{vehicle}_bus2.csv"
df2 = pl.read_parquet(BUS2_PARQUET) bus1_parquet = f"data/parquet/{vehicle}_bus1.parquet"
dec1 = decode_j1939_frames(df1) bus2_parquet = f"data/parquet/{vehicle}_bus2.parquet"
dec2 = decode_j1939_frames(df2) bus1_decoded = f"data/parquet/{vehicle}_bus1_decoded.parquet"
dec1.write_parquet(BUS1_DECODED) bus2_decoded = f"data/parquet/{vehicle}_bus2_decoded.parquet"
dec2.write_parquet(BUS2_DECODED)
if not Path(bus1_decoded).exists() or not Path(bus2_decoded).exists():
print(f"Parsing raw log: {log_file.name}...")
parse_log(str(log_file), bus1_csv, bus2_csv)
print("Converting to parquet...")
parse_csv(bus1_csv).sink_parquet(bus1_parquet)
parse_csv(bus2_csv).sink_parquet(bus2_parquet)
print("Decoding J1939...")
df1 = pl.read_parquet(bus1_parquet)
df2 = pl.read_parquet(bus2_parquet)
dec1 = decode_j1939_frames(df1)
dec2 = decode_j1939_frames(df2)
dec1.write_parquet(bus1_decoded)
dec2.write_parquet(bus2_decoded)
run_pipeline() run_pipeline()
print("Loading data into memory...") print("Loading data into memory...")
DATA = { DATA = {}
"Bus 1": load_data(BUS1_DECODED), VEHICLE_META = {}
"Bus 2": load_data(BUS2_DECODED)
} for log_file in Path(RAW_LOG_DIR).glob("*.txt"):
vehicle, brand, model = parse_vehicle_from_filename(log_file.name)
VEHICLE_META[vehicle] = {"brand": brand, "model": model}
bus1_decoded = f"data/parquet/{vehicle}_bus1_decoded.parquet"
bus2_decoded = f"data/parquet/{vehicle}_bus2_decoded.parquet"
if Path(bus1_decoded).exists() and Path(bus2_decoded).exists():
DATA[vehicle] = {
"Bus 1": load_data(bus1_decoded),
"Bus 2": load_data(bus2_decoded)
}
PRECOMPUTED_FIGURES = {} PRECOMPUTED_FIGURES = {}
DATA_BY_ID = {} DATA_BY_ID = {}
CORR_CACHE = {} CORR_CACHE = {}
PREPARED_LOGS_CACHE = {}
def process_bus_data(bus, df): def process_bus_data(vehicle, bus, df):
precomp = {} precomp = {}
precomp[f"{bus}_freq"] = plot_frequency(calculate_frequency(df), title=f"{bus} Frequency") precomp[f"{vehicle}_{bus}_freq"] = plot_frequency(calculate_frequency(df), title=f"{vehicle} {bus} Frequency")
precomp[f"{bus}_entropy"] = plot_entropy_heatmap(calculate_byte_entropy(df), title=f"{bus} Byte-Level Entropy") precomp[f"{vehicle}_{bus}_entropy"] = plot_entropy_heatmap(calculate_byte_entropy(df), title=f"{vehicle} {bus} Byte-Level Entropy")
can_id_col = 'ID' if 'ID' in df.columns else 'Identifier' can_id_col = 'ID' if 'ID' in df.columns else 'Identifier'
formatted = _format_can_id_vec(df[can_id_col]) formatted = _format_can_id_vec(df[can_id_col])
df = df.assign(Formatted_ID=formatted) df = df.assign(Formatted_ID=formatted)
df = df.sort_values(['Formatted_ID', 'Timestamp'], kind='stable') df = df.sort_values(['Formatted_ID', 'Timestamp'], kind='stable')
grouped = {} grouped = {}
@@ -82,15 +112,17 @@ def process_bus_data(bus, df):
group = group.iloc[keep] group = group.iloc[keep]
grouped[can_id] = (group, byte_cols) grouped[can_id] = (group, byte_cols)
return precomp, grouped return vehicle, bus, precomp, grouped
with ThreadPoolExecutor() as executor: with ThreadPoolExecutor() as executor:
futures = {executor.submit(process_bus_data, bus, df): bus for bus, df in DATA.items()} futures = []
for vehicle, buses in DATA.items():
for bus, df in buses.items():
futures.append(executor.submit(process_bus_data, vehicle, bus, df))
for future in futures: for future in futures:
bus = futures[future] v, b, precomp, grouped = future.result()
precomp, grouped = future.result()
PRECOMPUTED_FIGURES.update(precomp) PRECOMPUTED_FIGURES.update(precomp)
DATA_BY_ID[bus] = grouped DATA_BY_ID[(v, b)] = grouped
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
app.config.suppress_callback_exceptions = True app.config.suppress_callback_exceptions = True
@@ -101,16 +133,54 @@ app.layout = dbc.Container([
dbc.Tab(label="Overview", tab_id="overview", children=[ dbc.Tab(label="Overview", tab_id="overview", children=[
html.Div(id="overview-content") html.Div(id="overview-content")
]), ]),
dbc.Tab(label="Statistics", tab_id="statistics", children=[ dbc.Tab(label="Vehicles", tab_id="vehicles", children=[
dbc.Row([ dbc.Row([
dbc.Col(html.Label("Select Bus:"), width=1, className="mt-2"), dbc.Col(html.Label("Vehicle:", className="mt-2"), width="auto"),
dbc.Col(dcc.Dropdown( dbc.Col(dcc.Dropdown(
id='bus-selector', id='vehicles-vehicle-selector',
options=[{'label': k, 'value': k} for k in DATA.keys()], options=[{'label': v, 'value': v} for v in DATA.keys()],
value=list(DATA.keys())[0] if DATA else None,
clearable=False
), width=3, className="me-4"),
], className="mb-3 mt-3", align="end"),
html.Div(id='vehicles-content', className="mt-3")
]),
dbc.Tab(label="Logs", tab_id="logs", children=[
dbc.Row([
dbc.Col(html.Label("Vehicle:", className="mt-2"), width="auto"),
dbc.Col(dcc.Dropdown(
id='logs-vehicle-selector',
options=[{'label': v, 'value': v} for v in DATA.keys()],
value=list(DATA.keys())[0] if DATA else None,
clearable=False
), width=3, className="me-4"),
dbc.Col(html.Label("Bus:", className="mt-2"), width="auto"),
dbc.Col(dcc.Dropdown(
id='logs-bus-selector',
options=[{'label': 'Bus 1', 'value': 'Bus 1'}, {'label': 'Bus 2', 'value': 'Bus 2'}],
value='Bus 1', value='Bus 1',
clearable=False clearable=False
), width=2), ), width=2),
], className="mb-3 mt-3"), ], className="mb-3 mt-3", align="end"),
get_logs_table_component()
]),
dbc.Tab(label="Statistics", tab_id="statistics", children=[
dbc.Row([
dbc.Col(html.Label("Vehicle:", className="mt-2"), width="auto"),
dbc.Col(dcc.Dropdown(
id='vehicle-selector',
options=[{'label': v, 'value': v} for v in DATA.keys()],
value=list(DATA.keys())[0] if DATA else None,
clearable=False
), width=3, className="me-4"),
dbc.Col(html.Label("Bus:", className="mt-2"), width="auto"),
dbc.Col(dcc.Dropdown(
id='bus-selector',
options=[{'label': 'Bus 1', 'value': 'Bus 1'}, {'label': 'Bus 2', 'value': 'Bus 2'}],
value='Bus 1',
clearable=False
), width=2),
], className="mb-3 mt-3", align="end"),
dbc.Tabs([ dbc.Tabs([
dbc.Tab(label="Frequency", tab_id="freq"), dbc.Tab(label="Frequency", tab_id="freq"),
dbc.Tab(label="ID Viewer", tab_id="id_viewer"), dbc.Tab(label="ID Viewer", tab_id="id_viewer"),
@@ -122,21 +192,136 @@ app.layout = dbc.Container([
], id="main-tabs", active_tab="statistics") ], id="main-tabs", active_tab="statistics")
], fluid=True) ], fluid=True)
def get_prepared_logs(vehicle, bus):
cache_key = (vehicle, bus)
if cache_key not in PREPARED_LOGS_CACHE:
df = DATA[vehicle][bus]
PREPARED_LOGS_CACHE[cache_key] = prepare_logs_data(df)
return PREPARED_LOGS_CACHE[cache_key]
def build_page_buttons(current_page: int, total_pages: int, max_buttons: int = 15) -> list:
buttons: list = []
if total_pages <= 1:
return buttons
half = max_buttons // 2
start = max(0, current_page - half)
end = min(total_pages, start + max_buttons)
if end - start < max_buttons:
start = max(0, end - max_buttons)
if start > 0:
buttons.append(
dbc.Button("1", id={'type': 'page-btn', 'index': 0}, color="secondary", outline=True, size="sm", className="me-1")
)
if start > 1:
buttons.append(html.Span("", className="mx-1 align-middle"))
for i in range(start, end):
is_current = (i == current_page)
buttons.append(
dbc.Button(
str(i + 1),
id={'type': 'page-btn', 'index': i},
size="sm",
color="primary" if is_current else "secondary",
outline=not is_current,
className="me-1",
disabled=is_current,
)
)
if end < total_pages:
if end < total_pages - 1:
buttons.append(html.Span("", className="mx-1 align-middle"))
buttons.append(
dbc.Button(
str(total_pages),
id={'type': 'page-btn', 'index': total_pages - 1},
color="secondary", outline=True, size="sm", className="me-1",
)
)
return buttons
@app.callback(
Output('logs-table', 'data'),
Output('logs-table', 'columns'),
Output('logs-info-text', 'children'),
Output('logs-page-nav', 'children'),
Output('logs-current-page', 'data'),
Input('logs-vehicle-selector', 'value'),
Input('logs-bus-selector', 'value'),
Input('logs-prev-btn', 'n_clicks'),
Input('logs-next-btn', 'n_clicks'),
Input({'type': 'page-btn', 'index': dash.ALL}, 'n_clicks'),
State('logs-current-page', 'data'),
)
def update_logs_table(vehicle, bus, prev_clicks, next_clicks, page_btn_clicks, current_page):
if (not vehicle or not bus or vehicle not in DATA or bus not in DATA[vehicle]):
return [], [], "No data available", [], 0
prepared_df = get_prepared_logs(vehicle, bus)
total_rows = len(prepared_df)
if total_rows == 0:
return [], [], "No data available", [], 0
total_pages = max(1, (total_rows + PAGE_SIZE - 1) // PAGE_SIZE)
ctx = dash.callback_context
triggered_id = ctx.triggered_id
current_page = current_page if current_page is not None else 0
if triggered_id in ('logs-vehicle-selector', 'logs-bus-selector'):
current_page = 0
elif triggered_id == 'logs-prev-btn':
current_page = max(0, current_page - 1)
elif triggered_id == 'logs-next-btn':
current_page = current_page + 1
elif isinstance(triggered_id, dict) and triggered_id.get('type') == 'page-btn':
if ctx.triggered and ctx.triggered[0]['value']:
current_page = triggered_id['index']
current_page = max(0, min(current_page, total_pages - 1))
start_idx = current_page * PAGE_SIZE
end_idx = min(start_idx + PAGE_SIZE, total_rows)
page_data = prepared_df.iloc[start_idx:end_idx].to_dict('records')
columns = [{"name": i, "id": i} for i in prepared_df.columns]
info_text = (f"Page {current_page + 1} of {total_pages} | "
f"Showing rows {start_idx + 1:,}{end_idx:,} "
f"of {total_rows:,} total frames")
page_buttons = build_page_buttons(current_page, total_pages)
return page_data, columns, info_text, page_buttons, current_page
@app.callback( @app.callback(
Output('tab-content', 'children'), Output('tab-content', 'children'),
Input('tabs', 'active_tab'), Input('tabs', 'active_tab'),
Input('vehicle-selector', 'value'),
Input('bus-selector', 'value') Input('bus-selector', 'value')
) )
def render_content(tab, bus): def render_content(tab, vehicle, bus):
df = DATA[bus] if not vehicle or not bus or vehicle not in DATA or bus not in DATA[vehicle]:
return html.Div("No data available")
df = DATA[vehicle][bus]
if tab == 'freq': if tab == 'freq':
return dcc.Graph(figure=PRECOMPUTED_FIGURES[f"{bus}_freq"], style={'height': '80vh'}) return dcc.Graph(figure=PRECOMPUTED_FIGURES[f"{vehicle}_{bus}_freq"], style={'height': '80vh'})
elif tab == 'id_viewer': elif tab == 'id_viewer':
ids = sorted(DATA_BY_ID[bus].keys()) ids = sorted(DATA_BY_ID.get((vehicle, bus), {}).keys())
return html.Div([ return html.Div([
html.Label("Select CAN ID:"), html.Label("CAN ID:"),
dcc.Dropdown( dcc.Dropdown(
id='id-selector', id='id-selector',
options=[{'label': i, 'value': i} for i in ids], options=[{'label': i, 'value': i} for i in ids],
@@ -148,7 +333,7 @@ def render_content(tab, bus):
]) ])
elif tab == 'corr': elif tab == 'corr':
ids = sorted(DATA_BY_ID[bus].keys()) ids = sorted(DATA_BY_ID.get((vehicle, bus), {}).keys())
return html.Div([ return html.Div([
dbc.Row([ dbc.Row([
dbc.Col(html.Label("Method:"), width=1, className="mt-2"), dbc.Col(html.Label("Method:"), width=1, className="mt-2"),
@@ -170,53 +355,115 @@ def render_content(tab, bus):
]) ])
elif tab == 'entropy': elif tab == 'entropy':
return dcc.Graph(figure=PRECOMPUTED_FIGURES[f"{bus}_entropy"], style={'height': '80vh'}) return dcc.Graph(figure=PRECOMPUTED_FIGURES[f"{vehicle}_{bus}_entropy"], style={'height': '80vh'})
return html.Div("Tab not found") return html.Div("Tab not found")
@app.callback( @app.callback(
Output('id-viewer-graph', 'figure'), Output('id-viewer-graph', 'figure'),
Input('id-selector', 'value'), Input('id-selector', 'value'),
Input('vehicle-selector', 'value'),
Input('bus-selector', 'value'), Input('bus-selector', 'value'),
Input('tabs', 'active_tab'), Input('tabs', 'active_tab'),
) )
def update_id_viewer(selected_id, bus, tab): def update_id_viewer(selected_id, vehicle, bus, tab):
if tab != 'id_viewer' or not selected_id: if tab != 'id_viewer' or not selected_id or not vehicle or not bus:
return dash.no_update return dash.no_update
grouped_data = DATA_BY_ID.get(bus, {}) grouped_data = DATA_BY_ID.get((vehicle, bus), {})
if selected_id not in grouped_data: if selected_id not in grouped_data:
return dash.no_update return dash.no_update
filtered_df, byte_cols = grouped_data[selected_id] filtered_df, byte_cols = grouped_data[selected_id]
return plot_bits(filtered_df, byte_cols, selected_id, title=f"{bus} Byte Visualization") return plot_bits(filtered_df, byte_cols, selected_id, title=f"{vehicle} {bus} Byte Visualization")
@app.callback( @app.callback(
Output('corr-graph', 'figure'), Output('corr-graph', 'figure'),
Input('corr-method', 'value'), Input('corr-method', 'value'),
Input('corr-target', 'value'), Input('corr-target', 'value'),
Input('vehicle-selector', 'value'),
Input('bus-selector', 'value'), Input('bus-selector', 'value'),
Input('tabs', 'active_tab'), Input('tabs', 'active_tab'),
) )
def update_corr(method, target, bus, tab): def update_corr(method, target, vehicle, bus, tab):
if tab != 'corr': if tab != 'corr' or not vehicle or not bus:
return dash.no_update return dash.no_update
target_id = None if target == 'all' or not target else target target_id = None if target == 'all' or not target else target
cache_key = (bus, method, target_id) cache_key = (vehicle, bus, method, target_id)
if cache_key not in CORR_CACHE: if cache_key not in CORR_CACHE:
df = DATA[bus] df = DATA[vehicle][bus]
corr_df = calculate_correlation(df, method=method, target_id=target_id) corr_df = calculate_correlation(df, method=method, target_id=target_id)
CORR_CACHE[cache_key] = corr_df CORR_CACHE[cache_key] = corr_df
else: else:
corr_df = CORR_CACHE[cache_key] corr_df = CORR_CACHE[cache_key]
title = f"{bus} Correlation" title = f"{vehicle} {bus} Correlation"
if target_id: if target_id:
title += f" ({target_id})" title += f" ({target_id})"
return plot_correlation_heatmap(corr_df, target_id=target_id, title=title) return plot_correlation_heatmap(corr_df, target_id=target_id, title=title)
@app.callback(
Output('vehicles-content', 'children'),
Input('vehicles-vehicle-selector', 'value')
)
def render_vehicles(vehicle):
if not vehicle or vehicle not in DATA:
return html.Div("No data available", className="text-muted")
brand = VEHICLE_META.get(vehicle, {}).get("brand", "")
vehicle_module = get_vehicle_module(brand)
dfs = []
for bus_df in DATA[vehicle].values():
dfs.append(bus_df)
if not dfs:
return html.Div("No data available", className="text-muted")
df = pd.concat(dfs, ignore_index=True)
if 'Timestamp' in df.columns:
df = df.sort_values('Timestamp', kind='stable').reset_index(drop=True)
cards = []
for nid, frame_def in vehicle_module.DECODER_RULES.items():
decoded = vehicle_module.decode_dataframe(df, frame_def.can_id)
for item in frame_def.signals:
if hasattr(item, 'plot_func') and callable(item.plot_func):
title = f"{frame_def.can_id} - {item.name}"
fig = item.plot_func(decoded, frame_def.color)
else:
sig = item
if getattr(sig, 'skip_plot', False):
continue
unit_str = f" ({sig.unit})" if sig.unit else ""
title = f"{frame_def.can_id} - {sig.name}{unit_str}"
fig = vehicle_module.plot_signal(decoded, sig.name, title=title, color=frame_def.color)
card = dbc.Card([
dbc.CardBody([
dcc.Graph(figure=fig, config={'displayModeBar': False},
style={'height': '280px'})
], className="p-2"),
], className="shadow-sm border-0 h-100")
cards.append(
dbc.Col(card, xs=12, sm=6, md=4, lg=3, className="mb-3")
)
if not cards:
return html.Div(
"No decoded signals available. Add rules in the vehicle module.",
className="text-muted"
)
return dbc.Row(cards)
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True) app.run(debug=False)
+6 -2
View File
@@ -83,8 +83,12 @@ def calculate_correlation(df: pd.DataFrame, method: str, target_id: str | None =
return c.max(axis=0) return c.max(axis=0)
return np.zeros(n_cols, dtype=np.float64) return np.zeros(n_cols, dtype=np.float64)
with ThreadPoolExecutor() as executor: out = np.zeros((len(unique_ids), n_cols), dtype=np.float64)
out = np.array(list(executor.map(_process_group, groups))) if len(groups) > 0:
with ThreadPoolExecutor() as executor:
results = list(executor.map(_process_group, groups))
for i, res in enumerate(results):
out[i] = res
result = pd.DataFrame(out, index=unique_ids, columns=available_cols) result = pd.DataFrame(out, index=unique_ids, columns=available_cols)
result.index.name = 'Identifier' result.index.name = 'Identifier'
+6 -2
View File
@@ -70,8 +70,12 @@ def calculate_byte_entropy(df: pd.DataFrame) -> pd.DataFrame:
res[ci] = _entropy_col(sub[:, ci]) res[ci] = _entropy_col(sub[:, ci])
return res return res
with ThreadPoolExecutor() as executor: out = np.zeros((len(unique_ids), n_cols), dtype=np.float64)
out = np.array(list(executor.map(_process_group, groups))) if len(groups) > 0:
with ThreadPoolExecutor() as executor:
results = list(executor.map(_process_group, groups))
for i, res in enumerate(results):
out[i] = res
result = pd.DataFrame(out, index=unique_ids, columns=available_cols) result = pd.DataFrame(out, index=unique_ids, columns=available_cols)
result.index.name = 'Identifier' result.index.name = 'Identifier'
+19
View File
@@ -0,0 +1,19 @@
# File: vehicle/__init__.py
# Copyright (C) 2026 Erick Ahmed
# SPDX-License-Identifier: AGPL-3.0-or-later
import importlib
def get_vehicle_module(brand: str):
"""
Dynamically imports the correct decoder module based on the vehicle brand.
Falls back to 'vehicle.generic' if a specific brand module is not found.
"""
if not brand:
return importlib.import_module("vehicle.generic")
module_name = f"vehicle.{brand.lower().replace(' ', '_')}"
try:
return importlib.import_module(module_name)
except ModuleNotFoundError:
return importlib.import_module("vehicle.generic")
+139
View File
@@ -0,0 +1,139 @@
# File: vehicle/base.py
# Copyright (C) 2026 Erick Ahmed
# SPDX-License-Identifier: AGPL-3.0-or-later
from dataclasses import dataclass, field
from typing import List
import numpy as np
import pandas as pd
import plotly.graph_objects as go
@dataclass
class SignalDef:
name: str
bit_start: int
bit_length: int
factor: float = 1.0
offset: float = 0.0
is_signed: bool = False
byte_order: str = "little"
unit: str = ""
@dataclass
class FrameDef:
can_id: str
description: str = ""
color: str = "#377eb8"
signals: List[SignalDef] = field(default_factory=list)
def normalize_id(can_id: str) -> str:
s = str(can_id).strip().upper()
if s.startswith("0X"):
s = s[2:]
return s.lstrip("0") or "0"
def _extract_signal(bytes_arr: np.ndarray, sig: SignalDef) -> np.ndarray:
if bytes_arr.size == 0:
return np.zeros(0, dtype=np.float64)
byte_lo = sig.bit_start // 8
byte_hi = (sig.bit_start + sig.bit_length - 1) // 8
byte_indices = [i for i in range(byte_lo, byte_hi + 1) if 0 <= i < 8]
if not byte_indices:
return np.full(bytes_arr.shape[0], np.nan, dtype=np.float64)
raw = np.zeros(bytes_arr.shape[0], dtype=np.int64)
if sig.byte_order == "little":
for shift, bi in enumerate(byte_indices):
raw += bytes_arr[:, bi].astype(np.int64) << (shift * 8)
else:
for shift, bi in enumerate(reversed(byte_indices)):
raw += bytes_arr[:, bi].astype(np.int64) << (shift * 8)
intra_byte_shift = sig.bit_start % 8
raw = raw >> intra_byte_shift
mask = (1 << sig.bit_length) - 1
raw = raw & mask
if sig.is_signed and sig.bit_length < 64:
sign_bit = 1 << (sig.bit_length - 1)
raw = (raw ^ sign_bit) - sign_bit
return raw.astype(np.float64) * sig.factor + sig.offset
def decode_dataframe(df: pd.DataFrame, can_id: str, decoder_rules: dict) -> pd.DataFrame:
norm = normalize_id(can_id)
if norm not in decoder_rules:
return pd.DataFrame()
frame_def = decoder_rules[norm]
id_col = "ID" if "ID" in df.columns else "Identifier"
df_ids = df[id_col].astype(str).map(normalize_id)
mask = df_ids == norm
sub = df.loc[mask].copy()
if sub.empty:
return pd.DataFrame()
byte_cols = [f"b{i}" for i in range(8) if f"b{i}" in sub.columns]
if not byte_cols:
return pd.DataFrame()
arr = np.zeros((len(sub), 8), dtype=np.int64)
for i, c in enumerate(byte_cols):
arr[:, i] = pd.to_numeric(sub[c], errors="coerce").fillna(0).astype(np.int64).to_numpy()
out = pd.DataFrame()
out["Timestamp"] = sub["Timestamp"].to_numpy() if "Timestamp" in sub.columns else np.arange(len(sub))
for sig in frame_def.signals:
out[sig.name] = _extract_signal(arr, sig)
return out
def plot_signal(df: pd.DataFrame, signal_name: str, title: str, color: str = "#377eb8", height: int = 280) -> go.Figure:
fig = go.Figure()
if df.empty or signal_name not in df.columns:
fig.update_layout(
title=dict(text=title, font=dict(size=14)),
annotations=[dict(text="No data", showarrow=False, x=0.5, y=0.5,
font=dict(size=13, color="#888"))],
height=height,
template="plotly_white",
)
return fig
fig.add_trace(go.Scatter(
x=df["Timestamp"],
y=df[signal_name],
mode="lines",
line=dict(width=2, color=color),
name=signal_name,
hovertemplate=f"<b>{signal_name}</b><br>Time: %{{x}}<br>Value: %{{y:.2f}}<extra></extra>",
))
fig.update_layout(
title=dict(text=title, font=dict(size=14, color="#1a1a1a"),
x=0.5, xanchor="center", pad=dict(b=10)),
height=height,
autosize=True,
template="plotly_white",
margin=dict(l=55, r=20, t=55, b=45),
xaxis=dict(
title=dict(text="Time", font=dict(size=11)),
showgrid=True, gridwidth=0.5, gridcolor="#eee",
zeroline=False, linecolor="#bdbdbd",
),
yaxis=dict(
title=dict(text=signal_name, font=dict(size=11)),
showgrid=True, gridwidth=0.5, gridcolor="#eee",
zeroline=False, linecolor="#bdbdbd",
),
font=dict(family="Segoe UI, Arial, sans-serif", size=11, color="#2a2a2a"),
hoverlabel=dict(bgcolor="white", font_size=12,
font_family="Segoe UI", bordercolor="#cccccc"),
)
return fig
+146
View File
@@ -0,0 +1,146 @@
# File: vehicle/komatsu.py
# 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
from copy import deepcopy
from vehicle.base import (
SignalDef, FrameDef, normalize_id, decode_dataframe as _decode_dataframe, plot_signal
)
@dataclass
class CustomPlotDef:
name: str
plot_func: Callable
load_state_sig = SignalDef(
name="Engine Load State",
bit_start=24,
bit_length=8,
factor=1,
offset=0.0,
is_signed=False,
byte_order="big",
unit="",
)
load_state_sig.skip_plot = True
DECODER_RULES = {
normalize_id("0x011F"): FrameDef(
can_id="0x011F",
description="Engine ECM Main Broadcast",
color="#e41a1c",
signals=[
SignalDef(
name="Engine",
bit_start=0,
bit_length=16,
factor=0.125,
offset=0.0,
is_signed=False,
byte_order="big",
unit="RPM",
),
SignalDef(
name="Pressure / Load",
bit_start=16,
bit_length=16,
factor=0.05,
offset=0,
is_signed=False,
byte_order="little",
unit="%",
),
],
),
normalize_id("0x0CFF3300"): FrameDef(
can_id="0x0CFF3300",
description="Engine temperature and load state block",
color="#0080fe",
signals=[
SignalDef(
name="Engine coolant temp",
bit_start=8,
bit_length=8,
factor=1,
offset=0.0,
is_signed=False,
byte_order="big",
unit="",
),
SignalDef(
name="Engine oil temp",
bit_start=40,
bit_length=8,
factor=1,
offset=0.0,
is_signed=False,
byte_order="big",
unit="",
),
load_state_sig,
CustomPlotDef(
name="Engine Load State",
plot_func=lambda decoded, color: plot_load_state_pie(decoded, color)
),
],
),
}
LOAD_STATE_MAP = {
0: "Boot up",
16: "Normal load",
32: "High load"
}
def plot_load_state_pie(decoded_df, color):
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)
labels = states.map(LOAD_STATE_MAP).fillna("Unknown")
counts = labels.value_counts().reset_index()
counts.columns = ['State', 'Count']
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"
}
)
fig.update_traces(
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)
)
return fig
def decode_dataframe(df, can_id):
filtered_rules = {}
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
filtered_rules[nid] = new_frame
return _decode_dataframe(df, can_id, filtered_rules)