Добавить общие графики, декодер KONOR и порт STM32 bxCAN
This commit is contained in:
50
python/tests/test_plot.py
Normal file
50
python/tests/test_plot.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Cross-port contract: Python/ctypes and Kotlin/JNI consume the same fixtures."""
|
||||
import ctypes
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport
|
||||
|
||||
|
||||
class PlotTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.core = PlotMath(ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"]))
|
||||
|
||||
def test_shared_numeric_fixtures(self):
|
||||
path = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/plot-v1.json"
|
||||
for case in json.loads(path.read_text())["cases"]:
|
||||
with self.subTest(case=case["name"]):
|
||||
if case["output"] is None:
|
||||
with self.assertRaises(ValueError):
|
||||
self.core.call(case["op"], *case["input"])
|
||||
else:
|
||||
actual = self.core.call(case["op"], *case["input"])
|
||||
self.assertEqual(len(actual), len(case["output"]))
|
||||
for a, b in zip(actual, case["output"]):
|
||||
self.assertAlmostEqual(a, b, places=10)
|
||||
|
||||
def test_markers_stay_in_data_coordinates_and_cross(self):
|
||||
bounds = Bounds(1000, 2000, -10, 10)
|
||||
markers = Markers().positioned(self.core, bounds)
|
||||
zoomed = bounds.visible(self.core, Viewport(.25, .25, .5, .5))
|
||||
self.assertEqual(markers, markers.positioned(self.core, zoomed))
|
||||
moved = markers.drag(self.core, Marker.A, 50, 500, zoomed)
|
||||
self.assertAlmostEqual(moved.a, markers.a + 50)
|
||||
self.assertEqual(markers.b, moved.b)
|
||||
self.assertEqual(Marker.A, moved.hit(self.core, 0, 50, 500, 100, 20, zoomed))
|
||||
crossed = markers.move(Marker.A, 1900).move(Marker.B, 1100)
|
||||
self.assertEqual(-800, self.core.delta(crossed.a, crossed.b))
|
||||
|
||||
def test_invalid_numeric_inputs_do_not_escape_to_painter(self):
|
||||
for value in (math.nan, math.inf, -math.inf):
|
||||
self.assertEqual(Viewport(), Viewport().transform(self.core, zoom_x=value))
|
||||
with self.assertRaises(ValueError):
|
||||
self.core.pinch_axis(value, 10, 8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
117
python/tests/test_spectrum.py
Normal file
117
python/tests/test_spectrum.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import cmath
|
||||
import ctypes
|
||||
import math
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from protocan.spectrum import Filter, NativeSpectrum, Window
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Set SETPROTOCOL_LIBRARY to the built C library")
|
||||
class SpectrumTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.core = NativeSpectrum(ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"]))
|
||||
|
||||
def sample(self, n=4096, fs=1024, frequencies=(64,), amplitude=1):
|
||||
times = [i / fs for i in range(n)]
|
||||
return times, [amplitude * sum(math.sin(2 * math.pi * f * t) for f in frequencies) for t in times]
|
||||
|
||||
def test_fft_matches_independent_direct_dft(self):
|
||||
times, _ = self.sample(32)
|
||||
values = [math.sin(i * 1.37) + 0.1 * i for i in range(32)]
|
||||
result = self.core.analyze(times, values, window=Window.RECT, remove_mean=False)
|
||||
for k, amplitude in enumerate(result.amplitudes):
|
||||
direct = abs(sum(v * cmath.exp(-2j * math.pi * k * i / 32) for i, v in enumerate(values))) / 32
|
||||
if k not in (0, 16):
|
||||
direct *= 2
|
||||
self.assertAlmostEqual(direct, amplitude, places=11)
|
||||
|
||||
def test_all_windows_preserve_bin_centered_peak_amplitude(self):
|
||||
times, values = self.sample(amplitude=3.25)
|
||||
for window in Window:
|
||||
with self.subTest(window=window):
|
||||
result = self.core.analyze(times, values, window=window)
|
||||
peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__)
|
||||
self.assertEqual(64, result.frequencies[peak])
|
||||
self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9)
|
||||
|
||||
def test_dc_and_nyquist_are_not_doubled(self):
|
||||
times, _ = self.sample()
|
||||
result = self.core.analyze(times, [2.5] * len(times), remove_mean=False, window=Window.RECT)
|
||||
self.assertAlmostEqual(2.5, result.amplitudes[0], places=10)
|
||||
result = self.core.analyze(times, [3 * (-1) ** i for i in range(len(times))], window=Window.RECT)
|
||||
self.assertAlmostEqual(3, result.amplitudes[-1], places=10)
|
||||
result = self.core.analyze(times, [2.5] * len(times))
|
||||
self.assertLess(max(result.amplitudes), 1e-12)
|
||||
|
||||
def test_windows_suppress_far_leakage_and_flattop_recovers_off_bin_amplitude(self):
|
||||
times, values = self.sample(frequencies=(64.13,))
|
||||
rect = self.core.analyze(times, values, window=Window.RECT)
|
||||
hann = self.core.analyze(times, values, window=Window.HANN)
|
||||
flat = self.core.analyze(times, values, window=Window.FLATTOP)
|
||||
self.assertLess(hann.amplitudes[400], rect.amplitudes[400] / 100)
|
||||
self.assertAlmostEqual(1, max(flat.amplitudes), delta=0.002)
|
||||
|
||||
def test_filters_attenuate_expected_bands(self):
|
||||
times, values = self.sample(frequencies=(16, 64, 256))
|
||||
low = self.core.analyze(times, values, filter=Filter.LOW_PASS, high_hz=64)
|
||||
high = self.core.analyze(times, values, filter=Filter.HIGH_PASS, low_hz=64)
|
||||
band = self.core.analyze(times, values, filter=Filter.BAND_PASS, low_hz=32, high_hz=128)
|
||||
notch = self.core.analyze(times, values, filter=Filter.NOTCH, low_hz=64)
|
||||
at = lambda result, hz: result.amplitudes[int(hz / (result.sample_rate / result.size))]
|
||||
self.assertGreater(at(low, 16), 0.99)
|
||||
self.assertLess(at(low, 256), 0.05)
|
||||
self.assertAlmostEqual(1 / math.sqrt(2), at(low, 64), delta=0.001)
|
||||
self.assertLess(at(high, 16), 0.07)
|
||||
self.assertGreater(at(high, 256), 0.99)
|
||||
self.assertGreater(at(band, 64), 0.93)
|
||||
self.assertLess(at(band, 16), 0.25)
|
||||
self.assertLess(at(band, 256), 0.2)
|
||||
self.assertLess(at(notch, 64), 0.02)
|
||||
self.assertGreater(at(notch, 16), 0.99)
|
||||
|
||||
def test_timestamp_rate_not_requested_rate_and_jitter_interpolation(self):
|
||||
n, fs = 1024, 200
|
||||
times = [(i + (0.05 if i % 2 else 0)) / fs for i in range(n)]
|
||||
values = [2 * math.sin(2 * math.pi * (16 * fs / n) * t) for t in times]
|
||||
result = self.core.analyze(times, values)
|
||||
self.assertAlmostEqual((n - 1) / (times[-1] - times[0]), result.sample_rate)
|
||||
self.assertGreater(result.jitter, 0.04)
|
||||
self.assertAlmostEqual(2, result.amplitudes[16], delta=0.005)
|
||||
|
||||
def test_rejects_gaps_duplicates_bad_values_and_cutoffs(self):
|
||||
times, values = self.sample(128)
|
||||
for broken in ([0.0] * 128, times[:64] + [t + 1 for t in times[64:]], list(reversed(times))):
|
||||
with self.assertRaisesRegex(ValueError, "timestamps"):
|
||||
self.core.analyze(broken, values)
|
||||
for value in (float("nan"), float("inf")):
|
||||
with self.assertRaises(ValueError):
|
||||
self.core.analyze(times, values[:-1] + [value])
|
||||
for cutoff in (0, -1, 512, 1000, float("nan")):
|
||||
with self.assertRaisesRegex(ValueError, "Filter frequencies"):
|
||||
self.core.analyze(times, values, filter=Filter.LOW_PASS, high_hz=cutoff)
|
||||
with self.assertRaises(ValueError):
|
||||
self.core.analyze(times, values, filter=Filter.BAND_PASS, low_hz=100, high_hz=50)
|
||||
|
||||
def test_size_limits_tail_selection_and_inputs_unchanged(self):
|
||||
times, values = self.sample(1000)
|
||||
original = values[:]
|
||||
result = self.core.analyze(times, values)
|
||||
self.assertEqual(512, result.size)
|
||||
self.assertEqual(original, values)
|
||||
self.assertEqual(256, self.core.analyze(times, values, max_size=256).size)
|
||||
for n in (0, 1, 15):
|
||||
with self.assertRaisesRegex(ValueError, "16 samples"):
|
||||
self.core.analyze(times[:n], values[:n])
|
||||
for size in (0, 15, 1000, 32768):
|
||||
with self.assertRaises(ValueError):
|
||||
self.core.analyze(times, values, max_size=size)
|
||||
with self.assertRaises(ValueError):
|
||||
self.core.analyze(times[:-1], values)
|
||||
times, values = self.sample(20000)
|
||||
self.assertEqual(16384, self.core.analyze(times, values, max_size=16384).size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
87
python/tests/test_trends.py
Normal file
87
python/tests/test_trends.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from protocan.trends import (
|
||||
MAX_FILE_BYTES, MAX_POINTS, NativeTrends, TrendHistory, TrendSignal,
|
||||
decode_settings, encode_settings, parse_address,
|
||||
)
|
||||
|
||||
FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/trends-v1.json"
|
||||
|
||||
|
||||
class TrendTests(unittest.TestCase):
|
||||
def test_shared_kotlin_fixture_and_round_trip(self):
|
||||
settings = decode_settings(FIXTURE.read_text(encoding="utf-8"))
|
||||
self.assertEqual(5, sum(map(len, settings.values())))
|
||||
self.assertEqual("Ток фазы А", settings["TMS2812"][0].name)
|
||||
self.assertEqual(settings, decode_settings(encode_settings(settings)))
|
||||
self.assertEqual(settings, decode_settings("\ufeff" + encode_settings(settings)))
|
||||
|
||||
def test_profile_specific_defaults(self):
|
||||
self.assertEqual("SET_GAS", TrendSignal.new("SET_V1").source)
|
||||
self.assertEqual("CAN_RAW", TrendSignal.new("BALZAM_CAN").source)
|
||||
self.assertEqual("CAN_GAS", TrendSignal.new("SLCAN").source)
|
||||
|
||||
def test_invalid_import_and_types(self):
|
||||
fixture = FIXTURE.read_text(encoding="utf-8")
|
||||
for field, value in (("order", "1"), ("order", 1.5), ("order", True),
|
||||
("visible", "true"), ("color", "red"),
|
||||
("source", "CAN_GAS"), ("address", "0x100000000")):
|
||||
with self.subTest(field=field, value=value), self.assertRaises(ValueError):
|
||||
data = json.loads(fixture)
|
||||
data["profiles"]["TMS2812"][0][field] = value
|
||||
decode_settings(json.dumps(data))
|
||||
for version in (2, "1", 1.5, True):
|
||||
with self.assertRaises(ValueError):
|
||||
data = json.loads(fixture)
|
||||
data["version"] = version
|
||||
decode_settings(json.dumps(data))
|
||||
with self.assertRaises(ValueError):
|
||||
decode_settings(" " * (MAX_FILE_BYTES + 1))
|
||||
|
||||
def test_duplicates_and_limits(self):
|
||||
signal = TrendSignal("one")
|
||||
for signals in ([signal, replace(signal, id="two")],
|
||||
[signal, replace(signal, order=2)],
|
||||
[replace(signal, id=str(i), order=i + 1) for i in range(65)]):
|
||||
with self.assertRaises(ValueError):
|
||||
encode_settings({"TMS2812": signals})
|
||||
|
||||
def test_addresses_and_signedness(self):
|
||||
self.assertEqual(255, parse_address("0xFF", 255))
|
||||
self.assertEqual(100, parse_address("100", 100))
|
||||
for value in ("-1", "+1", "FF", "0x", "1.0", "256"):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_address(value, 255)
|
||||
self.assertEqual(-2.0, TrendSignal("s", valueType="INT16").word_value(65534))
|
||||
|
||||
def test_bounded_history(self):
|
||||
history = TrendHistory()
|
||||
for i in range(MAX_POINTS + 10):
|
||||
history.append({"a": float(i), "bad": float("nan")}, i)
|
||||
self.assertEqual(MAX_POINTS, len(history.series["a"]))
|
||||
self.assertEqual((10, 10.0), history.series["a"][0])
|
||||
self.assertNotIn("bad", history.series)
|
||||
|
||||
@unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Host DLL not supplied")
|
||||
def test_actual_shared_c_core(self):
|
||||
core = NativeTrends(ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"]))
|
||||
signal = TrendSignal("gas", source="CAN_GAS", address="0x1235", valueType="INT16")
|
||||
frame_id = 0x1FD31234
|
||||
data = bytes([1, 0, 254, 255])
|
||||
self.assertEqual(-2.0, core.can_value(signal, frame_id, 1, data))
|
||||
self.assertEqual(65534.0, core.can_value(replace(signal, valueType="UINT16"), frame_id, 1, data))
|
||||
self.assertIsNone(core.can_value(replace(signal, device=12), frame_id, 1, data))
|
||||
for flag in (2, 4, 8):
|
||||
self.assertIsNone(core.can_value(signal, frame_id, 1 | flag, data))
|
||||
self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data))
|
||||
raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2)
|
||||
self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user