From adb6d4c2d395af1c7e5ef500384b523c31f18df5 Mon Sep 17 00:00:00 2001 From: kaatr Date: Tue, 10 Jun 2025 16:17:01 +0300 Subject: [PATCH 01/25] Add sai-cli diagnoze (v1) --- python/cli/diagnose/__init__.py | 0 python/cli/diagnose/diagnose.py | 139 ++++++++++++++++ python/cli/diagnose/html.py | 178 ++++++++++++++++++++ python/cli/diagnose/sensors.py | 287 ++++++++++++++++++++++++++++++++ python/cli/sai_cli.py | 2 + 5 files changed, 606 insertions(+) create mode 100644 python/cli/diagnose/__init__.py create mode 100644 python/cli/diagnose/diagnose.py create mode 100644 python/cli/diagnose/html.py create mode 100644 python/cli/diagnose/sensors.py diff --git a/python/cli/diagnose/__init__.py b/python/cli/diagnose/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py new file mode 100644 index 0000000..7fad01c --- /dev/null +++ b/python/cli/diagnose/diagnose.py @@ -0,0 +1,139 @@ +""" +Visualize and diagnose common issues in data in Spectacular AI format +""" + +import json +import pathlib +import sys + +from .html import generateHtml +import sensors + +def define_args(parser): + parser.add_argument("dataset_path", type=pathlib.Path, help="Path to dataset") + parser.add_argument("--output_html", type=pathlib.Path, help="Path to calibration report HTML output.") + parser.add_argument("--output_json", type=pathlib.Path, help="Path to JSON output.") + parser.add_argument("--zero", help="Rescale time to start from zero", action='store_true') + parser.add_argument("--skip", type=float, help="Skip N seconds from the start") + parser.add_argument("--max", type=float, help="Plot max N seconds from the start") + return parser + +def define_subparser(subparsers): + sub = subparsers.add_parser('diagnose', help=__doc__.strip()) + sub.set_defaults(func=generateReport) + return define_args(sub) + +def generateReport(args): + from datetime import datetime + + datasetPath = args.dataset_path + jsonlFile = datasetPath if datasetPath.suffix == ".jsonl" else datasetPath.joinpath("data.jsonl") + if not jsonlFile.is_file(): + raise FileNotFoundError(f"{jsonlFile} does not exist") + + output = { + 'passed': True, + 'date': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + 'dataset_path': str(jsonlFile.parent) + } + + # Plot figures if output isn't specified + if args.output_html or args.output_json: + plotFigures = False + else: + plotFigures = True + + accelerometer = {"x": [], "y": [], "z": [], "t": [], "td": []} + gyroscope = {"x": [], "y": [], "z": [], "t": [], "td": []} + cpu = {"v": [], "t": []} + cameras = {} + + startTime = None + timeOffset = 0 + + with open(jsonlFile) as f: + nSkipped = 0 + for line in f.readlines(): + try: + measurement = json.loads(line) + except: + sys.stderr.write('ignoring non JSON line: %s' % line) + continue + time = measurement.get("time") + sensor = measurement.get("sensor") + frames = measurement.get("frames") + metrics = measurement.get("systemMetrics") + if frames is None and 'frame' in measurement: + frames = [measurement['frame']] + frames[0]['cameraInd'] = 0 + + if time is None: continue + if sensor is None and frames is None and metrics is None: continue + + if startTime is None: + startTime = time + if args.zero: + timeOffset = startTime + + if (args.skip is not None and time - startTime < args.skip) or (args.max is not None and time - startTime > args.max): + nSkipped += 1 + continue + + t = time - timeOffset + if sensor is not None: + measurementType = sensor["type"] + if measurementType == "accelerometer": + for i, c in enumerate('xyz'): accelerometer[c].append(sensor["values"][i]) + if len(accelerometer["t"]) > 0: + diff = t - accelerometer["t"][-1] + accelerometer["td"].append(diff) + accelerometer["t"].append(t) + elif measurementType == "gyroscope": + for i, c in enumerate('xyz'): gyroscope[c].append(sensor["values"][i]) + if len(gyroscope["t"]) > 0: + diff = t - gyroscope["t"][-1] + gyroscope["td"].append(diff) + gyroscope["t"].append(t) + elif frames is not None: + for f in frames: + if f.get("missingBitmap", False): continue + ind = f["cameraInd"] + if cameras.get(ind) is None: + cameras[ind] = {"td": [], "t": [] } + if "features" in f and len(f["features"]) > 0: + cameras[ind]["features"] = [] + else: + diff = t - cameras[ind]["t"][-1] + cameras[ind]["td"].append(diff) + + if "features" in f and len(f["features"]) > 0: + cameras[ind]["features"].append(len(f["features"])) + cameras[ind]["t"].append(t) + elif metrics is not None and 'cpu' in metrics: + cpu["t"].append(t) + cpu["v"].append(metrics['cpu'].get('systemTotalUsagePercent', 0)) + + if nSkipped > 0: + print('skipped %d lines' % nSkipped) + + sensors.camera(cameras, output) + sensors.accelerometer(accelerometer, output) + sensors.gyroscope(gyroscope, output) + sensors.cpu(cpu, output) + + if args.output_json: + with open(args.output_json, "w") as f: + f.write(json.dumps(output, indent=4)) + print("Generated JSON report data at:", args.output_json) + + if args.output_html: + generateHtml(output, args.output_html) + +if __name__ == '__main__': + def parse_args(): + import argparse + parser = argparse.ArgumentParser(description=__doc__.strip()) + parser = define_args(parser) + return parser.parse_args() + + generateReport(parse_args()) \ No newline at end of file diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py new file mode 100644 index 0000000..56916cc --- /dev/null +++ b/python/cli/diagnose/html.py @@ -0,0 +1,178 @@ +HEAD = """ + + + + +Spectacular AI dataset diagnose report + + + +""" + +TAIL = "\n" + +def h1(title): return f"

{title}

\n" +def h2(title): return f"

{title}

\n" +def p(text, size="16px"): return f'

{text}

