"""Модель нейтральных сигналов для шаблона без привязки к ТЗ.""" from __future__ import annotations from dataclasses import dataclass, field from math import sin @dataclass(frozen=True) class BitDefinition: """@brief Описание битового индикатора или управляющей дискреты. @param key Стабильный ключ обмена, не зависящий от подписи в UI. @param title Отображаемое имя сигнала. @param writable Разрешено ли пользователю изменять значение. @param alarm Активное состояние отображается аварийным цветом. """ key: str title: str writable: bool = False alarm: bool = False @dataclass(frozen=True) class NumericDefinition: """@brief Метаданные цифрового поля или измерительного индикатора. Границы и точность принадлежат catalog, поэтому UI не содержит сведений о конкретной карте объектов контроллера. """ key: str title: str unit: str = "" minimum: float = 0.0 maximum: float = 100.0 decimals: int = 1 writable: bool = False @dataclass(frozen=True) class SignalCatalog: """@brief Полный неизменяемый набор отображаемых сигналов. Catalog является переносимой границей между данными устройства и Qt UI. Для реального изделия заменяются определения, а не код вкладок. """ controller_fields: tuple[NumericDefinition, ...] indicators: tuple[NumericDefinition, ...] status_bits: tuple[BitDefinition, ...] input_discretes: tuple[BitDefinition, ...] output_discretes: tuple[BitDefinition, ...] @classmethod def template(cls) -> "SignalCatalog": """@brief Создаёт согласованный catalog с нейтральными именами. @return Шаблон для обсуждения компоновки без зависимости от финального ТЗ. """ return cls( controller_fields=tuple( NumericDefinition( key=f"controller_{index:02d}", title=f"Параметр {index:02d}", minimum=-9999, maximum=9999, decimals=0, writable=True, ) for index in range(1, 9) ), indicators=tuple( NumericDefinition( key=f"indicator_{index:02d}", title=f"Индикатор {index:02d}", unit=("В" if index <= 4 else "%"), maximum=(400.0 if index <= 4 else 100.0), ) for index in range(1, 9) ), status_bits=tuple( BitDefinition( key=f"status_{index:02d}", title=f"Статус {index:02d}", alarm=index in (7, 8), ) for index in range(1, 9) ), input_discretes=tuple( BitDefinition( key=f"input_{index:02d}", title=f"Вход {index:02d}", ) for index in range(1, 17) ), output_discretes=tuple( BitDefinition( key=f"output_{index:02d}", title=f"Выход {index:02d}", writable=True, ) for index in range(1, 17) ), ) @dataclass class SignalSnapshot: """@brief Атомарный снимок значений от одного момента времени. Словари заменяются целиком при публикации, чтобы вкладки не видели смесь данных от двух циклов опроса. """ timestamp_s: float = 0.0 numeric: dict[str, float] = field(default_factory=dict) bits: dict[str, bool] = field(default_factory=dict) class MockSignalSource: """@brief Детерминированный генератор данных без контроллера. Не зависит от Qt и используется как переносимое ядро в host-тестах. """ def __init__(self, catalog: SignalCatalog) -> None: self._catalog = catalog self._outputs = {item.key: False for item in catalog.output_discretes} def set_output(self, key: str, value: bool) -> None: """@brief Сохраняет состояние существующего template-выхода. @param key Ключ из ``SignalCatalog.output_discretes``. @param value Новое логическое состояние. @raises KeyError Если ключ отсутствует в разрешённом наборе. """ if key not in self._outputs: raise KeyError(f"Неизвестная управляющая дискрета: {key}") self._outputs[key] = bool(value) def snapshot(self, timestamp_s: float) -> SignalSnapshot: """@brief Генерирует согласованный снимок для заданного времени. @param timestamp_s Монотонное время от запуска mock в секундах. @return Новый независимый ``SignalSnapshot``. """ numeric: dict[str, float] = {} bits: dict[str, bool] = {} for index, item in enumerate(self._catalog.controller_fields): numeric[item.key] = float((index + 1) * 10) for index, item in enumerate(self._catalog.indicators): center = item.maximum * 0.55 amplitude = item.maximum * 0.18 numeric[item.key] = center + amplitude * sin(timestamp_s * 0.7 + index) for index, item in enumerate(self._catalog.status_bits): bits[item.key] = int(timestamp_s + index) % (index + 2) == 0 for index, item in enumerate(self._catalog.input_discretes): bits[item.key] = int(timestamp_s * 2 + index) % 5 < 2 bits.update(self._outputs) return SignalSnapshot(timestamp_s=timestamp_s, numeric=numeric, bits=bits)