163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
"""Модель нейтральных сигналов для шаблона без привязки к ТЗ."""
|
||
|
||
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)
|