\n' +def li(text, size="16px"): return f'
  • {text}
  • \n' +def table(pairs): + s = '\n' + for key, value in pairs: + s += '\n' % (key, value) + s += "
    %s%s
    \n" + return s + +def passed(v, large=True): + if v: + classes = 'passed' + text = 'Passed' + else: + classes = 'failed' + text = 'FAILED' + if large: + classes +=" large-text" + tag = 'p' + else: + tag = 'span' + return '<%s class="%s">%s' % (tag, classes, text, tag) + +def status(sensor): + diagnosis = sensor["status"]["diagnosis"] + ok = sensor["status"]["ok"] + badDt = sensor["status"]["bad_delta_time"] + duplicate = sensor["status"]["duplicate_timestamp"] + dataGap = sensor["status"]["data_gap"] + wrongOrder = sensor["status"]["wrong_order"] + total = sensor["count"] + TO_PERCENT = 100.0 / total if total > 0 else 0 + + if diagnosis == "ok": + s = 'OK' + elif diagnosis == "warning": + s = 'Warning' + elif diagnosis == "error": + s = 'Error' + else: + raise ValueError(f"Unknown diagnosis: {diagnosis}") + + description = sensor["status"]["description"] + if len(description) > 0: + s += p('Issues:') + s += "" + + s += p("Sample distribution") + s += "" + + return s + +def generateSensor(sensor, name): + s = "" + s += "
    \n" + s += h2("{} {}".format(name, status(sensor))) + for image in sensor["images"]: + s += f'Plot\n' + s += "
    \n" + return s + +def generateHtml(output, output_html): + s = HEAD + s += h1("Dataset report") + s += '
    \n' + kv_pairs = [ + ('Outcome', passed(output["passed"], large=False)), + ('Date', output['date']), + ('Dataset', output["dataset_path"]) + ] + + for camera in output["cameras"]: + kv_pairs.append(( + 'Camera #{}'.format(camera["ind"]), + '{:.1f}Hz {} frames'.format( + camera["frequency"], + camera["count"]))) + + SENSOR_NAMES = ["accelerometer", "gyroscope"] + for sensor in SENSOR_NAMES: + if sensor not in output: continue + kv_pairs.append(( + sensor.capitalize(), + '{:.1f}Hz {} samples'.format( + output[sensor]["frequency"], + output[sensor]["count"] + ))) + + s += table(kv_pairs) + if not output["passed"]: s += p("One or more checks below failed.") + s += '
    \n' + + for camera in output["cameras"]: + s += generateSensor(camera, 'Camera #{}'.format(camera["ind"])) + + for sensor in SENSOR_NAMES: + if sensor not in output: continue + s += generateSensor(output[sensor], sensor.capitalize()) + + s += TAIL + + with open(output_html, "w") as f: + f.write(s) + print("Generated HTML report at:", output_html) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py new file mode 100644 index 0000000..fbe0938 --- /dev/null +++ b/python/cli/diagnose/sensors.py @@ -0,0 +1,287 @@ +import numpy as np +from enum import Enum + +SECONDS_TO_MILLISECONDS = 1e3 +CAMERA_MIN_FREQUENCY_HZ = 1.0 +IMU_MIN_FREQUENCY_HZ = 50.0 + +DELTA_TIME_PLOT_KWARGS = { + 'plottype': 'scatter', + 'xlabel': "Time (s)", + 'yLabel':"Time diff (ms)", + 's': 1 +} +SIGNAL_PLOT_KWARGS = { + 'xlabel': "Time (s)", + 'style': '.-', + 'linewidth': 0.1, + 'markersize': 1 +} + +class DiagnosisLevel(Enum): + OK = 0 + WARNING = 1 + ERROR = 2 + + def __lt__(self, other): + if not isinstance(other, DiagnosisLevel): + return NotImplemented + return self.value < other.value + + def __eq__(self, other): + if not isinstance(other, DiagnosisLevel): + return NotImplemented + return self.value == other.value + + def toString(self): + return self.name.lower() + +class Status(Enum): + OK = 0 + BAD_DELTA_TIME = 1 + DUPLICATE_TIMESTAMP = 2 + DATA_GAP = 3 + WRONG_ORDER = 4 + LOW_FREQUENCY = 5 + + def diagnosis(self): + if self == Status.OK: + return DiagnosisLevel.OK + elif self == Status.BAD_DELTA_TIME: + return DiagnosisLevel.WARNING + elif self == Status.DUPLICATE_TIMESTAMP: + return DiagnosisLevel.WARNING + elif self == Status.DATA_GAP: + return DiagnosisLevel.ERROR + elif self == Status.WRONG_ORDER: + return DiagnosisLevel.ERROR + elif self == Status.LOW_FREQUENCY: + return DiagnosisLevel.ERROR + else: + raise ValueError(f"Unknown status: {self}") + +def computeStatusForSamples(deltaTimes, minFrequencyHz=None): + WARNING_RELATIVE_DELTA_TIME = 0.1 + ERROR_DELTA_TIME_SECONDS = 0.5 + + medianDeltaTime = np.median(deltaTimes) + thresholdDataGap = ERROR_DELTA_TIME_SECONDS + medianDeltaTime + thresholdDeltaTimeWarning = WARNING_RELATIVE_DELTA_TIME * medianDeltaTime + + status = [] + for td in deltaTimes: + error = abs(td - medianDeltaTime) + if td < 0: + status.append(Status.WRONG_ORDER) + elif td == 0: + status.append(Status.DUPLICATE_TIMESTAMP) + elif error > thresholdDataGap: + status.append(Status.DATA_GAP) + elif error > thresholdDeltaTimeWarning: + status.append(Status.BAD_DELTA_TIME) + else: + status.append(Status.OK) + + def getSummary(status): + ok = np.sum(status == Status.OK) + badDt = np.sum(status == Status.BAD_DELTA_TIME) + duplicate = np.sum(status == Status.DUPLICATE_TIMESTAMP) + dataGap = np.sum(status == Status.DATA_GAP) + wrongOrder = np.sum(status == Status.WRONG_ORDER) + + diagnosis = DiagnosisLevel.OK + description = [] + + if minFrequencyHz is not None: + frequency = 1.0 / medianDeltaTime + if frequency < minFrequencyHz: + description.append(f"Minimum required frequency is {minFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") + diagnosis = max(diagnosis, Status.LOW_FREQUENCY.diagnosis()) + + if dataGap > 0: + description.append(f"Found {dataGap} pauses longer than {thresholdDataGap:.2f}seconds.") + diagnosis = max(diagnosis, Status.DATA_GAP.diagnosis()) + + if wrongOrder > 0: + description.append(f"Found {wrongOrder} timestamps that are in non-chronological order.") + diagnosis = max(diagnosis, Status.WRONG_ORDER.diagnosis()) + + if duplicate > 0: + description.append(f"Found {duplicate} duplicate timestamps.") + MAX_DUPLICATE_TIMESTAMP_RATIO = 0.01 + if MAX_DUPLICATE_TIMESTAMP_RATIO * ok < duplicate: + diagnosis = max(diagnosis, Status.DUPLICATE_TIMESTAMP.diagnosis()) + + if badDt > 0: + description.append( + f"Found {badDt} timestamps that differ from " + f"expected delta time ({medianDeltaTime*SECONDS_TO_MILLISECONDS:.1f}ms) " + f"more than {thresholdDeltaTimeWarning*SECONDS_TO_MILLISECONDS:.1f}ms.") + MAX_BAD_DELTA_TIME_RATIO = 0.05 + if MAX_BAD_DELTA_TIME_RATIO * ok < badDt: + diagnosis = max(diagnosis, Status.BAD_DELTA_TIME.diagnosis()) + + return { + "diagnosis": diagnosis.toString(), + "ok": int(ok), + "bad_delta_time": int(badDt), + "duplicate_timestamp": int(duplicate), + "data_gap": int(dataGap), + "wrong_order": int(wrongOrder), + "description": description + } + + def getDeltaTimePlotColors(status): + colors = np.zeros((len(status), 3)) + for i, s in enumerate(status): + if s.diagnosis() == DiagnosisLevel.OK: + colors[i] = (0, 1, 0) # Green + elif s.diagnosis() == DiagnosisLevel.WARNING: + colors[i] = (1, 0.65, 0) # Orange + elif s.diagnosis() == DiagnosisLevel.ERROR: + colors[i] = (1, 0, 0) # Red + else: + raise ValueError(f"Unknown status: {s}") + return colors + + status = np.array(status) + summary = getSummary(status) + colors = getDeltaTimePlotColors(status) + return summary, colors + +def base64(fig): + import io + import base64 + buf = io.BytesIO() + fig.savefig(buf, format='png') + buf.seek(0) + return base64.b64encode(buf.getvalue()).decode('utf-8') + +def plotFrame( + x, + ys, + title, + style=None, + plottype='plot', + xlabel=None, + yLabel=None, + legend=None, + ymin=None, + ymax=None, + plot=False, + **kwargs): + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(8, 6)) # Fixed image size + + ax.set_title(title) + p = getattr(ax, plottype) + + if ymin is not None and ymax is not None: + ax.set_ylim(ymin, ymax) + + if style is not None: + p(x, ys, style, **kwargs) + else: + p(x, ys, **kwargs) + + ax.margins(x=0) + if xlabel is not None: ax.set_xlabel(xlabel) + if yLabel is not None: ax.set_ylabel(yLabel) + if legend is not None: ax.legend(legend, fontsize='large', markerscale=10) + fig.tight_layout() + if plot: plt.show() + + return base64(fig) + +def camera(data, output): + output["cameras"] = [] + for ind in data.keys(): + camera = data[ind] + if len(camera["t"]) == 0: continue + + status, colors = computeStatusForSamples(data[ind]["td"], CAMERA_MIN_FREQUENCY_HZ) + cameraOutput = { + "status": status, + "ind": ind, + "frequency": 1.0 / np.median(data[ind]["td"]), + "count": len(data[ind]["t"]) + } + + if cameraOutput["status"]["diagnosis"] == DiagnosisLevel.ERROR.toString(): + output["passed"] = False + + cameraOutput["images"] = [ + plotFrame( + camera["t"][1:], + np.array(camera["td"]) * SECONDS_TO_MILLISECONDS, + f"Camera #{ind} frame time diff", + color=colors, + **DELTA_TIME_PLOT_KWARGS) + ] + + if camera.get("features"): + cameraOutput["images"].append(plotFrame( + camera["t"], + camera["features"], + f"Camera #{ind} features", + yLabel="Number of features", + **SIGNAL_PLOT_KWARGS)) + output["cameras"].append(cameraOutput) + +def accelerometer(data, output): + status, colors = computeStatusForSamples(data["td"], IMU_MIN_FREQUENCY_HZ) + output["accelerometer"] = { + "status": status, + "images": [ + plotFrame( + data['t'], + list(zip(data['x'], data['y'], data['z'])), + "Accelerometer signal", + yLabel="Acceleration (m/s²)", + legend=['x', 'y', 'z'], + **SIGNAL_PLOT_KWARGS), + plotFrame( + data["t"][1:], + np.array(data["td"]) * SECONDS_TO_MILLISECONDS, + "Accelerometer time diff", + color=colors, + **DELTA_TIME_PLOT_KWARGS) + ], + "frequency": 1.0 / np.median(data["td"]), + "count": len(data["t"]) + } + if output["accelerometer"]["status"]["diagnosis"] == DiagnosisLevel.ERROR.toString(): + output["passed"] = False + +def gyroscope(data, output): + status, colors = computeStatusForSamples(data["td"], IMU_MIN_FREQUENCY_HZ) + + output["gyroscope"] = { + "status": status, + "images": [ + plotFrame( + data["t"], + list(zip(data['x'], data['y'], data['z'])), + "Gyroscope signal", + yLabel="Gyroscope (rad/s)", + legend=['x', 'y', 'z'], + **SIGNAL_PLOT_KWARGS), + plotFrame( + data["t"][1:], + np.array(data["td"]) * SECONDS_TO_MILLISECONDS, + "Gyroscope time diff (ms)", + color=colors, + **DELTA_TIME_PLOT_KWARGS) + ], + "frequency": 1.0 / np.median(data["td"]), + "count": len(data["t"]) + } + if output["gyroscope"]["status"]["diagnosis"] == DiagnosisLevel.ERROR.toString(): + output["passed"] = False + +def cpu(data, output): + if len(data["t"]) > 0: + output["cpu"] = { + "image": plotFrame(data["t"], data["v"], "CPU system load (%)", ymin=0, ymax=100) + } \ No newline at end of file diff --git a/python/cli/sai_cli.py b/python/cli/sai_cli.py index 2e95f23..776f740 100644 --- a/python/cli/sai_cli.py +++ b/python/cli/sai_cli.py @@ -5,6 +5,7 @@ from .convert.convert import define_subparser as convert_define_subparser from .smooth import define_subparser as smooth_define_subparser from .calibrate.calibrate import define_subparser as calibrate_define_subparser +from .diagnose.diagnose import define_subparser as diagnose_define_subparser def parse_args(): parser = argparse.ArgumentParser(description='Spectacular AI command line tool') @@ -14,6 +15,7 @@ def parse_args(): smooth_define_subparser(subparsers) calibrate_define_subparser(subparsers) convert_define_subparser(subparsers) + diagnose_define_subparser(subparsers) return parser.parse_args() def main(): From ccdf16e27442de64e004d855efb59043cfc389d6 Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 11 Jun 2025 11:44:44 +0300 Subject: [PATCH 02/25] Refactor & check for duplicate signal values --- python/cli/diagnose/html.py | 27 +--- python/cli/diagnose/sensors.py | 285 +++++++++++++++++---------------- 2 files changed, 150 insertions(+), 162 deletions(-) diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py index 56916cc..e9f46c3 100644 --- a/python/cli/diagnose/html.py +++ b/python/cli/diagnose/html.py @@ -83,14 +83,7 @@ def passed(v, large=True): return '<%s class="%s">%s' % (tag, classes, text, tag) def status(sensor): - diagnosis = sensor["status"]["diagnosis"] - ok = sensor["status"]["ok"] - badDt = sensor["status"]["bad_delta_time"] - duplicate = sensor["status"]["duplicate_timestamp"] - dataGap = sensor["status"]["data_gap"] - wrongOrder = sensor["status"]["wrong_order"] - total = sensor["count"] - TO_PERCENT = 100.0 / total if total > 0 else 0 + diagnosis = sensor["diagnosis"] if diagnosis == "ok": s = 'OK' @@ -101,27 +94,13 @@ def status(sensor): else: raise ValueError(f"Unknown diagnosis: {diagnosis}") - description = sensor["status"]["description"] - if len(description) > 0: + if len(sensor["issues"]) > 0: s += p('Issues:') s += "" - s += p("Sample distribution") - s += "" - return s def generateSensor(sensor, name): diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index fbe0938..88e3cf4 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -4,12 +4,12 @@ SECONDS_TO_MILLISECONDS = 1e3 CAMERA_MIN_FREQUENCY_HZ = 1.0 IMU_MIN_FREQUENCY_HZ = 50.0 +TO_PERCENT = 100.0 DELTA_TIME_PLOT_KWARGS = { 'plottype': 'scatter', 'xlabel': "Time (s)", - 'yLabel':"Time diff (ms)", - 's': 1 + 'yLabel':"Time diff (ms)" } SIGNAL_PLOT_KWARGS = { 'xlabel': "Time (s)", @@ -36,118 +36,100 @@ def __eq__(self, other): def toString(self): return self.name.lower() -class Status(Enum): - OK = 0 - BAD_DELTA_TIME = 1 - DUPLICATE_TIMESTAMP = 2 - DATA_GAP = 3 - WRONG_ORDER = 4 - LOW_FREQUENCY = 5 - - def diagnosis(self): - if self == Status.OK: - return DiagnosisLevel.OK - elif self == Status.BAD_DELTA_TIME: - return DiagnosisLevel.WARNING - elif self == Status.DUPLICATE_TIMESTAMP: - return DiagnosisLevel.WARNING - elif self == Status.DATA_GAP: - return DiagnosisLevel.ERROR - elif self == Status.WRONG_ORDER: - return DiagnosisLevel.ERROR - elif self == Status.LOW_FREQUENCY: - return DiagnosisLevel.ERROR - else: - raise ValueError(f"Unknown status: {self}") - -def computeStatusForSamples(deltaTimes, minFrequencyHz=None): - WARNING_RELATIVE_DELTA_TIME = 0.1 - ERROR_DELTA_TIME_SECONDS = 0.5 - - medianDeltaTime = np.median(deltaTimes) - thresholdDataGap = ERROR_DELTA_TIME_SECONDS + medianDeltaTime - thresholdDeltaTimeWarning = WARNING_RELATIVE_DELTA_TIME * medianDeltaTime - - status = [] - for td in deltaTimes: - error = abs(td - medianDeltaTime) - if td < 0: - status.append(Status.WRONG_ORDER) - elif td == 0: - status.append(Status.DUPLICATE_TIMESTAMP) - elif error > thresholdDataGap: - status.append(Status.DATA_GAP) - elif error > thresholdDeltaTimeWarning: - status.append(Status.BAD_DELTA_TIME) - else: - status.append(Status.OK) - - def getSummary(status): - ok = np.sum(status == Status.OK) - badDt = np.sum(status == Status.BAD_DELTA_TIME) - duplicate = np.sum(status == Status.DUPLICATE_TIMESTAMP) - dataGap = np.sum(status == Status.DATA_GAP) - wrongOrder = np.sum(status == Status.WRONG_ORDER) - - diagnosis = DiagnosisLevel.OK - description = [] +class Status: + def __init__(self): + self.diagnosis = DiagnosisLevel.OK # Overall diagnosis of the data + self.issues = [] # Human readable list of issues found during analysis + + def __updateDiagnosis(self, newDiagnosis): + self.diagnosis = max(self.diagnosis, newDiagnosis) + + def analyzeTimestamps(self, deltaTimes, minFrequencyHz=None): + WARNING_RELATIVE_DELTA_TIME = 0.1 + ERROR_DELTA_TIME_SECONDS = 0.5 + COLOR_OK = (0, 1, 0) # Green + COLOR_WARNING = (1, 0.65, 0) # Orange + COLOR_ERROR = (1, 0, 0) # Red + + samplesInWrongOrder = 0 + duplicateTimestamps = 0 + dataGaps = 0 + badDeltaTimes = 0 + total = len(deltaTimes) + + def toPercent(value): + p = (value / total) * TO_PERCENT + return f"{p:.1f}%" + + medianDeltaTime = np.median(deltaTimes) + thresholdDataGap = ERROR_DELTA_TIME_SECONDS + medianDeltaTime + thresholdDeltaTimeWarning = WARNING_RELATIVE_DELTA_TIME * medianDeltaTime + + deltaTimePlotColors = [] + for td in deltaTimes: + error = abs(td - medianDeltaTime) + if td < 0: + samplesInWrongOrder += 1 + deltaTimePlotColors.append(COLOR_ERROR) + elif td == 0: + duplicateTimestamps += 1 + deltaTimePlotColors.append(COLOR_ERROR) + elif error > thresholdDataGap: + dataGaps += 1 + deltaTimePlotColors.append(COLOR_ERROR) + elif error > thresholdDeltaTimeWarning: + badDeltaTimes += 1 + deltaTimePlotColors.append(COLOR_WARNING) + else: + deltaTimePlotColors.append(COLOR_OK) + + if samplesInWrongOrder > 0: + self.issues.append(f"Found {samplesInWrongOrder} ({toPercent(samplesInWrongOrder)}) timestamps that are in non-chronological order.") + self.__updateDiagnosis(DiagnosisLevel.ERROR) + + if duplicateTimestamps > 0: + self.issues.append(f"Found {duplicateTimestamps} ({toPercent(duplicateTimestamps)}) duplicate timestamps.") + self.__updateDiagnosis(DiagnosisLevel.ERROR) + + if dataGaps > 0: + self.issues.append(f"Found {dataGaps} ({toPercent(dataGaps)}) pauses longer than {thresholdDataGap:.2f}seconds.") + self.__updateDiagnosis(DiagnosisLevel.ERROR) + + if badDeltaTimes > 0: + self.issues.append( + f"Found {badDeltaTimes} ({toPercent(badDeltaTimes)}) timestamps that differ from " + f"expected delta time ({medianDeltaTime*SECONDS_TO_MILLISECONDS:.1f}ms) " + f"more than {thresholdDeltaTimeWarning*SECONDS_TO_MILLISECONDS:.1f}ms.") + MAX_BAD_DELTA_TIME_RATIO = 0.01 + if MAX_BAD_DELTA_TIME_RATIO * total < badDeltaTimes: + self.__updateDiagnosis(DiagnosisLevel.WARNING) if minFrequencyHz is not None: frequency = 1.0 / medianDeltaTime if frequency < minFrequencyHz: - description.append(f"Minimum required frequency is {minFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") - diagnosis = max(diagnosis, Status.LOW_FREQUENCY.diagnosis()) - - if dataGap > 0: - description.append(f"Found {dataGap} pauses longer than {thresholdDataGap:.2f}seconds.") - diagnosis = max(diagnosis, Status.DATA_GAP.diagnosis()) - - if wrongOrder > 0: - description.append(f"Found {wrongOrder} timestamps that are in non-chronological order.") - diagnosis = max(diagnosis, Status.WRONG_ORDER.diagnosis()) - - if duplicate > 0: - description.append(f"Found {duplicate} duplicate timestamps.") - MAX_DUPLICATE_TIMESTAMP_RATIO = 0.01 - if MAX_DUPLICATE_TIMESTAMP_RATIO * ok < duplicate: - diagnosis = max(diagnosis, Status.DUPLICATE_TIMESTAMP.diagnosis()) - - if badDt > 0: - description.append( - f"Found {badDt} timestamps that differ from " - f"expected delta time ({medianDeltaTime*SECONDS_TO_MILLISECONDS:.1f}ms) " - f"more than {thresholdDeltaTimeWarning*SECONDS_TO_MILLISECONDS:.1f}ms.") - MAX_BAD_DELTA_TIME_RATIO = 0.05 - if MAX_BAD_DELTA_TIME_RATIO * ok < badDt: - diagnosis = max(diagnosis, Status.BAD_DELTA_TIME.diagnosis()) - - return { - "diagnosis": diagnosis.toString(), - "ok": int(ok), - "bad_delta_time": int(badDt), - "duplicate_timestamp": int(duplicate), - "data_gap": int(dataGap), - "wrong_order": int(wrongOrder), - "description": description - } + self.issues.append(f"Minimum required frequency is {minFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") + self.__updateDiagnosis(DiagnosisLevel.ERROR) - def getDeltaTimePlotColors(status): - colors = np.zeros((len(status), 3)) - for i, s in enumerate(status): - if s.diagnosis() == DiagnosisLevel.OK: - colors[i] = (0, 1, 0) # Green - elif s.diagnosis() == DiagnosisLevel.WARNING: - colors[i] = (1, 0.65, 0) # Orange - elif s.diagnosis() == DiagnosisLevel.ERROR: - colors[i] = (1, 0, 0) # Red - else: - raise ValueError(f"Unknown status: {s}") - return colors + return deltaTimePlotColors + + def analyzeSignal(self, signal, maxDuplicateRatio=0.001): + prev = None + total = np.shape(signal)[0] + + def toPercent(value): + p = (value / total) * TO_PERCENT + return f"{p:.1f}%" - status = np.array(status) - summary = getSummary(status) - colors = getDeltaTimePlotColors(status) - return summary, colors + duplicateSamples = 0 + for v in signal: + if prev is not None and v == prev: + duplicateSamples += 1 + prev = v + + if duplicateSamples > 0: + self.issues.append(f"Found {duplicateSamples} ({toPercent(duplicateSamples)}) duplicate samples in the signal.") + if maxDuplicateRatio * total < duplicateSamples: + self.__updateDiagnosis(DiagnosisLevel.WARNING) def base64(fig): import io @@ -198,31 +180,37 @@ def camera(data, output): output["cameras"] = [] for ind in data.keys(): camera = data[ind] - if len(camera["t"]) == 0: continue + timestamps = np.array(camera["t"]) + deltaTimes = np.array(camera["td"]) + + if len(timestamps) == 0: continue - status, colors = computeStatusForSamples(data[ind]["td"], CAMERA_MIN_FREQUENCY_HZ) + status = Status() + deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, CAMERA_MIN_FREQUENCY_HZ) cameraOutput = { - "status": status, + "diagnosis": status.diagnosis.toString(), + "issues": status.issues, "ind": ind, - "frequency": 1.0 / np.median(data[ind]["td"]), - "count": len(data[ind]["t"]) + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps) } - if cameraOutput["status"]["diagnosis"] == DiagnosisLevel.ERROR.toString(): + if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False cameraOutput["images"] = [ plotFrame( - camera["t"][1:], - np.array(camera["td"]) * SECONDS_TO_MILLISECONDS, + timestamps[1:], + deltaTimes * SECONDS_TO_MILLISECONDS, f"Camera #{ind} frame time diff", - color=colors, + color=deltaTimePlotColors, + s=10, **DELTA_TIME_PLOT_KWARGS) ] if camera.get("features"): cameraOutput["images"].append(plotFrame( - camera["t"], + timestamps, camera["features"], f"Camera #{ind} features", yLabel="Number of features", @@ -230,54 +218,75 @@ def camera(data, output): output["cameras"].append(cameraOutput) def accelerometer(data, output): - status, colors = computeStatusForSamples(data["td"], IMU_MIN_FREQUENCY_HZ) + timestamps = np.array(data["t"]) + deltaTimes = np.array(data["td"]) + signal = list(zip(data['x'], data['y'], data['z'])) + + if len(timestamps) == 0: return + + status = Status() + deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, IMU_MIN_FREQUENCY_HZ) + status.analyzeSignal(signal) + output["accelerometer"] = { - "status": status, + "diagnosis": status.diagnosis.toString(), + "issues": status.issues, "images": [ plotFrame( - data['t'], - list(zip(data['x'], data['y'], data['z'])), + timestamps, + signal, "Accelerometer signal", yLabel="Acceleration (m/s²)", legend=['x', 'y', 'z'], **SIGNAL_PLOT_KWARGS), plotFrame( - data["t"][1:], - np.array(data["td"]) * SECONDS_TO_MILLISECONDS, + timestamps[1:], + deltaTimes * SECONDS_TO_MILLISECONDS, "Accelerometer time diff", - color=colors, + color=deltaTimePlotColors, + s=1, **DELTA_TIME_PLOT_KWARGS) ], - "frequency": 1.0 / np.median(data["td"]), - "count": len(data["t"]) + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps) } - if output["accelerometer"]["status"]["diagnosis"] == DiagnosisLevel.ERROR.toString(): + if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False def gyroscope(data, output): - status, colors = computeStatusForSamples(data["td"], IMU_MIN_FREQUENCY_HZ) + timestamps = np.array(data["t"]) + deltaTimes = np.array(data["td"]) + signal = list(zip(data['x'], data['y'], data['z'])) + + if len(timestamps) == 0: return + + status = Status() + deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, IMU_MIN_FREQUENCY_HZ) + status.analyzeSignal(signal) output["gyroscope"] = { - "status": status, + "diagnosis": status.diagnosis.toString(), + "issues": status.issues, "images": [ plotFrame( - data["t"], - list(zip(data['x'], data['y'], data['z'])), + timestamps, + signal, "Gyroscope signal", yLabel="Gyroscope (rad/s)", legend=['x', 'y', 'z'], **SIGNAL_PLOT_KWARGS), plotFrame( - data["t"][1:], - np.array(data["td"]) * SECONDS_TO_MILLISECONDS, + timestamps[1:], + deltaTimes * SECONDS_TO_MILLISECONDS, "Gyroscope time diff (ms)", - color=colors, + color=deltaTimePlotColors, + s=1, **DELTA_TIME_PLOT_KWARGS) ], - "frequency": 1.0 / np.median(data["td"]), - "count": len(data["t"]) + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps) } - if output["gyroscope"]["status"]["diagnosis"] == DiagnosisLevel.ERROR.toString(): + if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False def cpu(data, output): From 9437cd8f9fb2fe30c741f5f8c2d4e3934fdd533c Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 11 Jun 2025 12:20:13 +0300 Subject: [PATCH 03/25] Support magnetometer --- python/cli/diagnose/diagnose.py | 12 +++++++++- python/cli/diagnose/html.py | 2 +- python/cli/diagnose/sensors.py | 40 +++++++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index 7fad01c..e835e27 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -6,7 +6,7 @@ import pathlib import sys -from .html import generateHtml +from html1 import generateHtml import sensors def define_args(parser): @@ -43,8 +43,11 @@ def generateReport(args): else: plotFigures = True + SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer"] + accelerometer = {"x": [], "y": [], "z": [], "t": [], "td": []} gyroscope = {"x": [], "y": [], "z": [], "t": [], "td": []} + magnetometer = {"x": [], "y": [], "z": [], "t": [], "td": []} cpu = {"v": [], "t": []} cameras = {} @@ -94,6 +97,12 @@ def generateReport(args): diff = t - gyroscope["t"][-1] gyroscope["td"].append(diff) gyroscope["t"].append(t) + elif measurementType == "magnetometer": + for i, c in enumerate('xyz'): magnetometer[c].append(sensor["values"][i]) + if len(magnetometer["t"]) > 0: + diff = t - magnetometer["t"][-1] + magnetometer["td"].append(diff) + magnetometer["t"].append(t) elif frames is not None: for f in frames: if f.get("missingBitmap", False): continue @@ -119,6 +128,7 @@ def generateReport(args): sensors.camera(cameras, output) sensors.accelerometer(accelerometer, output) sensors.gyroscope(gyroscope, output) + sensors.magnetometer(magnetometer, output) sensors.cpu(cpu, output) if args.output_json: diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py index e9f46c3..1109419 100644 --- a/python/cli/diagnose/html.py +++ b/python/cli/diagnose/html.py @@ -129,7 +129,7 @@ def generateHtml(output, output_html): camera["frequency"], camera["count"]))) - SENSOR_NAMES = ["accelerometer", "gyroscope"] + SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer"] for sensor in SENSOR_NAMES: if sensor not in output: continue kv_pairs.append(( diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 88e3cf4..a77b2f3 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -112,7 +112,7 @@ def toPercent(value): return deltaTimePlotColors - def analyzeSignal(self, signal, maxDuplicateRatio=0.001): + def analyzeSignal(self, signal, maxDuplicateRatio=0.01): prev = None total = np.shape(signal)[0] @@ -272,7 +272,7 @@ def gyroscope(data, output): timestamps, signal, "Gyroscope signal", - yLabel="Gyroscope (rad/s)", + yLabel="rad/s", legend=['x', 'y', 'z'], **SIGNAL_PLOT_KWARGS), plotFrame( @@ -289,6 +289,42 @@ def gyroscope(data, output): if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False +def magnetometer(data, output): + timestamps = np.array(data["t"]) + deltaTimes = np.array(data["td"]) + signal = list(zip(data['x'], data['y'], data['z'])) + + if len(timestamps) == 0: return + + status = Status() + deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, IMU_MIN_FREQUENCY_HZ) + status.analyzeSignal(signal) + + output["magnetometer"] = { + "diagnosis": status.diagnosis.toString(), + "issues": status.issues, + "images": [ + plotFrame( + timestamps, + signal, + "Magnetometer signal", + yLabel="Microteslas (μT)", + legend=['x', 'y', 'z'], + **SIGNAL_PLOT_KWARGS), + plotFrame( + timestamps[1:], + deltaTimes * SECONDS_TO_MILLISECONDS, + "Magnetometer time diff (ms)", + color=deltaTimePlotColors, + s=1, + **DELTA_TIME_PLOT_KWARGS) + ], + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps) + } + if status.diagnosis == DiagnosisLevel.ERROR: + output["passed"] = False + def cpu(data, output): if len(data["t"]) > 0: output["cpu"] = { From be21f00ed7d8ef9c767953ac47aceb46df74851e Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 11 Jun 2025 12:50:32 +0300 Subject: [PATCH 04/25] Support barometer --- python/cli/diagnose/diagnose.py | 17 +++++++++++--- python/cli/diagnose/html.py | 2 +- python/cli/diagnose/sensors.py | 39 ++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index e835e27..1513a3e 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -43,11 +43,10 @@ def generateReport(args): else: plotFigures = True - SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer"] - accelerometer = {"x": [], "y": [], "z": [], "t": [], "td": []} gyroscope = {"x": [], "y": [], "z": [], "t": [], "td": []} magnetometer = {"x": [], "y": [], "z": [], "t": [], "td": []} + barometer = {"v": [], "t": [], "td": []} cpu = {"v": [], "t": []} cameras = {} @@ -64,6 +63,7 @@ def generateReport(args): continue time = measurement.get("time") sensor = measurement.get("sensor") + barometerMeasurement = measurement.get("barometer") frames = measurement.get("frames") metrics = measurement.get("systemMetrics") if frames is None and 'frame' in measurement: @@ -71,7 +71,11 @@ def generateReport(args): frames[0]['cameraInd'] = 0 if time is None: continue - if sensor is None and frames is None and metrics is None: continue + + if (sensor is None + and frames is None + and metrics is None + and barometerMeasurement is None): continue if startTime is None: startTime = time @@ -103,6 +107,12 @@ def generateReport(args): diff = t - magnetometer["t"][-1] magnetometer["td"].append(diff) magnetometer["t"].append(t) + elif barometerMeasurement is not None: + barometer["v"].append(barometerMeasurement.get("pressureHectopascals", 0)) + if len(barometer["t"]) > 0: + diff = t - barometer["t"][-1] + barometer["td"].append(diff) + barometer["t"].append(t) elif frames is not None: for f in frames: if f.get("missingBitmap", False): continue @@ -129,6 +139,7 @@ def generateReport(args): sensors.accelerometer(accelerometer, output) sensors.gyroscope(gyroscope, output) sensors.magnetometer(magnetometer, output) + sensors.barometer(barometer, output) sensors.cpu(cpu, output) if args.output_json: diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py index 1109419..a3058ce 100644 --- a/python/cli/diagnose/html.py +++ b/python/cli/diagnose/html.py @@ -129,7 +129,7 @@ def generateHtml(output, output_html): camera["frequency"], camera["count"]))) - SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer"] + SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer", "barometer"] for sensor in SENSOR_NAMES: if sensor not in output: continue kv_pairs.append(( diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index a77b2f3..ce4b224 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -4,6 +4,8 @@ SECONDS_TO_MILLISECONDS = 1e3 CAMERA_MIN_FREQUENCY_HZ = 1.0 IMU_MIN_FREQUENCY_HZ = 50.0 +MAGNETOMETER_MIN_FREQUENCY_HZ = 1.0 +BAROMETER_MIN_FREQUENCY_HZ = 1.0 TO_PERCENT = 100.0 DELTA_TIME_PLOT_KWARGS = { @@ -297,7 +299,7 @@ def magnetometer(data, output): if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, IMU_MIN_FREQUENCY_HZ) + deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, MAGNETOMETER_MIN_FREQUENCY_HZ) status.analyzeSignal(signal) output["magnetometer"] = { @@ -325,6 +327,41 @@ def magnetometer(data, output): if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False +def barometer(data, output): + timestamps = np.array(data["t"]) + deltaTimes = np.array(data["td"]) + signal = data['v'] + + if len(timestamps) == 0: return + + status = Status() + deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, BAROMETER_MIN_FREQUENCY_HZ) + status.analyzeSignal(signal) + + output["barometer"] = { + "diagnosis": status.diagnosis.toString(), + "issues": status.issues, + "images": [ + plotFrame( + timestamps, + signal, + "Barometer signal", + yLabel="Pressure (hPa)", + **SIGNAL_PLOT_KWARGS), + plotFrame( + timestamps[1:], + deltaTimes * SECONDS_TO_MILLISECONDS, + "Barometer time diff (ms)", + color=deltaTimePlotColors, + s=1, + **DELTA_TIME_PLOT_KWARGS) + ], + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps) + } + if status.diagnosis == DiagnosisLevel.ERROR: + output["passed"] = False + def cpu(data, output): if len(data["t"]) > 0: output["cpu"] = { From 40b4985153b3d283f060621ca57b4ea5ddf9a3c6 Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 11 Jun 2025 13:58:11 +0300 Subject: [PATCH 05/25] Clean up --- python/cli/diagnose/diagnose.py | 67 +++++++++++++++------------------ python/cli/diagnose/sensors.py | 27 +++++++++---- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index 1513a3e..ec53075 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -43,12 +43,23 @@ def generateReport(args): else: plotFigures = True - accelerometer = {"x": [], "y": [], "z": [], "t": [], "td": []} - gyroscope = {"x": [], "y": [], "z": [], "t": [], "td": []} - magnetometer = {"x": [], "y": [], "z": [], "t": [], "td": []} - barometer = {"v": [], "t": [], "td": []} - cpu = {"v": [], "t": []} - cameras = {} + data = { + 'accelerometer': {"v": [], "t": [], "td": []}, + 'gyroscope': {"v": [], "t": [], "td": []}, + 'magnetometer': {"v": [], "t": [], "td": []}, + 'barometer': {"v": [], "t": [], "td": []}, + 'cpu': {"v": [], "t": []}, + 'cameras': {} + } + + def addMeasurement(type, t, v): + assert type in data, f"Unknown sensor type: {type}" + sensorData = data[type] + sensorData['v'].append(v) + if len(sensorData["t"]) > 0: + diff = t - sensorData["t"][-1] + sensorData["td"].append(diff) + sensorData["t"].append(t) startTime = None timeOffset = 0 @@ -89,33 +100,15 @@ def generateReport(args): t = time - timeOffset if sensor is not None: measurementType = sensor["type"] - if measurementType == "accelerometer": - for i, c in enumerate('xyz'): accelerometer[c].append(sensor["values"][i]) - if len(accelerometer["t"]) > 0: - diff = t - accelerometer["t"][-1] - accelerometer["td"].append(diff) - accelerometer["t"].append(t) - elif measurementType == "gyroscope": - for i, c in enumerate('xyz'): gyroscope[c].append(sensor["values"][i]) - if len(gyroscope["t"]) > 0: - diff = t - gyroscope["t"][-1] - gyroscope["td"].append(diff) - gyroscope["t"].append(t) - elif measurementType == "magnetometer": - for i, c in enumerate('xyz'): magnetometer[c].append(sensor["values"][i]) - if len(magnetometer["t"]) > 0: - diff = t - magnetometer["t"][-1] - magnetometer["td"].append(diff) - magnetometer["t"].append(t) + if measurementType in ["accelerometer", "gyroscope", "magnetometer"]: + v = [sensor["values"][i] for i in range(3)] + addMeasurement(measurementType, t, v) elif barometerMeasurement is not None: - barometer["v"].append(barometerMeasurement.get("pressureHectopascals", 0)) - if len(barometer["t"]) > 0: - diff = t - barometer["t"][-1] - barometer["td"].append(diff) - barometer["t"].append(t) + addMeasurement("barometer", t, barometerMeasurement["pressureHectopascals"]) elif frames is not None: for f in frames: if f.get("missingBitmap", False): continue + cameras = data['cameras'] ind = f["cameraInd"] if cameras.get(ind) is None: cameras[ind] = {"td": [], "t": [] } @@ -129,18 +122,18 @@ def generateReport(args): cameras[ind]["features"].append(len(f["features"])) cameras[ind]["t"].append(t) elif metrics is not None and 'cpu' in metrics: - cpu["t"].append(t) - cpu["v"].append(metrics['cpu'].get('systemTotalUsagePercent', 0)) + data["cpu"]["t"].append(t) + data["cpu"]["v"].append(metrics['cpu'].get('systemTotalUsagePercent', 0)) if nSkipped > 0: print('skipped %d lines' % nSkipped) - sensors.camera(cameras, output) - sensors.accelerometer(accelerometer, output) - sensors.gyroscope(gyroscope, output) - sensors.magnetometer(magnetometer, output) - sensors.barometer(barometer, output) - sensors.cpu(cpu, output) + sensors.camera(data, output) + sensors.accelerometer(data, output) + sensors.gyroscope(data, output) + sensors.magnetometer(data, output) + sensors.barometer(data, output) + sensors.cpu(data, output) if args.output_json: with open(args.output_json, "w") as f: diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index ce4b224..86c825c 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -179,7 +179,9 @@ def plotFrame( return base64(fig) def camera(data, output): + data = data["cameras"] output["cameras"] = [] + for ind in data.keys(): camera = data[ind] timestamps = np.array(camera["t"]) @@ -220,9 +222,10 @@ def camera(data, output): output["cameras"].append(cameraOutput) def accelerometer(data, output): + data = data["accelerometer"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) - signal = list(zip(data['x'], data['y'], data['z'])) + signal = data['v'] if len(timestamps) == 0: return @@ -256,9 +259,10 @@ def accelerometer(data, output): output["passed"] = False def gyroscope(data, output): + data = data["gyroscope"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) - signal = list(zip(data['x'], data['y'], data['z'])) + signal = data['v'] if len(timestamps) == 0: return @@ -274,7 +278,7 @@ def gyroscope(data, output): timestamps, signal, "Gyroscope signal", - yLabel="rad/s", + yLabel="Angular velocity (rad/s)", legend=['x', 'y', 'z'], **SIGNAL_PLOT_KWARGS), plotFrame( @@ -292,9 +296,10 @@ def gyroscope(data, output): output["passed"] = False def magnetometer(data, output): + data = data["magnetometer"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) - signal = list(zip(data['x'], data['y'], data['z'])) + signal = data['v'] if len(timestamps) == 0: return @@ -328,6 +333,7 @@ def magnetometer(data, output): output["passed"] = False def barometer(data, output): + data = data["barometer"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) signal = data['v'] @@ -363,7 +369,12 @@ def barometer(data, output): output["passed"] = False def cpu(data, output): - if len(data["t"]) > 0: - output["cpu"] = { - "image": plotFrame(data["t"], data["v"], "CPU system load (%)", ymin=0, ymax=100) - } \ No newline at end of file + data = data["cpu"] + timestamps = np.array(data["t"]) + values = data["v"] + + if len(timestamps) == 0: return + + output["cpu"] = { + "image": plotFrame(timestamps, values, "CPU system load (%)", ymin=0, ymax=100) + } \ No newline at end of file From 03742803af9c9fd95e48bf96c160978461683976 Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 11 Jun 2025 14:09:00 +0300 Subject: [PATCH 06/25] Add sanity check for maximum frequency --- python/cli/diagnose/sensors.py | 47 +++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 86c825c..7712653 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -2,11 +2,16 @@ from enum import Enum SECONDS_TO_MILLISECONDS = 1e3 +TO_PERCENT = 100.0 + CAMERA_MIN_FREQUENCY_HZ = 1.0 +CAMERA_MAX_FREQUENCY_HZ = 100.0 IMU_MIN_FREQUENCY_HZ = 50.0 +IMU_MAX_FREQUENCY_HZ = 1e4 MAGNETOMETER_MIN_FREQUENCY_HZ = 1.0 +MAGNETOMETER_MAX_FREQUENCY_HZ = 1e3 BAROMETER_MIN_FREQUENCY_HZ = 1.0 -TO_PERCENT = 100.0 +BAROMETER_MAX_FREQUENCY_HZ = 1e3 DELTA_TIME_PLOT_KWARGS = { 'plottype': 'scatter', @@ -46,7 +51,7 @@ def __init__(self): def __updateDiagnosis(self, newDiagnosis): self.diagnosis = max(self.diagnosis, newDiagnosis) - def analyzeTimestamps(self, deltaTimes, minFrequencyHz=None): + def analyzeTimestamps(self, deltaTimes, minFrequencyHz, maxFrequencyHz): WARNING_RELATIVE_DELTA_TIME = 0.1 ERROR_DELTA_TIME_SECONDS = 0.5 COLOR_OK = (0, 1, 0) # Green @@ -106,11 +111,14 @@ def toPercent(value): if MAX_BAD_DELTA_TIME_RATIO * total < badDeltaTimes: self.__updateDiagnosis(DiagnosisLevel.WARNING) - if minFrequencyHz is not None: - frequency = 1.0 / medianDeltaTime - if frequency < minFrequencyHz: - self.issues.append(f"Minimum required frequency is {minFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") - self.__updateDiagnosis(DiagnosisLevel.ERROR) + frequency = 1.0 / medianDeltaTime + if frequency < minFrequencyHz: + self.issues.append(f"Minimum required frequency is {minFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") + self.__updateDiagnosis(DiagnosisLevel.ERROR) + + if frequency > maxFrequencyHz: + self.issues.append(f"Maximum allowed frequency is {maxFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") + self.__updateDiagnosis(DiagnosisLevel.ERROR) return deltaTimePlotColors @@ -190,7 +198,10 @@ def camera(data, output): if len(timestamps) == 0: continue status = Status() - deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, CAMERA_MIN_FREQUENCY_HZ) + deltaTimePlotColors = status.analyzeTimestamps( + deltaTimes, + CAMERA_MIN_FREQUENCY_HZ, + CAMERA_MAX_FREQUENCY_HZ) cameraOutput = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, @@ -230,7 +241,10 @@ def accelerometer(data, output): if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, IMU_MIN_FREQUENCY_HZ) + deltaTimePlotColors = status.analyzeTimestamps( + deltaTimes, + IMU_MIN_FREQUENCY_HZ, + IMU_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) output["accelerometer"] = { @@ -267,7 +281,10 @@ def gyroscope(data, output): if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, IMU_MIN_FREQUENCY_HZ) + deltaTimePlotColors = status.analyzeTimestamps( + deltaTimes, + IMU_MIN_FREQUENCY_HZ, + IMU_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) output["gyroscope"] = { @@ -304,7 +321,10 @@ def magnetometer(data, output): if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, MAGNETOMETER_MIN_FREQUENCY_HZ) + deltaTimePlotColors = status.analyzeTimestamps( + deltaTimes, + MAGNETOMETER_MIN_FREQUENCY_HZ, + MAGNETOMETER_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) output["magnetometer"] = { @@ -341,7 +361,10 @@ def barometer(data, output): if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps(deltaTimes, BAROMETER_MIN_FREQUENCY_HZ) + deltaTimePlotColors = status.analyzeTimestamps( + deltaTimes, + BAROMETER_MIN_FREQUENCY_HZ, + BAROMETER_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) output["barometer"] = { From ce7bd0a78a6cb8c03f095ed49e5a5e6e59bd7142 Mon Sep 17 00:00:00 2001 From: Pekka Rantalankila Date: Wed, 11 Jun 2025 14:38:42 +0300 Subject: [PATCH 07/25] Add script to run sai-cli without installation (#67) --- python/cli/calibrate/calibrate.py | 9 ++++++--- python/cli/diagnose/diagnose.py | 18 +++++++++--------- python/cli/diagnose/sensors.py | 14 +++++++------- python/run_sai_cli.py | 4 ++++ 4 files changed, 26 insertions(+), 19 deletions(-) create mode 100644 python/run_sai_cli.py diff --git a/python/cli/calibrate/calibrate.py b/python/cli/calibrate/calibrate.py index 3f555b9..8661005 100644 --- a/python/cli/calibrate/calibrate.py +++ b/python/cli/calibrate/calibrate.py @@ -14,6 +14,9 @@ def define_subparser(subparsers): sub = subparsers.add_parser('calibrate', help=__doc__.strip()) sub.set_defaults(func=call_calibrate) from spectacularAI.calibration import define_args as define_args_calibration - from .report import define_args as define_args_report - define_args_calibration(sub) - define_args_report(sub) + try: + from .report import define_args as define_args_report + define_args_calibration(sub) + define_args_report(sub) + except: + pass diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index ec53075..1c48282 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -6,8 +6,8 @@ import pathlib import sys -from html1 import generateHtml -import sensors +from .html import generateHtml +from .sensors import * def define_args(parser): parser.add_argument("dataset_path", type=pathlib.Path, help="Path to dataset") @@ -128,12 +128,12 @@ def addMeasurement(type, t, v): if nSkipped > 0: print('skipped %d lines' % nSkipped) - sensors.camera(data, output) - sensors.accelerometer(data, output) - sensors.gyroscope(data, output) - sensors.magnetometer(data, output) - sensors.barometer(data, output) - sensors.cpu(data, output) + diagnoseCamera(data, output) + diagnoseAccelerometer(data, output) + diagnoseGyroscope(data, output) + diagnoseMagnetometer(data, output) + diagnoseBarometer(data, output) + diagnoseCpu(data, output) if args.output_json: with open(args.output_json, "w") as f: @@ -150,4 +150,4 @@ def parse_args(): parser = define_args(parser) return parser.parse_args() - generateReport(parse_args()) \ No newline at end of file + generateReport(parse_args()) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 7712653..6cb6155 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -186,7 +186,7 @@ def plotFrame( return base64(fig) -def camera(data, output): +def diagnoseCamera(data, output): data = data["cameras"] output["cameras"] = [] @@ -232,7 +232,7 @@ def camera(data, output): **SIGNAL_PLOT_KWARGS)) output["cameras"].append(cameraOutput) -def accelerometer(data, output): +def diagnoseAccelerometer(data, output): data = data["accelerometer"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) @@ -272,7 +272,7 @@ def accelerometer(data, output): if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False -def gyroscope(data, output): +def diagnoseGyroscope(data, output): data = data["gyroscope"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) @@ -312,7 +312,7 @@ def gyroscope(data, output): if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False -def magnetometer(data, output): +def diagnoseMagnetometer(data, output): data = data["magnetometer"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) @@ -352,7 +352,7 @@ def magnetometer(data, output): if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False -def barometer(data, output): +def diagnoseBarometer(data, output): data = data["barometer"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) @@ -391,7 +391,7 @@ def barometer(data, output): if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False -def cpu(data, output): +def diagnoseCpu(data, output): data = data["cpu"] timestamps = np.array(data["t"]) values = data["v"] @@ -400,4 +400,4 @@ def cpu(data, output): output["cpu"] = { "image": plotFrame(timestamps, values, "CPU system load (%)", ymin=0, ymax=100) - } \ No newline at end of file + } diff --git a/python/run_sai_cli.py b/python/run_sai_cli.py new file mode 100644 index 0000000..1ce202f --- /dev/null +++ b/python/run_sai_cli.py @@ -0,0 +1,4 @@ +from cli.sai_cli import main + +if __name__ == '__main__': + main() From 2274b99323d98bd648d2ebdb73c969daca3672e2 Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 11 Jun 2025 15:06:41 +0300 Subject: [PATCH 08/25] Fix merge conflict --- python/cli/diagnose/html.py | 28 +++++++++++++------ python/cli/diagnose/sensors.py | 49 ++++++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py index a3058ce..263f99c 100644 --- a/python/cli/diagnose/html.py +++ b/python/cli/diagnose/html.py @@ -122,18 +122,22 @@ def generateHtml(output, output_html): ('Dataset', output["dataset_path"]) ] - for camera in output["cameras"]: - kv_pairs.append(( - 'Camera #{}'.format(camera["ind"]), - '{:.1f}Hz {} frames'.format( - camera["frequency"], - camera["count"]))) + if len(output["cameras"]) == 0: + kv_pairs.append(('Cameras', 'No data')) + else: + for camera in output["cameras"]: + kv_pairs.append(( + 'Camera #{}'.format(camera["ind"]), + '{:.1f}Hz {} frames'.format( + camera["frequency"], + camera["count"]))) SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer", "barometer"] for sensor in SENSOR_NAMES: if sensor not in output: continue kv_pairs.append(( sensor.capitalize(), + 'No data' if output[sensor]["count"] == 0 else '{:.1f}Hz {} samples'.format( output[sensor]["frequency"], output[sensor]["count"] @@ -143,8 +147,16 @@ def generateHtml(output, output_html): if not output["passed"]: s += p("One or more checks below failed.") s += '\n' - for camera in output["cameras"]: - s += generateSensor(camera, 'Camera #{}'.format(camera["ind"])) + if len(output["cameras"]) == 0: + camera = { + "diagnosis": "error", + "issues": ["Missing camera(s)."], + "images": [] + } + s += generateSensor(camera, "Camera") + else: + for camera in output["cameras"]: + s += generateSensor(camera, 'Camera #{}'.format(camera["ind"])) for sensor in SENSOR_NAMES: if sensor not in output: continue diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 6cb6155..f6c560b 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -232,13 +232,25 @@ def diagnoseCamera(data, output): **SIGNAL_PLOT_KWARGS)) output["cameras"].append(cameraOutput) + if len(output["cameras"]) == 0: + # Camera is required + output["passed"] = False + def diagnoseAccelerometer(data, output): data = data["accelerometer"] timestamps = np.array(data["t"]) deltaTimes = np.array(data["td"]) signal = data['v'] - if len(timestamps) == 0: return + if len(timestamps) == 0: + # Accelerometer is required + output["accelerometer"] = { + "diagnosis": DiagnosisLevel.ERROR.toString(), + "issues": ["Missing accelerometer data."], + "count": 0, + "images": [] + } + return status = Status() deltaTimePlotColors = status.analyzeTimestamps( @@ -250,6 +262,8 @@ def diagnoseAccelerometer(data, output): output["accelerometer"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps), "images": [ plotFrame( timestamps, @@ -265,9 +279,7 @@ def diagnoseAccelerometer(data, output): color=deltaTimePlotColors, s=1, **DELTA_TIME_PLOT_KWARGS) - ], - "frequency": 1.0 / np.median(deltaTimes), - "count": len(timestamps) + ] } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False @@ -278,7 +290,16 @@ def diagnoseGyroscope(data, output): deltaTimes = np.array(data["td"]) signal = data['v'] - if len(timestamps) == 0: return + if len(timestamps) == 0: + # Gyroscope is required + output["gyroscope"] = { + "diagnosis": DiagnosisLevel.ERROR.toString(), + "issues": ["Missing gyroscope data."], + "count": 0, + "images": [] + } + output["passed"] = False + return status = Status() deltaTimePlotColors = status.analyzeTimestamps( @@ -290,6 +311,8 @@ def diagnoseGyroscope(data, output): output["gyroscope"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps), "images": [ plotFrame( timestamps, @@ -305,9 +328,7 @@ def diagnoseGyroscope(data, output): color=deltaTimePlotColors, s=1, **DELTA_TIME_PLOT_KWARGS) - ], - "frequency": 1.0 / np.median(deltaTimes), - "count": len(timestamps) + ] } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False @@ -330,6 +351,8 @@ def diagnoseMagnetometer(data, output): output["magnetometer"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps), "images": [ plotFrame( timestamps, @@ -345,9 +368,7 @@ def diagnoseMagnetometer(data, output): color=deltaTimePlotColors, s=1, **DELTA_TIME_PLOT_KWARGS) - ], - "frequency": 1.0 / np.median(deltaTimes), - "count": len(timestamps) + ] } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False @@ -370,6 +391,8 @@ def diagnoseBarometer(data, output): output["barometer"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps), "images": [ plotFrame( timestamps, @@ -384,9 +407,7 @@ def diagnoseBarometer(data, output): color=deltaTimePlotColors, s=1, **DELTA_TIME_PLOT_KWARGS) - ], - "frequency": 1.0 / np.median(deltaTimes), - "count": len(timestamps) + ] } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False From 5d2978c6cd058cb59ca415a809f87adfa76b9ebc Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 11 Jun 2025 16:29:31 +0300 Subject: [PATCH 09/25] Check timestamps overlap with IMU --- python/cli/diagnose/sensors.py | 81 ++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index f6c560b..6ea75a4 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -51,12 +51,15 @@ def __init__(self): def __updateDiagnosis(self, newDiagnosis): self.diagnosis = max(self.diagnosis, newDiagnosis) - def analyzeTimestamps(self, deltaTimes, minFrequencyHz, maxFrequencyHz): + def analyzeTimestamps( + self, + timestamps, + deltaTimes, + imuTimestamps, + minFrequencyHz, + maxFrequencyHz): WARNING_RELATIVE_DELTA_TIME = 0.1 ERROR_DELTA_TIME_SECONDS = 0.5 - COLOR_OK = (0, 1, 0) # Green - COLOR_WARNING = (1, 0.65, 0) # Orange - COLOR_ERROR = (1, 0, 0) # Red samplesInWrongOrder = 0 duplicateTimestamps = 0 @@ -72,6 +75,9 @@ def toPercent(value): thresholdDataGap = ERROR_DELTA_TIME_SECONDS + medianDeltaTime thresholdDeltaTimeWarning = WARNING_RELATIVE_DELTA_TIME * medianDeltaTime + COLOR_OK = (0, 1, 0) # Green + COLOR_WARNING = (1, 0.65, 0) # Orange + COLOR_ERROR = (1, 0, 0) # Red deltaTimePlotColors = [] for td in deltaTimes: error = abs(td - medianDeltaTime) @@ -120,6 +126,22 @@ def toPercent(value): self.issues.append(f"Maximum allowed frequency is {maxFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") self.__updateDiagnosis(DiagnosisLevel.ERROR) + # Check that timestamps overlap with IMU timestamps + if len(imuTimestamps) > 0: + t0 = np.min(imuTimestamps) + t1 = np.max(imuTimestamps) + + invalidTimestamps = 0 + for ts in timestamps: + if ts < t0 or ts > t1: + invalidTimestamps += 1 + + MIN_OVERLAP = 0.99 + if MIN_OVERLAP * total < invalidTimestamps: + self.issues.append(f"Found {invalidTimestamps} ({toPercent(invalidTimestamps)}) " + "timestamps that don't overlap with IMU") + self.__updateDiagnosis(DiagnosisLevel.WARNING) + return deltaTimePlotColors def analyzeSignal(self, signal, maxDuplicateRatio=0.01): @@ -186,12 +208,15 @@ def plotFrame( return base64(fig) +def getImuTimestamps(data): + return data["accelerometer"]["t"] + def diagnoseCamera(data, output): - data = data["cameras"] + sensor = data["cameras"] output["cameras"] = [] - for ind in data.keys(): - camera = data[ind] + for ind in sensor.keys(): + camera = sensor[ind] timestamps = np.array(camera["t"]) deltaTimes = np.array(camera["td"]) @@ -199,7 +224,9 @@ def diagnoseCamera(data, output): status = Status() deltaTimePlotColors = status.analyzeTimestamps( + timestamps, deltaTimes, + getImuTimestamps(data), CAMERA_MIN_FREQUENCY_HZ, CAMERA_MAX_FREQUENCY_HZ) cameraOutput = { @@ -237,10 +264,10 @@ def diagnoseCamera(data, output): output["passed"] = False def diagnoseAccelerometer(data, output): - data = data["accelerometer"] - timestamps = np.array(data["t"]) - deltaTimes = np.array(data["td"]) - signal = data['v'] + sensor = data["accelerometer"] + timestamps = np.array(sensor["t"]) + deltaTimes = np.array(sensor["td"]) + signal = sensor['v'] if len(timestamps) == 0: # Accelerometer is required @@ -254,7 +281,9 @@ def diagnoseAccelerometer(data, output): status = Status() deltaTimePlotColors = status.analyzeTimestamps( + timestamps, deltaTimes, + getImuTimestamps(data), IMU_MIN_FREQUENCY_HZ, IMU_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) @@ -285,10 +314,10 @@ def diagnoseAccelerometer(data, output): output["passed"] = False def diagnoseGyroscope(data, output): - data = data["gyroscope"] - timestamps = np.array(data["t"]) - deltaTimes = np.array(data["td"]) - signal = data['v'] + sensor = data["gyroscope"] + timestamps = np.array(sensor["t"]) + deltaTimes = np.array(sensor["td"]) + signal = sensor['v'] if len(timestamps) == 0: # Gyroscope is required @@ -303,7 +332,9 @@ def diagnoseGyroscope(data, output): status = Status() deltaTimePlotColors = status.analyzeTimestamps( + timestamps, deltaTimes, + getImuTimestamps(data), IMU_MIN_FREQUENCY_HZ, IMU_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) @@ -334,16 +365,18 @@ def diagnoseGyroscope(data, output): output["passed"] = False def diagnoseMagnetometer(data, output): - data = data["magnetometer"] - timestamps = np.array(data["t"]) - deltaTimes = np.array(data["td"]) - signal = data['v'] + sensor = data["magnetometer"] + timestamps = np.array(sensor["t"]) + deltaTimes = np.array(sensor["td"]) + signal = sensor['v'] if len(timestamps) == 0: return status = Status() deltaTimePlotColors = status.analyzeTimestamps( + timestamps, deltaTimes, + getImuTimestamps(data), MAGNETOMETER_MIN_FREQUENCY_HZ, MAGNETOMETER_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) @@ -374,16 +407,18 @@ def diagnoseMagnetometer(data, output): output["passed"] = False def diagnoseBarometer(data, output): - data = data["barometer"] - timestamps = np.array(data["t"]) - deltaTimes = np.array(data["td"]) - signal = data['v'] + sensor = data["barometer"] + timestamps = np.array(sensor["t"]) + deltaTimes = np.array(sensor["td"]) + signal = sensor['v'] if len(timestamps) == 0: return status = Status() deltaTimePlotColors = status.analyzeTimestamps( + timestamps, deltaTimes, + getImuTimestamps(data), BAROMETER_MIN_FREQUENCY_HZ, BAROMETER_MAX_FREQUENCY_HZ) status.analyzeSignal(signal) From 720bd759a034cd7ba4ba33811a003ba15edc463d Mon Sep 17 00:00:00 2001 From: kaatr Date: Thu, 12 Jun 2025 09:58:50 +0300 Subject: [PATCH 10/25] Support GPS --- python/cli/diagnose/diagnose.py | 17 +++-- python/cli/diagnose/gnss.py | 112 ++++++++++++++++++++++++++++++++ python/cli/diagnose/html.py | 2 +- python/cli/diagnose/sensors.py | 68 ++++++++++++++++--- 4 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 python/cli/diagnose/gnss.py diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index 1c48282..92f6dce 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -8,6 +8,7 @@ from .html import generateHtml from .sensors import * +from .gnss import GnssConverter def define_args(parser): parser.add_argument("dataset_path", type=pathlib.Path, help="Path to dataset") @@ -48,6 +49,7 @@ def generateReport(args): 'gyroscope': {"v": [], "t": [], "td": []}, 'magnetometer': {"v": [], "t": [], "td": []}, 'barometer': {"v": [], "t": [], "td": []}, + 'gps': {"v": [], "t": [], "td": []}, 'cpu': {"v": [], "t": []}, 'cameras': {} } @@ -63,6 +65,7 @@ def addMeasurement(type, t, v): startTime = None timeOffset = 0 + gnssConverter = GnssConverter() with open(jsonlFile) as f: nSkipped = 0 @@ -74,7 +77,8 @@ def addMeasurement(type, t, v): continue time = measurement.get("time") sensor = measurement.get("sensor") - barometerMeasurement = measurement.get("barometer") + barometer = measurement.get("barometer") + gps = measurement.get("gps") frames = measurement.get("frames") metrics = measurement.get("systemMetrics") if frames is None and 'frame' in measurement: @@ -86,7 +90,8 @@ def addMeasurement(type, t, v): if (sensor is None and frames is None and metrics is None - and barometerMeasurement is None): continue + and barometer is None + and gps is None): continue if startTime is None: startTime = time @@ -103,8 +108,11 @@ def addMeasurement(type, t, v): if measurementType in ["accelerometer", "gyroscope", "magnetometer"]: v = [sensor["values"][i] for i in range(3)] addMeasurement(measurementType, t, v) - elif barometerMeasurement is not None: - addMeasurement("barometer", t, barometerMeasurement["pressureHectopascals"]) + elif barometer is not None: + addMeasurement("barometer", t, barometer["pressureHectopascals"]) + elif gps is not None: + enu = gnssConverter.enu(gps["latitude"], gps["longitude"], gps["altitude"]) + addMeasurement("gps", t, [enu["x"], enu["y"], gps["altitude"]]) elif frames is not None: for f in frames: if f.get("missingBitmap", False): continue @@ -133,6 +141,7 @@ def addMeasurement(type, t, v): diagnoseGyroscope(data, output) diagnoseMagnetometer(data, output) diagnoseBarometer(data, output) + diagnoseGps(data, output) diagnoseCpu(data, output) if args.output_json: diff --git a/python/cli/diagnose/gnss.py b/python/cli/diagnose/gnss.py new file mode 100644 index 0000000..5a903b7 --- /dev/null +++ b/python/cli/diagnose/gnss.py @@ -0,0 +1,112 @@ +import numpy as np + +class Ellipsoid: + def __init__(self, a, b): + self.a = a # semi-major axis + self.b = b # semi-minor axis + f = 1.0 - b / a # flattening factor + self.e2 = 2 * f - f ** 2 # eccentricity squared + +class GnssConverter: + def __init__(self): + self.ell = Ellipsoid(a=6378137.0, b=6356752.31424518) # WGS-84 ellipsoid + self.initialized = False + self.originECEF = None + self.R_ecef2enu = None + self.R_enu2ecef = None + self.prev = {"x": 0, "y": 0, "z": 0} + + def set_origin(self, lat, lon, alt): + def ecef_to_enu_rotation_matrix(lat, lon): + lat = np.deg2rad(lat) + lon = np.deg2rad(lon) + + return np.array([ + [-np.sin(lon), np.cos(lon), 0], + [-np.sin(lat)*np.cos(lon), -np.sin(lat)*np.sin(lon), np.cos(lat)], + [np.cos(lat)*np.cos(lon), np.cos(lat)*np.sin(lon), np.sin(lat)] + ]) + + self.initialized = True + self.originECEF = self.__geodetic2ecef(lat, lon, alt) + self.R_ecef2enu = ecef_to_enu_rotation_matrix(lat, lon) + self.R_enu2ecef = self.R_ecef2enu.T + + def __geodetic2ecef(self, lat, lon, alt): + # https://gssc.esa.int/navipedia/index.php/Ellipsoidal_and_Cartesian_Coordinates_Conversion + lat = np.deg2rad(lat) + lon = np.deg2rad(lon) + a = self.ell.a + e2 = self.ell.e2 + N = a / np.sqrt(1 - e2 * np.sin(lat) * np.sin(lat)) # radius of curvature in the prime vertical + + x = (N + alt) * np.cos(lat) * np.cos(lon) + y = (N + alt) * np.cos(lat) * np.sin(lon) + z = ((1 - e2) * N + alt) * np.sin(lat) + return np.array([x, y, z]) + + def __ecef2geodetic(self, x, y, z): + # https://gssc.esa.int/navipedia/index.php/Ellipsoidal_and_Cartesian_Coordinates_Conversion + a = self.ell.a + e2 = self.ell.e2 + p = np.sqrt(x**2 + y**2) + lon = np.arctan2(y, x) + + # latitude and altitude are computed by an iterative procedure. + MAX_ITERS = 1000 + MIN_LATITUDE_CHANGE_RADIANS = 1e-10 + MIN_ALTITUDE_CHANGE_METERS = 1e-6 + lat_prev = np.arctan(z / ((1-e2)*p)) # initial value + alt_prev = -100000 # arbitrary + for _ in range(MAX_ITERS): + N_i = a / np.sqrt(1-e2*np.sin(lat_prev)**2) + alt_i = p / np.cos(lat_prev) - N_i + lat_i = np.arctan(z / ((1 - e2 * (N_i/(N_i + alt_i)))*p)) + if abs(lat_i - lat_prev) < MIN_LATITUDE_CHANGE_RADIANS and abs(alt_i - alt_prev) < MIN_ALTITUDE_CHANGE_METERS: break + alt_prev = alt_i + lat_prev = lat_i + + lat = np.rad2deg(lat_i) + lon = np.rad2deg(lon) + return np.array([lat, lon, alt_i]) + + def __ecef2enu(self, x, y, z): + # https://gssc.esa.int/navipedia/index.php/Transformations_between_ECEF_and_ENU_coordinates + assert(self.initialized) + xyz = np.array([x, y, z]) + xyz = xyz - self.originECEF + return self.R_ecef2enu @ xyz + + def __enu2ecef(self, e, n, u): + # https://gssc.esa.int/navipedia/index.php/Transformations_between_ECEF_and_ENU_coordinates + assert(self.initialized) + enu = np.array([e, n, u]) + xyz = self.R_enu2ecef @ enu + return xyz + self.originECEF + + def enu(self, lat, lon, alt=0, accuracy=1.0, minAccuracy=-1.0): + # Filter out inaccurate measurements to make pose alignment easier. + if (minAccuracy > 0.0 and (accuracy > minAccuracy or accuracy < 0.0)): + return self.prev + + if not self.initialized: + self.set_origin(lat, lon, alt) + + x, y, z = self.__geodetic2ecef(lat, lon, alt) + enu = self.__ecef2enu(x, y, z) + enu = { "x": enu[0], "y": enu[1], "z": enu[2] } + self.prev = enu + return enu + + def wgs(self, e, n, u): + assert(self.initialized) + x, y, z = self.__enu2ecef(e, n, u) + wgs = self.__ecef2geodetic(x, y, z) + return { "latitude": wgs[0], "longitude": wgs[1], "altitude": wgs[2] } + + def wgs_array(self, pos): + assert(self.initialized) + arr = [] + for enu in pos: + arr.append(self.wgs(enu[0], enu[1], enu[2])) + return arr \ No newline at end of file diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py index 263f99c..e38a001 100644 --- a/python/cli/diagnose/html.py +++ b/python/cli/diagnose/html.py @@ -132,7 +132,7 @@ def generateHtml(output, output_html): camera["frequency"], camera["count"]))) - SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer", "barometer"] + SENSOR_NAMES = ["accelerometer", "gyroscope", "magnetometer", "barometer", "gps"] for sensor in SENSOR_NAMES: if sensor not in output: continue kv_pairs.append(( diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 6ea75a4..09de718 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -12,14 +12,16 @@ MAGNETOMETER_MAX_FREQUENCY_HZ = 1e3 BAROMETER_MIN_FREQUENCY_HZ = 1.0 BAROMETER_MAX_FREQUENCY_HZ = 1e3 +GPS_MIN_FREQUENCY_HZ = None +GPS_MAX_FREQUENCY_HZ = 100.0 DELTA_TIME_PLOT_KWARGS = { 'plottype': 'scatter', - 'xlabel': "Time (s)", + 'xLabel': "Time (s)", 'yLabel':"Time diff (ms)" } SIGNAL_PLOT_KWARGS = { - 'xlabel': "Time (s)", + 'xLabel': "Time (s)", 'style': '.-', 'linewidth': 0.1, 'markersize': 1 @@ -57,7 +59,8 @@ def analyzeTimestamps( deltaTimes, imuTimestamps, minFrequencyHz, - maxFrequencyHz): + maxFrequencyHz, + allowDataGaps=False): WARNING_RELATIVE_DELTA_TIME = 0.1 ERROR_DELTA_TIME_SECONDS = 0.5 @@ -104,7 +107,7 @@ def toPercent(value): self.issues.append(f"Found {duplicateTimestamps} ({toPercent(duplicateTimestamps)}) duplicate timestamps.") self.__updateDiagnosis(DiagnosisLevel.ERROR) - if dataGaps > 0: + if dataGaps > 0 and not allowDataGaps: self.issues.append(f"Found {dataGaps} ({toPercent(dataGaps)}) pauses longer than {thresholdDataGap:.2f}seconds.") self.__updateDiagnosis(DiagnosisLevel.ERROR) @@ -118,11 +121,11 @@ def toPercent(value): self.__updateDiagnosis(DiagnosisLevel.WARNING) frequency = 1.0 / medianDeltaTime - if frequency < minFrequencyHz: + if minFrequencyHz is not None and frequency < minFrequencyHz: self.issues.append(f"Minimum required frequency is {minFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") self.__updateDiagnosis(DiagnosisLevel.ERROR) - if frequency > maxFrequencyHz: + if maxFrequencyHz is not None and frequency > maxFrequencyHz: self.issues.append(f"Maximum allowed frequency is {maxFrequencyHz:.1f}Hz but data is {frequency:.1f}Hz") self.__updateDiagnosis(DiagnosisLevel.ERROR) @@ -177,7 +180,7 @@ def plotFrame( title, style=None, plottype='plot', - xlabel=None, + xLabel=None, yLabel=None, legend=None, ymin=None, @@ -200,7 +203,7 @@ def plotFrame( p(x, ys, **kwargs) ax.margins(x=0) - if xlabel is not None: ax.set_xlabel(xlabel) + if xLabel is not None: ax.set_xlabel(xLabel) if yLabel is not None: ax.set_ylabel(yLabel) if legend is not None: ax.legend(legend, fontsize='large', markerscale=10) fig.tight_layout() @@ -447,6 +450,55 @@ def diagnoseBarometer(data, output): if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False +def diagnoseGps(data, output): + sensor = data["gps"] + timestamps = np.array(sensor["t"]) + deltaTimes = np.array(sensor["td"]) + signal = sensor['v'] + + if len(timestamps) == 0: return + + status = Status() + deltaTimePlotColors = status.analyzeTimestamps( + timestamps, + deltaTimes, + getImuTimestamps(data), + GPS_MIN_FREQUENCY_HZ, + GPS_MAX_FREQUENCY_HZ, + allowDataGaps=True) + status.analyzeSignal(signal) + + output["gps"] = { + "diagnosis": status.diagnosis.toString(), + "issues": status.issues, + "frequency": 1.0 / np.median(deltaTimes), + "count": len(timestamps), + "images": [ + plotFrame( + np.array(signal)[:, 0], + np.array(signal)[:, 1], + "GPS position (ENU)", + xLabel="x (m)", + yLabel="y (m)", + style='-'), + plotFrame( + timestamps, + np.array(signal)[:, 2], + "GPS altitude (WGS-84)", + yLabel="Altitude (m)", + **SIGNAL_PLOT_KWARGS), + plotFrame( + timestamps[1:], + deltaTimes * SECONDS_TO_MILLISECONDS, + "GPS time diff (ms)", + color=deltaTimePlotColors, + s=1, + **DELTA_TIME_PLOT_KWARGS) + ] + } + if status.diagnosis == DiagnosisLevel.ERROR: + output["passed"] = False + def diagnoseCpu(data, output): data = data["cpu"] timestamps = np.array(data["t"]) From 21ed48c0b11a008000bb5be36cb2d375f75a5ccd Mon Sep 17 00:00:00 2001 From: kaatr Date: Fri, 13 Jun 2025 16:45:51 +0300 Subject: [PATCH 11/25] Add IMU noise analysis & clean up --- python/cli/diagnose/sensors.py | 368 ++++++++++++++++++++------------- 1 file changed, 229 insertions(+), 139 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 09de718..bcb021c 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -15,18 +15,61 @@ GPS_MIN_FREQUENCY_HZ = None GPS_MAX_FREQUENCY_HZ = 100.0 -DELTA_TIME_PLOT_KWARGS = { - 'plottype': 'scatter', - 'xLabel': "Time (s)", - 'yLabel':"Time diff (ms)" -} SIGNAL_PLOT_KWARGS = { 'xLabel': "Time (s)", - 'style': '.-', - 'linewidth': 0.1, - 'markersize': 1 + 'style': '-', + 'linewidth': 1.0 } +def base64(fig): + import io + import base64 + buf = io.BytesIO() + fig.savefig(buf, format='png') + buf.seek(0) + return base64.b64encode(buf.getvalue()).decode('utf-8') + +def plotFrame( + x, + ys, + title, + style=None, + plottype='plot', + xLabel=None, + yLabel=None, + legend=None, + ymin=None, + ymax=None, + xMargin=0, + yMargin=0, + plot=False, + **kwargs): + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(8, 6)) # Fixed image size + + ax.set_title(title) + p = getattr(ax, plottype) + + if ymin is not None and ymax is not None: + ax.set_ylim(ymin, ymax) + + if style is not None: + p(x, ys, style, **kwargs) + else: + p(x, ys, **kwargs) + + ax.margins(x=xMargin, y=yMargin) + if xLabel is not None: ax.set_xlabel(xLabel) + if yLabel is not None: ax.set_ylabel(yLabel) + if legend is not None: + leg = ax.legend(legend, fontsize='large', markerscale=10) + for line in leg.get_lines(): line.set_linewidth(2) + fig.tight_layout() + if plot: plt.show() + + return base64(fig) + class DiagnosisLevel(Enum): OK = 0 WARNING = 1 @@ -49,6 +92,7 @@ class Status: def __init__(self): self.diagnosis = DiagnosisLevel.OK # Overall diagnosis of the data self.issues = [] # Human readable list of issues found during analysis + self.images = [] # Plots that were created during analysis def __updateDiagnosis(self, newDiagnosis): self.diagnosis = max(self.diagnosis, newDiagnosis) @@ -60,6 +104,7 @@ def analyzeTimestamps( imuTimestamps, minFrequencyHz, maxFrequencyHz, + plotArgs, allowDataGaps=False): WARNING_RELATIVE_DELTA_TIME = 0.1 ERROR_DELTA_TIME_SECONDS = 0.5 @@ -99,6 +144,15 @@ def toPercent(value): else: deltaTimePlotColors.append(COLOR_OK) + self.images.append(plotFrame( + timestamps[1:], + deltaTimes * SECONDS_TO_MILLISECONDS, + color=deltaTimePlotColors, + plottype="scatter", + xLabel="Time (s)", + yLabel="Time diff (ms)", + **plotArgs)) + if samplesInWrongOrder > 0: self.issues.append(f"Found {samplesInWrongOrder} ({toPercent(samplesInWrongOrder)}) timestamps that are in non-chronological order.") self.__updateDiagnosis(DiagnosisLevel.ERROR) @@ -145,9 +199,10 @@ def toPercent(value): "timestamps that don't overlap with IMU") self.__updateDiagnosis(DiagnosisLevel.WARNING) - return deltaTimePlotColors - - def analyzeSignal(self, signal, maxDuplicateRatio=0.01): + def analyzeSignalDuplicateValues( + self, + signal, + maxDuplicateRatio=0.01): prev = None total = np.shape(signal)[0] @@ -155,9 +210,10 @@ def toPercent(value): p = (value / total) * TO_PERCENT return f"{p:.1f}%" + # 1) Check for consecutive duplicate values in the signal duplicateSamples = 0 for v in signal: - if prev is not None and v == prev: + if prev is not None and (v == prev).all(): duplicateSamples += 1 prev = v @@ -166,50 +222,92 @@ def toPercent(value): if maxDuplicateRatio * total < duplicateSamples: self.__updateDiagnosis(DiagnosisLevel.WARNING) -def base64(fig): - import io - import base64 - buf = io.BytesIO() - fig.savefig(buf, format='png') - buf.seek(0) - return base64.b64encode(buf.getvalue()).decode('utf-8') - -def plotFrame( - x, - ys, - title, - style=None, - plottype='plot', - xLabel=None, - yLabel=None, - legend=None, - ymin=None, - ymax=None, - plot=False, - **kwargs): - import matplotlib.pyplot as plt - - fig, ax = plt.subplots(figsize=(8, 6)) # Fixed image size - - ax.set_title(title) - p = getattr(ax, plottype) - - if ymin is not None and ymax is not None: - ax.set_ylim(ymin, ymax) - - if style is not None: - p(x, ys, style, **kwargs) - else: - p(x, ys, **kwargs) - - ax.margins(x=0) - if xLabel is not None: ax.set_xlabel(xLabel) - if yLabel is not None: ax.set_ylabel(yLabel) - if legend is not None: ax.legend(legend, fontsize='large', markerscale=10) - fig.tight_layout() - if plot: plt.show() + def analyzeSignalNoise( + self, + signal, + timestamps, + samplingRate, + cutoffFrequency, + sensorName, + yLabel): + SNR_ERROR_THRESHOLD_DB = 0 + WINDOW_SIZE_SECONDS = 1.0 + count = np.shape(timestamps)[0] + windowSize = int(WINDOW_SIZE_SECONDS * samplingRate) + if windowSize <= 0: return + if count < windowSize: return + + def highpass(signal, fs, cutoff, order=3): + from scipy.signal import butter, filtfilt + b, a = butter(order, cutoff / (0.5 * fs), btype='high') + return filtfilt(b, a, signal) + + def signalToNoiseRatioDb(signal, noise): + signalPower = np.mean(signal**2) + noisePower = np.mean(noise**2) + if noisePower <= 0: return 0 + return 10.0 * np.log10(signalPower / noisePower) + + def rollingWindowSignalToNoiseRatioDb(signal, noise, windowSize): + if len(signal) < windowSize: return [] + snr = np.full(len(signal) - windowSize + 1, np.nan) + j = 0 + for i in range(windowSize - 1, len(signal)): + signalWindow = signal[i - windowSize + 1 : i + 1] + noiseWindow = noise[i - windowSize + 1 : i + 1] + snr[j] = signalToNoiseRatioDb(signalWindow, noiseWindow) + j += 1 + return snr + + # Find channel with worst SNR + noise = np.zeros_like(signal) + filtered = np.zeros_like(signal) + snrPerChannel = [] + for c in range(np.shape(signal)[1]): + noise[:, c] = highpass(signal[:, c], samplingRate, cutoffFrequency) + filtered[:, c] = signal[:, c] - noise[:, c] + snr = signalToNoiseRatioDb(filtered[:, c], noise[:, c]) + snrPerChannel.append(snr) + + idx = np.argmin(snrPerChannel) + signalWithNoise = signal[:, idx] + noise = noise[:, idx] + filtered = filtered[:, idx] + snr = snrPerChannel[idx] + + rollingWindowSnr = rollingWindowSignalToNoiseRatioDb(filtered, noise, windowSize) + + # Pick worst of + # 1) SNR for entire signal + # 2) Median of rolling window SNR (gives more robust estimate for the SNR) + snr = min(snr, np.median(rollingWindowSnr)) + + self.images.append(plotFrame( + timestamps[windowSize-1:], + rollingWindowSnr, + f"{sensorName} signal to noise ratio (SNR={snr:.1f}) using {WINDOW_SIZE_SECONDS} second window", + xLabel="Time (s)", + yLabel="SNR (dB)")) + + if snr < SNR_ERROR_THRESHOLD_DB: + self.issues.append(f"Signal to noise ratio {snr:.1f}dB is lower than the threshold {SNR_ERROR_THRESHOLD_DB:.1f}dB") + self.__updateDiagnosis(DiagnosisLevel.ERROR) - return base64(fig) + i0 = np.argwhere(rollingWindowSnr < SNR_ERROR_THRESHOLD_DB)[0][0] + i1 = min(i0 + int(5 * samplingRate), len(rollingWindowSnr)) # 5 second window + + self.images.append(plotFrame( + timestamps[i0:i1], + np.column_stack([ + signalWithNoise[i0:i1], + noise[i0:i1], + filtered[i0:i1]] + ), + "First part of signal with low signal to noise ratio", + legend=['signal with noise', f'noise (high-pass with cutoff={cutoffFrequency}Hz)', 'signal'], + yLabel=yLabel, + **SIGNAL_PLOT_KWARGS + )) def getImuTimestamps(data): return data["accelerometer"]["t"] @@ -226,33 +324,28 @@ def diagnoseCamera(data, output): if len(timestamps) == 0: continue status = Status() - deltaTimePlotColors = status.analyzeTimestamps( + status.analyzeTimestamps( timestamps, deltaTimes, getImuTimestamps(data), CAMERA_MIN_FREQUENCY_HZ, - CAMERA_MAX_FREQUENCY_HZ) + CAMERA_MAX_FREQUENCY_HZ, + plotArgs={ + "title": f"Camera #{ind} frame time diff", + "s": 10 + }) cameraOutput = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, "ind": ind, "frequency": 1.0 / np.median(deltaTimes), - "count": len(timestamps) + "count": len(timestamps), + "images": status.images } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False - cameraOutput["images"] = [ - plotFrame( - timestamps[1:], - deltaTimes * SECONDS_TO_MILLISECONDS, - f"Camera #{ind} frame time diff", - color=deltaTimePlotColors, - s=10, - **DELTA_TIME_PLOT_KWARGS) - ] - if camera.get("features"): cameraOutput["images"].append(plotFrame( timestamps, @@ -270,7 +363,7 @@ def diagnoseAccelerometer(data, output): sensor = data["accelerometer"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) - signal = sensor['v'] + signal = np.array(sensor['v']) if len(timestamps) == 0: # Accelerometer is required @@ -283,18 +376,32 @@ def diagnoseAccelerometer(data, output): return status = Status() - deltaTimePlotColors = status.analyzeTimestamps( + status.analyzeTimestamps( timestamps, deltaTimes, getImuTimestamps(data), IMU_MIN_FREQUENCY_HZ, - IMU_MAX_FREQUENCY_HZ) - status.analyzeSignal(signal) + IMU_MAX_FREQUENCY_HZ, + plotArgs={ + "title": "Accelerometer time diff", + "s": 1 + }) + status.analyzeSignalDuplicateValues(signal) + + samplingRate = 1.0 / np.median(deltaTimes) + ACCELEROMETER_CUTOFF_FREQUENCY = 10 + status.analyzeSignalNoise( + signal, + timestamps, + samplingRate, + ACCELEROMETER_CUTOFF_FREQUENCY, + sensorName="Accelerometer", + yLabel="Acceleration (m/s²)") output["accelerometer"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, - "frequency": 1.0 / np.median(deltaTimes), + "frequency": samplingRate, "count": len(timestamps), "images": [ plotFrame( @@ -304,14 +411,7 @@ def diagnoseAccelerometer(data, output): yLabel="Acceleration (m/s²)", legend=['x', 'y', 'z'], **SIGNAL_PLOT_KWARGS), - plotFrame( - timestamps[1:], - deltaTimes * SECONDS_TO_MILLISECONDS, - "Accelerometer time diff", - color=deltaTimePlotColors, - s=1, - **DELTA_TIME_PLOT_KWARGS) - ] + ] + status.images } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False @@ -320,7 +420,7 @@ def diagnoseGyroscope(data, output): sensor = data["gyroscope"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) - signal = sensor['v'] + signal = np.array(sensor['v']) if len(timestamps) == 0: # Gyroscope is required @@ -334,13 +434,17 @@ def diagnoseGyroscope(data, output): return status = Status() - deltaTimePlotColors = status.analyzeTimestamps( + status.analyzeTimestamps( timestamps, deltaTimes, getImuTimestamps(data), IMU_MIN_FREQUENCY_HZ, - IMU_MAX_FREQUENCY_HZ) - status.analyzeSignal(signal) + IMU_MAX_FREQUENCY_HZ, + plotArgs={ + "title": "Gyroscope time diff", + "s": 1 + }) + status.analyzeSignalDuplicateValues(signal) output["gyroscope"] = { "diagnosis": status.diagnosis.toString(), @@ -354,15 +458,8 @@ def diagnoseGyroscope(data, output): "Gyroscope signal", yLabel="Angular velocity (rad/s)", legend=['x', 'y', 'z'], - **SIGNAL_PLOT_KWARGS), - plotFrame( - timestamps[1:], - deltaTimes * SECONDS_TO_MILLISECONDS, - "Gyroscope time diff (ms)", - color=deltaTimePlotColors, - s=1, - **DELTA_TIME_PLOT_KWARGS) - ] + **SIGNAL_PLOT_KWARGS) + ] + status.images } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False @@ -371,18 +468,22 @@ def diagnoseMagnetometer(data, output): sensor = data["magnetometer"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) - signal = sensor['v'] + signal = np.array(sensor['v']) if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps( + status.analyzeTimestamps( timestamps, deltaTimes, getImuTimestamps(data), MAGNETOMETER_MIN_FREQUENCY_HZ, - MAGNETOMETER_MAX_FREQUENCY_HZ) - status.analyzeSignal(signal) + MAGNETOMETER_MAX_FREQUENCY_HZ, + plotArgs={ + "title": "Magnetometer time diff", + "s": 1 + }) + status.analyzeSignalDuplicateValues(signal) output["magnetometer"] = { "diagnosis": status.diagnosis.toString(), @@ -396,15 +497,8 @@ def diagnoseMagnetometer(data, output): "Magnetometer signal", yLabel="Microteslas (μT)", legend=['x', 'y', 'z'], - **SIGNAL_PLOT_KWARGS), - plotFrame( - timestamps[1:], - deltaTimes * SECONDS_TO_MILLISECONDS, - "Magnetometer time diff (ms)", - color=deltaTimePlotColors, - s=1, - **DELTA_TIME_PLOT_KWARGS) - ] + **SIGNAL_PLOT_KWARGS) + ] + status.images } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False @@ -413,18 +507,22 @@ def diagnoseBarometer(data, output): sensor = data["barometer"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) - signal = sensor['v'] + signal = np.array(sensor['v']) if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps( + status.analyzeTimestamps( timestamps, deltaTimes, getImuTimestamps(data), BAROMETER_MIN_FREQUENCY_HZ, - BAROMETER_MAX_FREQUENCY_HZ) - status.analyzeSignal(signal) + BAROMETER_MAX_FREQUENCY_HZ, + plotArgs={ + "title": "Barometer time diff", + "s": 1 + }) + status.analyzeSignalDuplicateValues(signal) output["barometer"] = { "diagnosis": status.diagnosis.toString(), @@ -437,15 +535,8 @@ def diagnoseBarometer(data, output): signal, "Barometer signal", yLabel="Pressure (hPa)", - **SIGNAL_PLOT_KWARGS), - plotFrame( - timestamps[1:], - deltaTimes * SECONDS_TO_MILLISECONDS, - "Barometer time diff (ms)", - color=deltaTimePlotColors, - s=1, - **DELTA_TIME_PLOT_KWARGS) - ] + **SIGNAL_PLOT_KWARGS) + ] + status.images } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False @@ -454,19 +545,23 @@ def diagnoseGps(data, output): sensor = data["gps"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) - signal = sensor['v'] + signal = np.array(sensor['v']) if len(timestamps) == 0: return status = Status() - deltaTimePlotColors = status.analyzeTimestamps( + status.analyzeTimestamps( timestamps, deltaTimes, getImuTimestamps(data), GPS_MIN_FREQUENCY_HZ, GPS_MAX_FREQUENCY_HZ, + plotArgs={ + "title": "GPS time diff", + "s": 1 + }, allowDataGaps=True) - status.analyzeSignal(signal) + status.analyzeSignalDuplicateValues(signal) output["gps"] = { "diagnosis": status.diagnosis.toString(), @@ -475,26 +570,21 @@ def diagnoseGps(data, output): "count": len(timestamps), "images": [ plotFrame( - np.array(signal)[:, 0], - np.array(signal)[:, 1], - "GPS position (ENU)", - xLabel="x (m)", - yLabel="y (m)", - style='-'), + signal[:, 0], + signal[:, 1], + "GPS position", + xLabel="ENU x (m)", + yLabel="ENU y (m)", + style='-', + xMargin=0.05, + yMargin=0.05,), plotFrame( timestamps, - np.array(signal)[:, 2], + signal[:, 2], "GPS altitude (WGS-84)", yLabel="Altitude (m)", - **SIGNAL_PLOT_KWARGS), - plotFrame( - timestamps[1:], - deltaTimes * SECONDS_TO_MILLISECONDS, - "GPS time diff (ms)", - color=deltaTimePlotColors, - s=1, - **DELTA_TIME_PLOT_KWARGS) - ] + **SIGNAL_PLOT_KWARGS) + ] + status.images } if status.diagnosis == DiagnosisLevel.ERROR: output["passed"] = False From de9b7b2e3a40c26636133904d8352db9868ae71e Mon Sep 17 00:00:00 2001 From: kaatr Date: Mon, 16 Jun 2025 09:49:06 +0300 Subject: [PATCH 12/25] Fix crash if signal length == 1 --- python/cli/diagnose/sensors.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index bcb021c..3ec6d0a 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -114,6 +114,7 @@ def analyzeTimestamps( dataGaps = 0 badDeltaTimes = 0 total = len(deltaTimes) + if total == 0: return def toPercent(value): p = (value / total) * TO_PERCENT @@ -312,6 +313,10 @@ def rollingWindowSignalToNoiseRatioDb(signal, noise, windowSize): def getImuTimestamps(data): return data["accelerometer"]["t"] +def computeSamplingRate(deltaTimes): + if len(deltaTimes) == 0: return 0 + return 1.0 / np.median(deltaTimes) + def diagnoseCamera(data, output): sensor = data["cameras"] output["cameras"] = [] @@ -338,7 +343,7 @@ def diagnoseCamera(data, output): "diagnosis": status.diagnosis.toString(), "issues": status.issues, "ind": ind, - "frequency": 1.0 / np.median(deltaTimes), + "frequency": computeSamplingRate(deltaTimes), "count": len(timestamps), "images": status.images } @@ -388,7 +393,7 @@ def diagnoseAccelerometer(data, output): }) status.analyzeSignalDuplicateValues(signal) - samplingRate = 1.0 / np.median(deltaTimes) + samplingRate = computeSamplingRate(deltaTimes) ACCELEROMETER_CUTOFF_FREQUENCY = 10 status.analyzeSignalNoise( signal, @@ -449,7 +454,7 @@ def diagnoseGyroscope(data, output): output["gyroscope"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, - "frequency": 1.0 / np.median(deltaTimes), + "frequency": computeSamplingRate(deltaTimes), "count": len(timestamps), "images": [ plotFrame( @@ -488,7 +493,7 @@ def diagnoseMagnetometer(data, output): output["magnetometer"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, - "frequency": 1.0 / np.median(deltaTimes), + "frequency": computeSamplingRate(deltaTimes), "count": len(timestamps), "images": [ plotFrame( @@ -527,7 +532,7 @@ def diagnoseBarometer(data, output): output["barometer"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, - "frequency": 1.0 / np.median(deltaTimes), + "frequency": computeSamplingRate(deltaTimes), "count": len(timestamps), "images": [ plotFrame( @@ -566,7 +571,7 @@ def diagnoseGps(data, output): output["gps"] = { "diagnosis": status.diagnosis.toString(), "issues": status.issues, - "frequency": 1.0 / np.median(deltaTimes), + "frequency": computeSamplingRate(deltaTimes), "count": len(timestamps), "images": [ plotFrame( @@ -575,15 +580,16 @@ def diagnoseGps(data, output): "GPS position", xLabel="ENU x (m)", yLabel="ENU y (m)", - style='-', + style='-' if len(timestamps) > 1 else '.', xMargin=0.05, - yMargin=0.05,), + yMargin=0.05), plotFrame( timestamps, signal[:, 2], "GPS altitude (WGS-84)", + xLabel="Time (s)", yLabel="Altitude (m)", - **SIGNAL_PLOT_KWARGS) + style='-' if len(timestamps) > 1 else '.') ] + status.images } if status.diagnosis == DiagnosisLevel.ERROR: From a9b6798508078d6c71eb305b0edf07da9f3adcb9 Mon Sep 17 00:00:00 2001 From: kaatr Date: Mon, 16 Jun 2025 10:09:35 +0300 Subject: [PATCH 13/25] Fix parsing features --- python/cli/diagnose/diagnose.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index 92f6dce..e41c3bf 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -119,15 +119,11 @@ def addMeasurement(type, t, v): cameras = data['cameras'] ind = f["cameraInd"] if cameras.get(ind) is None: - cameras[ind] = {"td": [], "t": [] } - if "features" in f and len(f["features"]) > 0: - cameras[ind]["features"] = [] + cameras[ind] = {"td": [], "t": [], "features": []} else: diff = t - cameras[ind]["t"][-1] cameras[ind]["td"].append(diff) - - if "features" in f and len(f["features"]) > 0: - cameras[ind]["features"].append(len(f["features"])) + if "features" in f: cameras[ind]["features"].append(len(f["features"])) cameras[ind]["t"].append(t) elif metrics is not None and 'cpu' in metrics: data["cpu"]["t"].append(t) From 458229c6377ef06e320e350c3486cb69fa5c0db7 Mon Sep 17 00:00:00 2001 From: kaatr Date: Tue, 17 Jun 2025 11:09:33 +0300 Subject: [PATCH 14/25] Require either --output_html or --output_json --- python/cli/diagnose/diagnose.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index e41c3bf..7e5d13b 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -38,11 +38,9 @@ def generateReport(args): 'dataset_path': str(jsonlFile.parent) } - # Plot figures if output isn't specified - if args.output_html or args.output_json: - plotFigures = False - else: - plotFigures = True + if not args.output_html and not args.output_json: + print("Either --output_html or --output_json is required") + return data = { 'accelerometer': {"v": [], "t": [], "td": []}, @@ -98,6 +96,7 @@ def addMeasurement(type, t, v): if args.zero: timeOffset = startTime + if (args.skip is not None and time - startTime < args.skip) or (args.max is not None and time - startTime > args.max): nSkipped += 1 continue @@ -129,8 +128,7 @@ def addMeasurement(type, t, v): data["cpu"]["t"].append(t) data["cpu"]["v"].append(metrics['cpu'].get('systemTotalUsagePercent', 0)) - if nSkipped > 0: - print('skipped %d lines' % nSkipped) + if nSkipped > 0: print(f'Skipped {nSkipped} lines') diagnoseCamera(data, output) diagnoseAccelerometer(data, output) From 8e4b927536f714455c98ab2dc43e0ff1f90e534b Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 18 Jun 2025 12:46:52 +0300 Subject: [PATCH 15/25] Improve IMU noise analysis --- python/cli/diagnose/sensors.py | 98 ++++++++++++++-------------------- 1 file changed, 41 insertions(+), 57 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 3ec6d0a..e6f3aa6 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -46,7 +46,7 @@ def plotFrame( **kwargs): import matplotlib.pyplot as plt - fig, ax = plt.subplots(figsize=(8, 6)) # Fixed image size + fig, ax = plt.subplots(figsize=(8, 6)) ax.set_title(title) p = getattr(ax, plottype) @@ -229,86 +229,68 @@ def analyzeSignalNoise( timestamps, samplingRate, cutoffFrequency, + noiseThreshold, sensorName, yLabel): - SNR_ERROR_THRESHOLD_DB = 0 WINDOW_SIZE_SECONDS = 1.0 count = np.shape(timestamps)[0] windowSize = int(WINDOW_SIZE_SECONDS * samplingRate) if windowSize <= 0: return if count < windowSize: return + if cutoffFrequency >= 2.0 * samplingRate: return def highpass(signal, fs, cutoff, order=3): from scipy.signal import butter, filtfilt b, a = butter(order, cutoff / (0.5 * fs), btype='high') return filtfilt(b, a, signal) - def signalToNoiseRatioDb(signal, noise): - signalPower = np.mean(signal**2) - noisePower = np.mean(noise**2) - if noisePower <= 0: return 0 - return 10.0 * np.log10(signalPower / noisePower) - - def rollingWindowSignalToNoiseRatioDb(signal, noise, windowSize): - if len(signal) < windowSize: return [] - snr = np.full(len(signal) - windowSize + 1, np.nan) - j = 0 - for i in range(windowSize - 1, len(signal)): - signalWindow = signal[i - windowSize + 1 : i + 1] - noiseWindow = noise[i - windowSize + 1 : i + 1] - snr[j] = signalToNoiseRatioDb(signalWindow, noiseWindow) - j += 1 - return snr - - # Find channel with worst SNR noise = np.zeros_like(signal) filtered = np.zeros_like(signal) - snrPerChannel = [] for c in range(np.shape(signal)[1]): noise[:, c] = highpass(signal[:, c], samplingRate, cutoffFrequency) filtered[:, c] = signal[:, c] - noise[:, c] - snr = signalToNoiseRatioDb(filtered[:, c], noise[:, c]) - snrPerChannel.append(snr) - idx = np.argmin(snrPerChannel) + # Find component with highest noise + noiseScale = np.mean(np.abs(noise), axis=0) + idx = np.argmax(noiseScale) + noiseScale = noiseScale[idx] signalWithNoise = signal[:, idx] noise = noise[:, idx] filtered = filtered[:, idx] - snr = snrPerChannel[idx] - rollingWindowSnr = rollingWindowSignalToNoiseRatioDb(filtered, noise, windowSize) + # Plot example of typical noise in the signal + PLOT_WINDOW_SIZE_SECONDS = 1.0 + # Find the index where the absolute noise is closest to the mean + i0 = np.argmin(np.abs(np.abs(noise) - noiseScale)) + i0 = max(0, i0 - int(0.5 * PLOT_WINDOW_SIZE_SECONDS * samplingRate)) + i1 = min(len(timestamps), i0 + int(PLOT_WINDOW_SIZE_SECONDS * samplingRate)) - # Pick worst of - # 1) SNR for entire signal - # 2) Median of rolling window SNR (gives more robust estimate for the SNR) - snr = min(snr, np.median(rollingWindowSnr)) + import matplotlib.pyplot as plt + fig, _ = plt.subplots(3, 1, figsize=(8, 6)) - self.images.append(plotFrame( - timestamps[windowSize-1:], - rollingWindowSnr, - f"{sensorName} signal to noise ratio (SNR={snr:.1f}) using {WINDOW_SIZE_SECONDS} second window", - xLabel="Time (s)", - yLabel="SNR (dB)")) - - if snr < SNR_ERROR_THRESHOLD_DB: - self.issues.append(f"Signal to noise ratio {snr:.1f}dB is lower than the threshold {SNR_ERROR_THRESHOLD_DB:.1f}dB") - self.__updateDiagnosis(DiagnosisLevel.ERROR) + plt.subplot(3, 1, 1) + plt.plot(timestamps[i0:i1], signalWithNoise[i0:i1], label="Original Signal") + plt.title("Original signal") + plt.ylabel(yLabel) + + plt.subplot(3, 1, 2) + plt.plot(timestamps[i0:i1], noise[i0:i1], label="High-Pass Filtered (keeps high frequencies)") + plt.title("High-Pass filtered signal (i.e. noise)") + plt.ylabel(yLabel) + + plt.subplot(3, 1, 3) + plt.plot(timestamps[i0:i1], filtered[i0:i1], label="Removed Low-Frequency Component") + plt.title("Signal without noise") + plt.xlabel('Time (s)') + plt.ylabel(yLabel) + + fig.suptitle(f"Preview of {sensorName} signal noise (mean={noiseScale:.1f}, threshold={noiseThreshold:.1f})") + fig.tight_layout(rect=[0, 0.03, 1, 0.95]) + self.images.append(base64(fig)) - i0 = np.argwhere(rollingWindowSnr < SNR_ERROR_THRESHOLD_DB)[0][0] - i1 = min(i0 + int(5 * samplingRate), len(rollingWindowSnr)) # 5 second window - - self.images.append(plotFrame( - timestamps[i0:i1], - np.column_stack([ - signalWithNoise[i0:i1], - noise[i0:i1], - filtered[i0:i1]] - ), - "First part of signal with low signal to noise ratio", - legend=['signal with noise', f'noise (high-pass with cutoff={cutoffFrequency}Hz)', 'signal'], - yLabel=yLabel, - **SIGNAL_PLOT_KWARGS - )) + if noiseScale > noiseThreshold: + self.issues.append(f"Signal noise {noiseScale} (mean) is higher than threshold {noiseThreshold}") + self.__updateDiagnosis(DiagnosisLevel.WARNING) def getImuTimestamps(data): return data["accelerometer"]["t"] @@ -394,12 +376,14 @@ def diagnoseAccelerometer(data, output): status.analyzeSignalDuplicateValues(signal) samplingRate = computeSamplingRate(deltaTimes) - ACCELEROMETER_CUTOFF_FREQUENCY = 10 + ACCELEROMETER_CUTOFF_FREQUENCY_HZ = min(samplingRate / 4.0, 50.0) + ACCELEROMETER_NOISE_THRESHOLD_MS2 = 2.5 status.analyzeSignalNoise( signal, timestamps, samplingRate, - ACCELEROMETER_CUTOFF_FREQUENCY, + ACCELEROMETER_CUTOFF_FREQUENCY_HZ, + ACCELEROMETER_NOISE_THRESHOLD_MS2, sensorName="Accelerometer", yLabel="Acceleration (m/s²)") From 8782f9783987639dea6186af66eb195d5ae5ddc6 Mon Sep 17 00:00:00 2001 From: kaatr Date: Wed, 18 Jun 2025 14:33:00 +0300 Subject: [PATCH 16/25] Improve delta time plots --- python/cli/diagnose/sensors.py | 54 +++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index e6f3aa6..b3b46b8 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -40,11 +40,12 @@ def plotFrame( legend=None, ymin=None, ymax=None, - xMargin=0, - yMargin=0, + xScale=None, + yScale=None, plot=False, **kwargs): import matplotlib.pyplot as plt + from matplotlib.ticker import ScalarFormatter fig, ax = plt.subplots(figsize=(8, 6)) @@ -59,9 +60,19 @@ def plotFrame( else: p(x, ys, **kwargs) - ax.margins(x=xMargin, y=yMargin) if xLabel is not None: ax.set_xlabel(xLabel) if yLabel is not None: ax.set_ylabel(yLabel) + if xScale is not None: + ax.set_xscale(xScale) + ax.xaxis.set_major_formatter(ScalarFormatter()) + ax.xaxis.set_minor_formatter(ScalarFormatter()) + ax.ticklabel_format(style='plain',axis='x',useOffset=False) + if yScale is not None: + ax.set_yscale(yScale) + ax.yaxis.set_major_formatter(ScalarFormatter()) + ax.yaxis.set_minor_formatter(ScalarFormatter()) + ax.ticklabel_format(style='plain',axis='y',useOffset=False) + if legend is not None: leg = ax.legend(legend, fontsize='large', markerscale=10) for line in leg.get_lines(): line.set_linewidth(2) @@ -106,8 +117,9 @@ def analyzeTimestamps( maxFrequencyHz, plotArgs, allowDataGaps=False): - WARNING_RELATIVE_DELTA_TIME = 0.1 - ERROR_DELTA_TIME_SECONDS = 0.5 + WARNING_RELATIVE_DELTA_TIME = 0.2 + DATA_GAP_RELATIVE_DELTA_TIME = 10 + MIN_DATA_GAP_SECONDS = 0.25 samplesInWrongOrder = 0 duplicateTimestamps = 0 @@ -121,8 +133,8 @@ def toPercent(value): return f"{p:.1f}%" medianDeltaTime = np.median(deltaTimes) - thresholdDataGap = ERROR_DELTA_TIME_SECONDS + medianDeltaTime thresholdDeltaTimeWarning = WARNING_RELATIVE_DELTA_TIME * medianDeltaTime + thresholdDataGap = max(MIN_DATA_GAP_SECONDS, DATA_GAP_RELATIVE_DELTA_TIME * medianDeltaTime) COLOR_OK = (0, 1, 0) # Green COLOR_WARNING = (1, 0.65, 0) # Orange @@ -152,6 +164,8 @@ def toPercent(value): plottype="scatter", xLabel="Time (s)", yLabel="Time diff (ms)", + yScale="log" if dataGaps > 0 else None, + s=10, **plotArgs)) if samplesInWrongOrder > 0: @@ -163,7 +177,7 @@ def toPercent(value): self.__updateDiagnosis(DiagnosisLevel.ERROR) if dataGaps > 0 and not allowDataGaps: - self.issues.append(f"Found {dataGaps} ({toPercent(dataGaps)}) pauses longer than {thresholdDataGap:.2f}seconds.") + self.issues.append(f"Found {dataGaps} ({toPercent(dataGaps)}) pauses longer than {SECONDS_TO_MILLISECONDS*thresholdDataGap:.1f}ms.") self.__updateDiagnosis(DiagnosisLevel.ERROR) if badDeltaTimes > 0: @@ -171,7 +185,7 @@ def toPercent(value): f"Found {badDeltaTimes} ({toPercent(badDeltaTimes)}) timestamps that differ from " f"expected delta time ({medianDeltaTime*SECONDS_TO_MILLISECONDS:.1f}ms) " f"more than {thresholdDeltaTimeWarning*SECONDS_TO_MILLISECONDS:.1f}ms.") - MAX_BAD_DELTA_TIME_RATIO = 0.01 + MAX_BAD_DELTA_TIME_RATIO = 0.05 if MAX_BAD_DELTA_TIME_RATIO * total < badDeltaTimes: self.__updateDiagnosis(DiagnosisLevel.WARNING) @@ -289,7 +303,7 @@ def highpass(signal, fs, cutoff, order=3): self.images.append(base64(fig)) if noiseScale > noiseThreshold: - self.issues.append(f"Signal noise {noiseScale} (mean) is higher than threshold {noiseThreshold}") + self.issues.append(f"Signal noise {noiseScale:.1f} (mean) is higher than threshold {noiseThreshold}.") self.__updateDiagnosis(DiagnosisLevel.WARNING) def getImuTimestamps(data): @@ -318,8 +332,7 @@ def diagnoseCamera(data, output): CAMERA_MIN_FREQUENCY_HZ, CAMERA_MAX_FREQUENCY_HZ, plotArgs={ - "title": f"Camera #{ind} frame time diff", - "s": 10 + "title": f"Camera #{ind} frame time diff" }) cameraOutput = { "diagnosis": status.diagnosis.toString(), @@ -370,8 +383,7 @@ def diagnoseAccelerometer(data, output): IMU_MIN_FREQUENCY_HZ, IMU_MAX_FREQUENCY_HZ, plotArgs={ - "title": "Accelerometer time diff", - "s": 1 + "title": "Accelerometer time diff" }) status.analyzeSignalDuplicateValues(signal) @@ -430,8 +442,7 @@ def diagnoseGyroscope(data, output): IMU_MIN_FREQUENCY_HZ, IMU_MAX_FREQUENCY_HZ, plotArgs={ - "title": "Gyroscope time diff", - "s": 1 + "title": "Gyroscope time diff" }) status.analyzeSignalDuplicateValues(signal) @@ -469,8 +480,7 @@ def diagnoseMagnetometer(data, output): MAGNETOMETER_MIN_FREQUENCY_HZ, MAGNETOMETER_MAX_FREQUENCY_HZ, plotArgs={ - "title": "Magnetometer time diff", - "s": 1 + "title": "Magnetometer time diff" }) status.analyzeSignalDuplicateValues(signal) @@ -508,8 +518,7 @@ def diagnoseBarometer(data, output): BAROMETER_MIN_FREQUENCY_HZ, BAROMETER_MAX_FREQUENCY_HZ, plotArgs={ - "title": "Barometer time diff", - "s": 1 + "title": "Barometer time diff" }) status.analyzeSignalDuplicateValues(signal) @@ -546,8 +555,7 @@ def diagnoseGps(data, output): GPS_MIN_FREQUENCY_HZ, GPS_MAX_FREQUENCY_HZ, plotArgs={ - "title": "GPS time diff", - "s": 1 + "title": "GPS time diff" }, allowDataGaps=True) status.analyzeSignalDuplicateValues(signal) @@ -564,9 +572,7 @@ def diagnoseGps(data, output): "GPS position", xLabel="ENU x (m)", yLabel="ENU y (m)", - style='-' if len(timestamps) > 1 else '.', - xMargin=0.05, - yMargin=0.05), + style='-' if len(timestamps) > 1 else '.'), plotFrame( timestamps, signal[:, 2], From 4ebd6d1f5d58f92f24850e34676740eb4de41631 Mon Sep 17 00:00:00 2001 From: kaatr Date: Thu, 19 Jun 2025 09:41:51 +0300 Subject: [PATCH 17/25] Try checking IMU units are correct --- python/cli/diagnose/sensors.py | 138 ++++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 28 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index b3b46b8..281181f 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -4,21 +4,9 @@ SECONDS_TO_MILLISECONDS = 1e3 TO_PERCENT = 100.0 -CAMERA_MIN_FREQUENCY_HZ = 1.0 -CAMERA_MAX_FREQUENCY_HZ = 100.0 -IMU_MIN_FREQUENCY_HZ = 50.0 -IMU_MAX_FREQUENCY_HZ = 1e4 -MAGNETOMETER_MIN_FREQUENCY_HZ = 1.0 -MAGNETOMETER_MAX_FREQUENCY_HZ = 1e3 -BAROMETER_MIN_FREQUENCY_HZ = 1.0 -BAROMETER_MAX_FREQUENCY_HZ = 1e3 -GPS_MIN_FREQUENCY_HZ = None -GPS_MAX_FREQUENCY_HZ = 100.0 - SIGNAL_PLOT_KWARGS = { 'xLabel': "Time (s)", - 'style': '-', - 'linewidth': 1.0 + 'style': '-' } def base64(fig): @@ -306,6 +294,55 @@ def highpass(signal, fs, cutoff, order=3): self.issues.append(f"Signal noise {noiseScale:.1f} (mean) is higher than threshold {noiseThreshold}.") self.__updateDiagnosis(DiagnosisLevel.WARNING) + def analyzeSignalUnit( + self, + signal, + timestamps, + correctUnit, + minThreshold=None, + maxThreshold=None): + + if signal.ndim == 1: + magnitude = np.abs(signal) + else: + magnitude = np.linalg.norm(signal, axis=1) + + minValue = np.min(magnitude) + maxValue = np.max(magnitude) + + shouldPlot = False + if minThreshold is not None and minValue < minThreshold: + shouldPlot = True + self.issues.append(f"Signal magnitude has values below threshold {minThreshold:.1f}{correctUnit}. " + f"Please check unit is {correctUnit}.") + self.__updateDiagnosis(DiagnosisLevel.ERROR) + elif maxThreshold is not None and maxValue > maxThreshold: + shouldPlot = True + self.issues.append(f"Signal magnitude has values above threshold {maxThreshold:.1f}{correctUnit}. " + f"Please check unit is {correctUnit}.") + self.__updateDiagnosis(DiagnosisLevel.ERROR) + + if shouldPlot: + ys = [magnitude] + legend = ["Signal Magnitude"] + + if minThreshold: + ys.append(np.full_like(magnitude, minThreshold)) + legend.append("Minimum threshold") + + if maxThreshold: + ys.append(np.full_like(magnitude, maxThreshold)) + legend.append("Maximum threshold") + + self.images.append(plotFrame( + x=timestamps, + ys=np.array(ys).T, + title="Signal magnitude and thresholds used in unit check", + yLabel=f"Magnitude ({correctUnit})", + legend=legend, + linewidth=2.0, + **SIGNAL_PLOT_KWARGS)) + def getImuTimestamps(data): return data["accelerometer"]["t"] @@ -314,6 +351,9 @@ def computeSamplingRate(deltaTimes): return 1.0 / np.median(deltaTimes) def diagnoseCamera(data, output): + CAMERA_MIN_FREQUENCY_HZ = 1.0 + CAMERA_MAX_FREQUENCY_HZ = 100.0 + sensor = data["cameras"] output["cameras"] = [] @@ -360,6 +400,12 @@ def diagnoseCamera(data, output): output["passed"] = False def diagnoseAccelerometer(data, output): + ACC_MIN_FREQUENCY_HZ = 50.0 + ACC_MAX_FREQUENCY_HZ = 1e4 + ACC_NOISE_THRESHOLD = 2.5 # m/s² + ACC_CUTOFF_FREQUENCY_HZ = 50.0 + ACC_UNIT_CHECK_THRESHOLD = 200.0 # m/s² + sensor = data["accelerometer"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) @@ -375,27 +421,31 @@ def diagnoseAccelerometer(data, output): } return + samplingRate = computeSamplingRate(deltaTimes) + cutoffThreshold = min(samplingRate / 4.0, ACC_CUTOFF_FREQUENCY_HZ) + status = Status() status.analyzeTimestamps( timestamps, deltaTimes, getImuTimestamps(data), - IMU_MIN_FREQUENCY_HZ, - IMU_MAX_FREQUENCY_HZ, + ACC_MIN_FREQUENCY_HZ, + ACC_MAX_FREQUENCY_HZ, plotArgs={ "title": "Accelerometer time diff" }) status.analyzeSignalDuplicateValues(signal) - - samplingRate = computeSamplingRate(deltaTimes) - ACCELEROMETER_CUTOFF_FREQUENCY_HZ = min(samplingRate / 4.0, 50.0) - ACCELEROMETER_NOISE_THRESHOLD_MS2 = 2.5 + status.analyzeSignalUnit( + signal, + timestamps, + "m/s²", + maxThreshold=ACC_UNIT_CHECK_THRESHOLD) status.analyzeSignalNoise( signal, timestamps, samplingRate, - ACCELEROMETER_CUTOFF_FREQUENCY_HZ, - ACCELEROMETER_NOISE_THRESHOLD_MS2, + cutoffThreshold, + ACC_NOISE_THRESHOLD, sensorName="Accelerometer", yLabel="Acceleration (m/s²)") @@ -418,6 +468,10 @@ def diagnoseAccelerometer(data, output): output["passed"] = False def diagnoseGyroscope(data, output): + GYRO_MIN_FREQUENCY_HZ = 50.0 + GYRO_MAX_FREQUENCY_HZ = 1e4 + GYRO_UNIT_CHECK_THRESHOLD = 20.0 # rad/s + sensor = data["gyroscope"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) @@ -439,12 +493,17 @@ def diagnoseGyroscope(data, output): timestamps, deltaTimes, getImuTimestamps(data), - IMU_MIN_FREQUENCY_HZ, - IMU_MAX_FREQUENCY_HZ, + GYRO_MIN_FREQUENCY_HZ, + GYRO_MAX_FREQUENCY_HZ, plotArgs={ "title": "Gyroscope time diff" }) status.analyzeSignalDuplicateValues(signal) + status.analyzeSignalUnit( + signal, + timestamps, + "rad/s", + maxThreshold=GYRO_UNIT_CHECK_THRESHOLD) output["gyroscope"] = { "diagnosis": status.diagnosis.toString(), @@ -465,6 +524,10 @@ def diagnoseGyroscope(data, output): output["passed"] = False def diagnoseMagnetometer(data, output): + MAGN_MIN_FREQUENCY_HZ = 1.0 + MAGN_MAX_FREQUENCY_HZ = 1e3 + MAGN_UNIT_CHECK_THRESHOLD = 1000 # microteslas + sensor = data["magnetometer"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) @@ -477,12 +540,17 @@ def diagnoseMagnetometer(data, output): timestamps, deltaTimes, getImuTimestamps(data), - MAGNETOMETER_MIN_FREQUENCY_HZ, - MAGNETOMETER_MAX_FREQUENCY_HZ, + MAGN_MIN_FREQUENCY_HZ, + MAGN_MAX_FREQUENCY_HZ, plotArgs={ "title": "Magnetometer time diff" }) status.analyzeSignalDuplicateValues(signal) + status.analyzeSignalUnit( + signal, + timestamps, + "microteslas (μT)", + maxThreshold=MAGN_UNIT_CHECK_THRESHOLD) output["magnetometer"] = { "diagnosis": status.diagnosis.toString(), @@ -494,7 +562,7 @@ def diagnoseMagnetometer(data, output): timestamps, signal, "Magnetometer signal", - yLabel="Microteslas (μT)", + yLabel="μT", legend=['x', 'y', 'z'], **SIGNAL_PLOT_KWARGS) ] + status.images @@ -503,6 +571,11 @@ def diagnoseMagnetometer(data, output): output["passed"] = False def diagnoseBarometer(data, output): + BARO_MIN_FREQUENCY_HZ = 1.0 + BARO_MAX_FREQUENCY_HZ = 1e3 + BARO_UNIT_CHECK_MIN_THRESHOLD = 800 # hPa + BARO_UNIT_CHECK_MAX_THRESHOLD = 1200 # hPa + sensor = data["barometer"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) @@ -515,12 +588,18 @@ def diagnoseBarometer(data, output): timestamps, deltaTimes, getImuTimestamps(data), - BAROMETER_MIN_FREQUENCY_HZ, - BAROMETER_MAX_FREQUENCY_HZ, + BARO_MIN_FREQUENCY_HZ, + BARO_MAX_FREQUENCY_HZ, plotArgs={ "title": "Barometer time diff" }) status.analyzeSignalDuplicateValues(signal) + status.analyzeSignalUnit( + signal, + timestamps, + "hPa", + BARO_UNIT_CHECK_MIN_THRESHOLD, + BARO_UNIT_CHECK_MAX_THRESHOLD) output["barometer"] = { "diagnosis": status.diagnosis.toString(), @@ -540,6 +619,9 @@ def diagnoseBarometer(data, output): output["passed"] = False def diagnoseGps(data, output): + GPS_MIN_FREQUENCY_HZ = None + GPS_MAX_FREQUENCY_HZ = 100.0 + sensor = data["gps"] timestamps = np.array(sensor["t"]) deltaTimes = np.array(sensor["td"]) From fdbf23a31efa6be44aa49660b2429052322fb07f Mon Sep 17 00:00:00 2001 From: kaatr Date: Thu, 19 Jun 2025 10:06:36 +0300 Subject: [PATCH 18/25] Clean up invalid JSON warning message --- python/cli/diagnose/diagnose.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index 7e5d13b..653f2be 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -4,7 +4,6 @@ import json import pathlib -import sys from .html import generateHtml from .sensors import * @@ -71,7 +70,7 @@ def addMeasurement(type, t, v): try: measurement = json.loads(line) except: - sys.stderr.write('ignoring non JSON line: %s' % line) + print(f"Warning: ignoring non JSON line: {line}") continue time = measurement.get("time") sensor = measurement.get("sensor") From 577130248b66755edf5fbeb9770f71feb1bcbcef Mon Sep 17 00:00:00 2001 From: kaatr Date: Thu, 19 Jun 2025 10:07:20 +0300 Subject: [PATCH 19/25] Check if accelerometer signal has gravity --- python/cli/diagnose/sensors.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 281181f..350b5b9 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -299,9 +299,9 @@ def analyzeSignalUnit( signal, timestamps, correctUnit, + sensorName, minThreshold=None, maxThreshold=None): - if signal.ndim == 1: magnitude = np.abs(signal) else: @@ -314,12 +314,12 @@ def analyzeSignalUnit( if minThreshold is not None and minValue < minThreshold: shouldPlot = True self.issues.append(f"Signal magnitude has values below threshold {minThreshold:.1f}{correctUnit}. " - f"Please check unit is {correctUnit}.") + f"Please verify measurement unit is {correctUnit}.") self.__updateDiagnosis(DiagnosisLevel.ERROR) elif maxThreshold is not None and maxValue > maxThreshold: shouldPlot = True self.issues.append(f"Signal magnitude has values above threshold {maxThreshold:.1f}{correctUnit}. " - f"Please check unit is {correctUnit}.") + f"Please verify measurement unit is {correctUnit}.") self.__updateDiagnosis(DiagnosisLevel.ERROR) if shouldPlot: @@ -337,12 +337,23 @@ def analyzeSignalUnit( self.images.append(plotFrame( x=timestamps, ys=np.array(ys).T, - title="Signal magnitude and thresholds used in unit check", + title=f"{sensorName} signal magnitude", yLabel=f"Magnitude ({correctUnit})", legend=legend, linewidth=2.0, **SIGNAL_PLOT_KWARGS)) + def analyzeAccelerometerSignalHasGravity(self, signal): + ACC_NORM_THRESHOLD = 8.0 # m/s² + magnitude = np.linalg.norm(signal, axis=1) + mean = np.mean(magnitude) + + if mean < ACC_NORM_THRESHOLD: + self.issues.append( + f"Mean accelerometer magnitude {mean:.1f} is below the expected threshold ({ACC_NORM_THRESHOLD:.1f}). " + "This suggests the signal may be missing gravitational acceleration.") + self.__updateDiagnosis(DiagnosisLevel.ERROR) + def getImuTimestamps(data): return data["accelerometer"]["t"] @@ -439,6 +450,7 @@ def diagnoseAccelerometer(data, output): signal, timestamps, "m/s²", + "Accelerometer", maxThreshold=ACC_UNIT_CHECK_THRESHOLD) status.analyzeSignalNoise( signal, @@ -448,6 +460,7 @@ def diagnoseAccelerometer(data, output): ACC_NOISE_THRESHOLD, sensorName="Accelerometer", yLabel="Acceleration (m/s²)") + status.analyzeAccelerometerSignalHasGravity(signal) output["accelerometer"] = { "diagnosis": status.diagnosis.toString(), @@ -503,6 +516,7 @@ def diagnoseGyroscope(data, output): signal, timestamps, "rad/s", + "Gyroscope", maxThreshold=GYRO_UNIT_CHECK_THRESHOLD) output["gyroscope"] = { @@ -550,6 +564,7 @@ def diagnoseMagnetometer(data, output): signal, timestamps, "microteslas (μT)", + "Magnetometer", maxThreshold=MAGN_UNIT_CHECK_THRESHOLD) output["magnetometer"] = { @@ -598,6 +613,7 @@ def diagnoseBarometer(data, output): signal, timestamps, "hPa", + "Barometer", BARO_UNIT_CHECK_MIN_THRESHOLD, BARO_UNIT_CHECK_MAX_THRESHOLD) From 76921f9ac705fec7e721ad3cc9a4bb7c9eece6a0 Mon Sep 17 00:00:00 2001 From: kaatr Date: Thu, 19 Jun 2025 10:27:25 +0300 Subject: [PATCH 20/25] Dont warn about bad delta times with allowDataGaps=True --- python/cli/diagnose/sensors.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/python/cli/diagnose/sensors.py b/python/cli/diagnose/sensors.py index 350b5b9..eeb83dd 100644 --- a/python/cli/diagnose/sensors.py +++ b/python/cli/diagnose/sensors.py @@ -168,7 +168,7 @@ def toPercent(value): self.issues.append(f"Found {dataGaps} ({toPercent(dataGaps)}) pauses longer than {SECONDS_TO_MILLISECONDS*thresholdDataGap:.1f}ms.") self.__updateDiagnosis(DiagnosisLevel.ERROR) - if badDeltaTimes > 0: + if badDeltaTimes > 0 and not allowDataGaps: self.issues.append( f"Found {badDeltaTimes} ({toPercent(badDeltaTimes)}) timestamps that differ from " f"expected delta time ({medianDeltaTime*SECONDS_TO_MILLISECONDS:.1f}ms) " @@ -220,10 +220,9 @@ def toPercent(value): duplicateSamples += 1 prev = v - if duplicateSamples > 0: + if maxDuplicateRatio * total < duplicateSamples: self.issues.append(f"Found {duplicateSamples} ({toPercent(duplicateSamples)}) duplicate samples in the signal.") - if maxDuplicateRatio * total < duplicateSamples: - self.__updateDiagnosis(DiagnosisLevel.WARNING) + self.__updateDiagnosis(DiagnosisLevel.WARNING) def analyzeSignalNoise( self, From 0c0ef278fc6af6fbeed1a797863716090edd1fa9 Mon Sep 17 00:00:00 2001 From: kaatr Date: Thu, 19 Jun 2025 13:58:25 +0300 Subject: [PATCH 21/25] Add colors to issues list --- python/cli/diagnose/diagnose.py | 2 +- python/cli/diagnose/html.py | 17 +++-- python/cli/diagnose/sensors.py | 118 ++++++++++++++++++-------------- 3 files changed, 79 insertions(+), 58 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index 653f2be..9712184 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -70,7 +70,7 @@ def addMeasurement(type, t, v): try: measurement = json.loads(line) except: - print(f"Warning: ignoring non JSON line: {line}") + print(f"Warning: ignoring non JSON line: '{line}'") continue time = measurement.get("time") sensor = measurement.get("sensor") diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py index e38a001..20eb1cc 100644 --- a/python/cli/diagnose/html.py +++ b/python/cli/diagnose/html.py @@ -6,8 +6,9 @@ Spectacular AI dataset diagnose report @@ -61,13 +75,38 @@ def h1(title): return f"

    {title}

    \n" def h2(title): return f"

    {title}

    \n" def p(text, size="16px"): return f'

    {text}

    \n' -def li(text, size="16px"): return f'
  • {text}
  • \n' -def table(pairs): +def summaryTable(pairs): s = '\n' for key, value in pairs: s += '\n' % (key, value) s += "
    %s%s
    \n" return s +def issueTable(issues): + s = '\n' + s += '\n\n\n\n\n\n' + s += '\n' + + for issue in issues: + msg = issue["message"] + severity = issue["diagnosis"] + + if severity == "error": + style = 'font-weight: bold; background-color: #f06262; text-align: center;' + label = "Critical" + elif severity == "warning": + style = 'font-weight: bold; background-color: #fcb88b; text-align: center;' + label = "Warning" + elif severity == "ok": + style = 'font-weight: bold; background-color: #b0e0b0; text-align: center;' + label = "OK" + else: + style = 'text-align: center;' + label = severity.capitalize() + + s += f'\n\n\n\n' + + s += '\n
    IssueSeverity
    {msg}{label}
    \n' + return s def passed(v, large=True): if v: @@ -87,22 +126,16 @@ def status(sensor): diagnosis = sensor["diagnosis"] if diagnosis == "ok": - s = 'Passed' + s = 'Passed\n' elif diagnosis == "warning": - s = 'Warning' + s = 'Warning\n' elif diagnosis == "error": - s = 'Error' + s = 'Error\n' else: raise ValueError(f"Unknown diagnosis: {diagnosis}") if len(sensor["issues"]) > 0: - s += p('Issues') - s += "
      " - for issue in sensor["issues"]: - style = issue["diagnosis"] - msg = issue["message"] - s += li(f'{msg}') - s += "
    " + s += issueTable(sensor["issues"]) return s @@ -146,7 +179,7 @@ def generateHtml(output, outputHtml): output[sensor]["count"] ))) - s += table(kvPairs) + s += summaryTable(kvPairs) if not output["passed"]: s += p("One or more checks below failed.") s += '\n' From 6808c41ba9c9b1e5f33816f29b7941beae8b54fa Mon Sep 17 00:00:00 2001 From: Jerry Ylilammi Date: Thu, 19 Jun 2025 16:06:42 +0300 Subject: [PATCH 25/25] Fix table being inside h2 tag. Fix report generation to current directory --- python/cli/diagnose/diagnose.py | 6 ++++-- python/cli/diagnose/html.py | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/cli/diagnose/diagnose.py b/python/cli/diagnose/diagnose.py index acf73a1..4e44e7d 100644 --- a/python/cli/diagnose/diagnose.py +++ b/python/cli/diagnose/diagnose.py @@ -139,13 +139,15 @@ def addMeasurement(type, t, v): diagnoseCpu(data, output) if args.output_json: - os.makedirs(os.path.dirname(args.output_json), exist_ok=True) + if os.path.dirname(args.output_json): + os.makedirs(os.path.dirname(args.output_json), exist_ok=True) with open(args.output_json, "w") as f: f.write(json.dumps(output, indent=4)) print("Generated JSON report data at:", args.output_json) if args.output_html: - os.makedirs(os.path.dirname(args.output_html), exist_ok=True) + if os.path.dirname(args.output_html): + os.makedirs(os.path.dirname(args.output_html), exist_ok=True) generateHtml(output, args.output_html) print("Generated HTML report at:", args.output_html) diff --git a/python/cli/diagnose/html.py b/python/cli/diagnose/html.py index 948c712..56c0cf9 100644 --- a/python/cli/diagnose/html.py +++ b/python/cli/diagnose/html.py @@ -133,16 +133,14 @@ def status(sensor): s = 'Error\n' else: raise ValueError(f"Unknown diagnosis: {diagnosis}") - - if len(sensor["issues"]) > 0: - s += issueTable(sensor["issues"]) - return s def generateSensor(sensor, name): s = "" s += "
    \n" s += h2("{} {}".format(name, status(sensor))) + if len(sensor["issues"]) > 0: + s += issueTable(sensor["issues"]) for image in sensor["images"]: s += f'Plot\n' s += "
    \n"