Skip to content

Commit 966ff0b

Browse files
authored
Merge pull request #66 from Western-Formula-Racing/fixes-apr28-test
PECAN sampling improvement, data uploader now supports .pecan
2 parents 9b6fd50 + 59bf673 commit 966ff0b

7 files changed

Lines changed: 161 additions & 74 deletions

File tree

pecan/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ function App() {
7878
dataStore.clearPersistedSnapshot();
7979
dataStore.notifyBoundsRefresh();
8080
clearCheckpoints();
81+
localStorage.removeItem("dash:plots");
82+
window.location.reload();
8183
};
8284

8385
useEffect(() => {

pecan/src/components/PlotManager.tsx

Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,10 @@ const PLOT_COLORS = [
1515
"#00bbcc",
1616
];
1717

18-
// Helper to calculate downsample resolution based on time window
19-
function calculateDownsampleResolution(windowMs: number): number {
20-
// Under 3s (3000ms), use 200ms resolution
21-
if (windowMs <= 3000) return 200;
22-
// Above 20s (20000ms), use 1000ms resolution
23-
if (windowMs >= 20000) return 1000;
24-
25-
// Linear interpolation between 3000ms and 20000ms
26-
// Range: 17000ms. Value range: 800ms.
27-
return 200 + ((windowMs - 3000) / 17000) * 800;
18+
// Returns downsample resolution in ms, or null for no downsampling (raw points).
19+
function calculateDownsampleResolution(windowMs: number): number | null {
20+
if (windowMs <= 30000) return null;
21+
return 100;
2822
}
2923

3024
export interface PlotSignal {
@@ -171,43 +165,44 @@ function PlotManager({
171165
const yData: number[] = [];
172166

173167
if (history.length > 0) {
174-
let currentBinStart =
175-
Math.floor(history[0].timestamp / resolution) * resolution;
176-
let currentSum = 0;
177-
let currentCount = 0;
178-
179-
for (const sample of history) {
180-
const signalData = sample.data[signal.signalName];
181-
if (signalData === undefined) continue;
182-
183-
const sampleBin =
184-
Math.floor(sample.timestamp / resolution) * resolution;
185-
186-
if (sampleBin === currentBinStart) {
187-
currentSum += signalData.sensorReading;
188-
currentCount++;
189-
} else {
190-
// Finalize previous bin
191-
if (currentCount > 0) {
192-
const avg = currentSum / currentCount;
193-
const x = (currentBinStart - windowEndMs) / 1000;
194-
xData.push(x);
195-
yData.push(avg);
168+
if (resolution === null) {
169+
for (const sample of history) {
170+
const signalData = sample.data[signal.signalName];
171+
if (signalData === undefined) continue;
172+
xData.push((sample.timestamp - windowEndMs) / 1000);
173+
yData.push(signalData.sensorReading);
174+
}
175+
} else {
176+
let currentBinStart =
177+
Math.floor(history[0].timestamp / resolution) * resolution;
178+
let currentSum = 0;
179+
let currentCount = 0;
180+
181+
for (const sample of history) {
182+
const signalData = sample.data[signal.signalName];
183+
if (signalData === undefined) continue;
184+
185+
const sampleBin =
186+
Math.floor(sample.timestamp / resolution) * resolution;
187+
188+
if (sampleBin === currentBinStart) {
189+
currentSum += signalData.sensorReading;
190+
currentCount++;
191+
} else {
192+
if (currentCount > 0) {
193+
xData.push((currentBinStart - windowEndMs) / 1000);
194+
yData.push(currentSum / currentCount);
195+
}
196+
currentBinStart = sampleBin;
197+
currentSum = signalData.sensorReading;
198+
currentCount = 1;
196199
}
197-
198-
// Move to new bin
199-
currentBinStart = sampleBin;
200-
currentSum = signalData.sensorReading;
201-
currentCount = 1;
202200
}
203-
}
204201

205-
// Finalize last bin
206-
if (currentCount > 0) {
207-
const avg = currentSum / currentCount;
208-
const x = (currentBinStart - windowEndMs) / 1000;
209-
xData.push(x);
210-
yData.push(avg);
202+
if (currentCount > 0) {
203+
xData.push((currentBinStart - windowEndMs) / 1000);
204+
yData.push(currentSum / currentCount);
205+
}
211206
}
212207
}
213208

pecan/src/pages/Dashboard.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,13 @@ function Dashboard() {
103103

104104
// Plotting State
105105
// =====================================================================
106-
const [plots, setPlots] = useState<Plot[]>([]);
106+
const [plots, setPlots] = useState<Plot[]>(() => {
107+
try {
108+
const raw = localStorage.getItem("dash:plots");
109+
if (raw) return JSON.parse(raw) as Plot[];
110+
} catch { /* ignore */ }
111+
return [];
112+
});
107113
const [nextPlotId, setNextPlotId] = useState(1);
108114
const livePlotsSnapshotRef = useRef<Plot[] | null>(null);
109115
// Stores the loadedAtMs of the replay session whose layout has been applied,
@@ -162,6 +168,13 @@ function Dashboard() {
162168
}
163169
}, [plots, viewMode, sortingMethod, session, saveConfig]);
164170

171+
// Persist plots locally so they survive page refresh
172+
useEffect(() => {
173+
try {
174+
localStorage.setItem("dash:plots", JSON.stringify(plots));
175+
} catch { /* ignore */ }
176+
}, [plots]);
177+
165178
// Data
166179
// =====================================================================
167180

@@ -850,6 +863,9 @@ function Dashboard() {
850863
<label className="text-gray-300 text-sm">
851864
Time Window (seconds, max 120):
852865
</label>
866+
{plotTimeWindow > 30000 && (
867+
<p className="text-yellow-400 text-xs">Downsampling to 100ms bins (window &gt; 30s)</p>
868+
)}
853869
<input
854870
type="number"
855871
min="1"

server/installer/file-uploader/app.py

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
Response,
88
)
99
import uuid, time, threading, json, logging, requests, os, asyncio, io, zipfile
10+
from datetime import datetime, timezone
11+
from zoneinfo import ZoneInfo
1012
from typing import Optional, Tuple, List
1113
from urllib.parse import quote
1214
from helper import CANTimescaleStreamer
@@ -20,7 +22,7 @@
2022

2123
error_logger = logging.getLogger(__name__)
2224

23-
ALLOWED_EXTENSIONS = {"csv", "zip"}
25+
ALLOWED_EXTENSIONS = {"csv", "zip", "pecan"}
2426
UPLOAD_ZIP_MAX_ARCHIVE_BYTES = int(os.getenv("UPLOAD_ZIP_MAX_ARCHIVE_BYTES", str(2 * 1024**3)))
2527
UPLOAD_ZIP_MAX_MEMBER_BYTES = int(os.getenv("UPLOAD_ZIP_MAX_MEMBER_BYTES", str(4 * 1024**3)))
2628
UPLOAD_ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES = int(
@@ -155,6 +157,16 @@ def _zip_entry_path_safe(arcname: str) -> bool:
155157
return ".." not in n.split("/")
156158

157159

160+
class _InMemoryFile:
161+
"""Minimal file-like object for passing in-memory bytes through expand_upload_files_to_csv_payloads."""
162+
def __init__(self, filename: str, data: bytes):
163+
self.filename = filename
164+
self._data = data
165+
166+
def read(self) -> bytes:
167+
return self._data
168+
169+
158170
def expand_upload_files_to_csv_payloads(files) -> Tuple[List[Tuple[str, bytes]], Optional[str]]:
159171
out: List[Tuple[str, bytes]] = []
160172
zip_idx = 0
@@ -178,36 +190,76 @@ def expand_upload_files_to_csv_payloads(files) -> Tuple[List[Tuple[str, bytes]],
178190
infos = [
179191
i for i in z.infolist()
180192
if not i.is_dir()
181-
and i.filename.lower().endswith(".csv")
193+
and (i.filename.lower().endswith(".csv") or i.filename.lower().endswith(".pecan"))
182194
and _zip_entry_path_safe(i.filename)
183195
# exclude macOS resource forks (__MACOSX/ and ._filename)
184196
and not i.filename.startswith("__MACOSX/")
185197
and not os.path.basename(i.filename).startswith("._")
186198
]
187199
if not infos:
188-
return [], f"No CSV files found in zip: {name}"
200+
return [], f"No CSV or .pecan files found in zip: {name}"
189201
if len(infos) > UPLOAD_ZIP_MAX_CSV_IN_ZIP:
190202
return [], f"Too many CSV entries in {name} (max {UPLOAD_ZIP_MAX_CSV_IN_ZIP})"
191203
total_uc = sum(i.file_size for i in infos)
192204
if total_uc > UPLOAD_ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES:
193205
return [], f"Zip {name} uncompressed total too large"
194206
for i in infos:
195207
if i.file_size > UPLOAD_ZIP_MAX_MEMBER_BYTES:
196-
return [], f"CSV inside zip too large: {i.filename} in {name}"
208+
return [], f"File inside zip too large: {i.filename} in {name}"
197209
leaf = os.path.basename(i.filename) or "data.csv"
198210
key = (zlabel, leaf.lower())
199211
if key in seen_in_zip:
200-
return [], f'Duplicate CSV filename "{leaf}" inside zip {name}'
212+
return [], f'Duplicate filename "{leaf}" inside zip {name}'
201213
seen_in_zip.add(key)
202214
with z.open(i, "r") as fp:
203215
body = fp.read()
204-
out.append((f"_z{zlabel}/{leaf}", body))
216+
if leaf.lower().endswith(".pecan"):
217+
# Convert .pecan to CSV in-place so the pipeline is uniform
218+
sub_out, err = expand_upload_files_to_csv_payloads(
219+
[_InMemoryFile(leaf, body)]
220+
)
221+
if err:
222+
return [], f"{err} (inside zip {name})"
223+
out.extend(sub_out)
224+
else:
225+
out.append((f"_z{zlabel}/{leaf}", body))
205226
except zipfile.BadZipFile:
206227
return [], f"Invalid or corrupt zip: {name}"
207228
except RuntimeError as e:
208229
return [], f"Could not read zip {name}: {e}"
230+
elif ext == "pecan":
231+
try:
232+
payload = json.loads(data.decode("utf-8"))
233+
except Exception:
234+
return [], f"Invalid .pecan file (bad JSON): {name}"
235+
if payload.get("format") != "pecan-session" or payload.get("version") != 2:
236+
return [], f".pecan file must be pecan-session v2 format: {name}"
237+
frames = payload.get("frames") or []
238+
if not frames:
239+
return [], f"No frames in .pecan file: {name}"
240+
epoch_base_ms = payload.get("epochBaseMs")
241+
if epoch_base_ms is None:
242+
return [], f".pecan file missing epochBaseMs — cannot determine timestamps: {name}"
243+
tz_toronto = ZoneInfo("America/Toronto")
244+
start_dt = datetime.fromtimestamp(epoch_base_ms / 1000, tz=tz_toronto)
245+
csv_filename = start_dt.strftime("%Y-%m-%d-%H-%M-%S") + ".csv"
246+
lines = []
247+
for frame in frames:
248+
if not isinstance(frame, list) or len(frame) < 4:
249+
continue
250+
try:
251+
t_rel_ms = int(frame[0])
252+
can_id = int(frame[1])
253+
data_bytes = bytes.fromhex(str(frame[3]))
254+
padded = (data_bytes + b"\x00" * 8)[:8]
255+
except Exception:
256+
continue
257+
lines.append(f"{t_rel_ms},CAN,{can_id}," + ",".join(str(b) for b in padded))
258+
if not lines:
259+
return [], f"No parseable frames in .pecan file: {name}"
260+
out.append((csv_filename, "\n".join(lines).encode("utf-8")))
209261
else:
210-
return [], f"Invalid file type (only .csv and .zip): {name}"
262+
return [], f"Invalid file type (only .csv, .zip, and .pecan): {name}"
211263
if not out:
212264
return [], "No CSV data to process"
213265
return out, None
@@ -501,12 +553,13 @@ def on_progress(sent: int, total: int) -> None:
501553
pass
502554

503555
def worker():
504-
streamer = CANTimescaleStreamer(
505-
postgres_dsn=POSTGRES_DSN,
506-
table=season.lower(),
507-
dbc_path=dbc_temp_path,
508-
)
556+
streamer = None
509557
try:
558+
streamer = CANTimescaleStreamer(
559+
postgres_dsn=POSTGRES_DSN,
560+
table=season.lower(),
561+
dbc_path=dbc_temp_path,
562+
)
510563
asyncio.run(
511564
streamer.stream_multiple_csvs(
512565
file_data=file_data,
@@ -516,14 +569,16 @@ def worker():
516569
)
517570
except Exception as e:
518571
error_logger.error(traceback.format_exc())
519-
PROGRESS[task_id]["msg"] = f"Error: {e}"
520-
PROGRESS[task_id]["done"] = True
572+
PROGRESS[task_id]["msg"] = f"Error: {e}"
573+
PROGRESS[task_id]["error"] = str(e)
574+
PROGRESS[task_id]["done"] = True
521575
slack.fail(str(e))
522576
finally:
523-
try:
524-
streamer.close()
525-
except Exception as e:
526-
print("error closing streamer", e)
577+
if streamer:
578+
try:
579+
streamer.close()
580+
except Exception as e:
581+
print("error closing streamer", e)
527582
if dbc_temp_path and os.path.exists(dbc_temp_path):
528583
try:
529584
os.unlink(dbc_temp_path)

server/installer/file-uploader/static/index.js

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ const DROP_SVG = `<svg id="file-upload-img" aria-hidden="true"
143143
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137
144144
5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
145145
</svg>
146-
<h3>Click to upload CSV or ZIP, or drag and drop</h3>`;
146+
<h3>Click to upload CSV, ZIP, or .pecan, or drag and drop</h3>`;
147147

148148
const SPINNER_HTML = `<svg class="spinner" viewBox="0 0 50 50">
149149
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
@@ -165,6 +165,9 @@ function handleProgress(task_id, fileName, season) {
165165
canSubmit = false;
166166
localStorage.setItem(STORAGE_KEY, task_id);
167167
setDropZoneState("uploading");
168+
const errBox = document.getElementById("upload-error-box");
169+
if (errBox) errBox.style.display = "none";
170+
document.getElementById("progress-bar").style.background = "";
168171

169172
// Show safe-to-close banner immediately
170173
if (fileName || season) showSafeToCloseBanner(fileName, season);
@@ -194,9 +197,24 @@ function handleProgress(task_id, fileName, season) {
194197
localStorage.removeItem(STORAGE_KEY);
195198
hideSafeToCloseBanner();
196199

197-
document.getElementById("progress-bar_pct").innerText = "Done ✓";
198-
document.getElementById("progress-bar_count").innerText =
199-
data.total ? `${data.total.toLocaleString()} rows written` : "Complete";
200+
if (data.error) {
201+
document.getElementById("progress-bar_pct").innerText = "Failed ✗";
202+
document.getElementById("progress-bar").style.background = "#b91c1c";
203+
document.getElementById("progress-bar_count").innerText = "";
204+
const errBox = document.getElementById("upload-error-box") || (() => {
205+
const el = document.createElement("div");
206+
el.id = "upload-error-box";
207+
el.style.cssText = "margin-top:10px;padding:10px 14px;background:#450a0a;border:1px solid #b91c1c;border-radius:6px;color:#fca5a5;font-size:0.85em;white-space:pre-wrap;word-break:break-word;";
208+
document.querySelector(".progress-bar_parent").after(el);
209+
return el;
210+
})();
211+
errBox.innerText = "❌ Upload failed:\n" + data.error;
212+
errBox.style.display = "block";
213+
} else {
214+
document.getElementById("progress-bar_pct").innerText = "Done ✓";
215+
document.getElementById("progress-bar_count").innerText =
216+
data.total ? `${data.total.toLocaleString()} rows written` : "Complete";
217+
}
200218

201219
["drop_zone-input", "season-select", "dbc-select", "dbc-input"].forEach((id) => {
202220
const el = document.getElementById(id);
@@ -264,8 +282,9 @@ function submitCsvUpload(files) {
264282
const n = file.name.toLowerCase();
265283
const okCsv = file.type === "text/csv" || n.endsWith(".csv") || file.type === "application/csv";
266284
const okZip = n.endsWith(".zip") || file.type === "application/zip" || file.type === "application/x-zip-compressed";
267-
if (!okCsv && !okZip) {
268-
alert(`${file.name} must be .csv or .zip`);
285+
const okPecan = n.endsWith(".pecan");
286+
if (!okCsv && !okZip && !okPecan) {
287+
alert(`${file.name} must be .csv, .zip, or .pecan`);
269288
return;
270289
}
271290
}

server/installer/file-uploader/templates/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
<div class="info">
1717
<img src="{{ url_for('static', filename='logo-32_375x.webp' )}}" />
1818
<h1>Upload DAQ Data</h1>
19-
<h2>(multiple CSVs or .zip of CSVs)</h2>
19+
<h2>(CSV, .zip of CSVs, or .pecan)</h2>
2020
<span id="season-select_span">
2121
<label for="season-select">Season:</label>
2222
<select name="season" id="season-select">
@@ -65,11 +65,11 @@ <h2>Task: <span id="task-id-label">{{ task_id }}</span></h2>
6565
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
6666
/>
6767
</svg>
68-
<h3>Click to upload CSV or zip, or drag and drop</h3>
68+
<h3>Click to upload CSV, zip, or .pecan, or drag and drop</h3>
6969
</label>
7070
<input
7171
type="file"
72-
accept=".csv,text/csv,.zip,application/zip,application/x-zip-compressed"
72+
accept=".csv,text/csv,.zip,application/zip,application/x-zip-compressed,.pecan"
7373
id="drop_zone-input"
7474
multiple
7575
style="display: none"

0 commit comments

Comments
 (0)