Merge remote-tracking branch 'origin/codex/trend-display-scale' into HEAD
This commit is contained in:
@@ -67,6 +67,10 @@ class PlotMath:
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds":
|
||||
"""Validate absolute axis limits in the shared core."""
|
||||
return Bounds(*self.call(8, left, right, bottom, top))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Viewport:
|
||||
@@ -92,6 +96,9 @@ class Bounds:
|
||||
bottom: float
|
||||
top: float
|
||||
|
||||
def validated(self, core: PlotMath) -> "Bounds":
|
||||
return core.limits(self.left, self.right, self.bottom, self.top)
|
||||
|
||||
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
|
||||
return core.call(2, value, self.bottom if horizontal else self.left,
|
||||
self.top if horizontal else self.right, int(horizontal))[0]
|
||||
|
||||
@@ -37,6 +37,12 @@ class Spectrum:
|
||||
return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectrumPeak:
|
||||
frequency_hz: float
|
||||
amplitude: float
|
||||
|
||||
|
||||
class NativeSpectrum:
|
||||
def __init__(self, library: ctypes.CDLL):
|
||||
self.lib = library
|
||||
@@ -46,6 +52,11 @@ class NativeSpectrum:
|
||||
ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int,
|
||||
pointer, ctypes.c_size_t, pointer]
|
||||
self._analyze.restype = ctypes.c_int
|
||||
self._peak = library.set_spectrum_dominant_peak
|
||||
self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double,
|
||||
ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t,
|
||||
pointer, ctypes.c_size_t]
|
||||
self._peak.restype = ctypes.c_int
|
||||
|
||||
def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE,
|
||||
low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum:
|
||||
@@ -69,3 +80,15 @@ class NativeSpectrum:
|
||||
raise ValueError(message.get(status, "FFT failed"))
|
||||
n = int(meta[0])
|
||||
return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1]))
|
||||
|
||||
def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0,
|
||||
absolute_floor: float = 1e-6) -> SpectrumPeak | None:
|
||||
amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes)
|
||||
scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))()
|
||||
output = (ctypes.c_double * 2)()
|
||||
status = self._peak(amplitudes, len(spectrum.amplitudes),
|
||||
spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor,
|
||||
scratch, len(scratch), output, 2)
|
||||
if status < 0:
|
||||
raise ValueError("Invalid spectrum peak input")
|
||||
return SpectrumPeak(output[0], output[1]) if status else None
|
||||
|
||||
@@ -54,6 +54,8 @@ class TrendSignal:
|
||||
deviceType: int = 7
|
||||
device: int = 13
|
||||
byteOffset: int = 0
|
||||
multiplier: float = 1.0
|
||||
iq: int = 0
|
||||
extended: bool = True
|
||||
|
||||
@classmethod
|
||||
@@ -72,9 +74,11 @@ class TrendSignal:
|
||||
for field in ("id", "name", "source", "address", "color", "valueType"):
|
||||
if type(getattr(self, field)) is not str:
|
||||
raise ValueError(f"{field} must be a string")
|
||||
for field in ("order", "deviceType", "device", "byteOffset"):
|
||||
for field in ("order", "deviceType", "device", "byteOffset", "iq"):
|
||||
if type(getattr(self, field)) is not int:
|
||||
raise ValueError(f"{field} must be an integer")
|
||||
if type(self.multiplier) not in (int, float) or not math.isfinite(self.multiplier):
|
||||
raise ValueError("multiplier must be a finite number")
|
||||
if type(self.visible) is not bool or type(self.extended) is not bool:
|
||||
raise ValueError("Visibility and CAN format must be boolean")
|
||||
if not self.id.strip() or len(self.id) > 80 or not 1 <= self.order <= 9999:
|
||||
@@ -98,12 +102,11 @@ class TrendSignal:
|
||||
raise ValueError("Invalid ProtoCAN device")
|
||||
if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6:
|
||||
raise ValueError("Word offset must be 0..6")
|
||||
if not 0 <= self.iq <= 30:
|
||||
raise ValueError("IQ must be 0..30")
|
||||
|
||||
def word_value(self, word: int) -> float:
|
||||
if not 0 <= word <= 65535:
|
||||
raise ValueError("Not a 16-bit word")
|
||||
return float(word - 65536 if self.valueType == "INT16" and word >= 32768 else word)
|
||||
|
||||
def display_value(self, raw_value: float) -> float:
|
||||
return raw_value * self.multiplier / (1 << self.iq)
|
||||
|
||||
def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None:
|
||||
ids = set()
|
||||
@@ -138,14 +141,15 @@ def decode_settings(text: str) -> dict[str, list[TrendSignal]]:
|
||||
raise ValueError("profiles must be an object")
|
||||
result = {}
|
||||
fields = set(TrendSignal.__dataclass_fields__)
|
||||
required_fields = fields - {"multiplier", "iq"}
|
||||
for profile, items in profiles.items():
|
||||
if not isinstance(items, list) or len(items) > MAX_SIGNALS:
|
||||
raise ValueError("Expected up to 64 signals")
|
||||
signals = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or not fields <= item.keys():
|
||||
if not isinstance(item, dict) or not required_fields <= item.keys():
|
||||
raise ValueError("Missing signal fields")
|
||||
signals.append(TrendSignal(**{key: item[key] for key in fields}))
|
||||
signals.append(TrendSignal(**{key: item[key] for key in fields if key in item}))
|
||||
result[profile] = signals
|
||||
validate_settings(result)
|
||||
return result
|
||||
@@ -178,6 +182,20 @@ class NativeTrends:
|
||||
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8,
|
||||
ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t]
|
||||
self.decode.restype = ctypes.c_int32
|
||||
self._word = library.set_trend_word_value
|
||||
self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8]
|
||||
self._word.restype = ctypes.c_int32
|
||||
self._watch_request = library.set_trend_watch_request
|
||||
self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16),
|
||||
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t]
|
||||
self._watch_request.restype = ctypes.c_size_t
|
||||
self._watch_ack = library.set_trend_watch_ack
|
||||
self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t]
|
||||
self._watch_ack.restype = ctypes.c_int
|
||||
self._watch_decode = library.set_trend_watch_decode
|
||||
self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t]
|
||||
self._watch_decode.restype = ctypes.c_int
|
||||
|
||||
def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None:
|
||||
if signal.source not in ("CAN_GAS", "CAN_RAW"):
|
||||
@@ -191,3 +209,33 @@ class NativeTrends:
|
||||
signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags,
|
||||
payload, len(data))
|
||||
return None if value == -2147483648 else float(value)
|
||||
|
||||
def word_value(self, signal: TrendSignal, word: int) -> float:
|
||||
if not 0 <= word <= 65535:
|
||||
raise ValueError("Not a 16-bit word")
|
||||
return float(self._word(word, signal.valueType == "INT16"))
|
||||
|
||||
def watch_request(self, period_ms: int, addresses: list[int]) -> bytes:
|
||||
if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any(
|
||||
type(address) is not int or not 0 <= address <= 65535 for address in addresses):
|
||||
raise ValueError("Invalid GAS watch request")
|
||||
source = (ctypes.c_uint16 * len(addresses))(*addresses)
|
||||
output = (ctypes.c_uint8 * (4 + len(addresses) * 2))()
|
||||
size = self._watch_request(period_ms, source, len(addresses), output, len(output))
|
||||
if not size:
|
||||
raise ValueError("Invalid GAS watch request")
|
||||
return bytes(output[:size])
|
||||
|
||||
def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None:
|
||||
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
|
||||
if not self._watch_ack(data, len(payload), period_ms, count):
|
||||
raise ValueError("Device did not accept the complete GAS subscription")
|
||||
|
||||
def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]:
|
||||
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
|
||||
timestamp = ctypes.c_uint32()
|
||||
words = (ctypes.c_uint16 * MAX_SIGNALS)()
|
||||
count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS)
|
||||
if count < 0 or expected_count is not None and count != expected_count:
|
||||
raise ValueError("Invalid GAS watch data")
|
||||
return timestamp.value, list(words[:count])
|
||||
|
||||
Reference in New Issue
Block a user