Chronaxis用户手册English
本页目录

chronaxis.execution.history_records

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""定义已解码历史记录与严格JSONL格式,不再次归一化数值。23样本同时保存物理数组值、逻辑ScalarValue和可选原诊断,时间/序号保持整数。4进度记录保留空批、watermark和整个来源EOF;None观测终点不补齐。5编码复用结果层固定白名单标签,不注册动态类或允许任意对象反序列化。6格式只用于新执行层历史,不修改分析计划schema或旧读写格式。7"""89# 标准库冻结模型及严格JSON;共享既有标量验证与固定类型编码。10from dataclasses import dataclass11from math import isfinite12import json13from sys import getsizeof14from ..core.models import SourceRef, SignalSample, require_identifier, require_scalar15from ..sources.csv_spec import CSVSourceRef16from ..sources.can_spec import CANSourceRef17from ..sources.text_can_ref import CANTextSourceRef18from ..core.numeric import ScalarValue19from .record_sample import RecordSample20from ..timebase.conversion import require_int64_ns21from ..core.types import SignalDType, QualityFlag22from ..results.codec import _pack, _unpack, _unique_object, _reject_constant, _estimate23from ..results.errors import ResultFormatError242526@dataclass(frozen=True)27class HistorySample:28    """保存单个已归一化观测;array_value不被用来推断有效性。2930value携带逻辑值、类型、掩码和质量,ENUM正常值是标签文本;array_value保留整数编码。31original_present区分没有原诊断列与该列中的真实None,原诊断允许非有限浮点。32batch_index只标识传输批次,不作为事件身份或绑定身份。33"""3435    batch_index: int36    name: str37    time_ns: int38    sequence: int39    array_value: int | float | bool40    value: ScalarValue41    unit: str42    source_ref: SourceRef43    original_present: bool44    original_value: int | float | bool | str | None4546    def __post_init__(self) -> None:47        """验证物理/逻辑值、身份和原诊断,不做scale或offset转换。4849        参数: 无;读取冻结字段。返回: None。50        异常: TypeError/ValueError/OverflowError为记录矛盾或非法标量。51        """52        # 批次号为非负原生整数,不能用bool或任意精度增长隐藏开销。53        if type(self.batch_index) is not int or not 0 <= self.batch_index < 2 ** 63:54            raise ValueError('历史batch_index需要非负int64')55        if not isinstance(self.value, ScalarValue) or type(self.unit) is not str:56            raise TypeError('历史需要ScalarValue和字符串单位')57        # 核心样本检查时间、别名、原序号、质量与来源;不依赖某一数值后端。58        SignalSample(self.name, self.time_ns, self.value.value, self.value.valid,59                     self.value.quality, self.source_ref, self.sequence)60        expected = {SignalDType.INT64: int, SignalDType.UINT64: int,61                    SignalDType.FLOAT64: float, SignalDType.BOOL: bool, SignalDType.ENUM: int}[self.value.dtype]62        if type(self.array_value) is not expected or (type(self.array_value) is float and not isfinite(self.array_value)):63            raise TypeError('历史物理值必须符合固定数组类型且有限')64        # 数组即使无效也具有合法物理类型;无效占位仍由valid/quality明确解释。65        physical_dtype = SignalDType.INT64 if self.value.dtype is SignalDType.ENUM else self.value.dtype66        ScalarValue(self.array_value, physical_dtype)67        if self.value.valid:68            logical = self.value.labels[self.array_value] if self.value.dtype is SignalDType.ENUM and 0 <= self.array_value < len(self.value.labels) else self.array_value69            if type(logical) is not type(self.value.value) or logical != self.value.value:70                raise ValueError('历史物理值与正常逻辑值矛盾')71        # 原诊断存在性必须独立,不能把None强制解释为缺列。72        if type(self.original_present) is not bool:73            raise TypeError('original_present需要bool')74        require_scalar(self.original_value)75        if not self.original_present and self.original_value is not None:76            raise ValueError('缺原诊断列时不能附带诊断值')777879@dataclass(frozen=True)80class HistoryProgress:81    """保存一批结束时的读取事实,sample_count包含该批所有信号列。"""8283    batch_index: int84    source: str85    session_id: str86    sample_count: int87    watermark_ns: int | None88    source_eof: bool89    observed_end_ns: int | None9091    def __post_init__(self) -> None:92        """校验计数、身份和可未知的观测/进度坐标。9394        参数: 无;读取全部字段。返回: None。95        异常: TypeError/ValueError/OverflowError为记录结构或时间错误。96        """97        # 空批合法,计数不能为负,也不能把bool当成1。98        for count in (self.batch_index, self.sample_count):99            if type(count) is not int or not 0 <= count < 2 ** 63:100                raise ValueError('历史计数需要非负int64')101        require_identifier(self.source, 'history.source')102        require_identifier(self.session_id, 'history.session_id')103        if type(self.source_eof) is not bool:104            raise TypeError('source_eof需要bool')105        for time in (self.watermark_ns, self.observed_end_ns):106            if time is not None:107                require_int64_ns(time)108109110HistoryRecord = HistorySample | HistoryProgress111112113def record_values(record: HistoryRecord) -> tuple:114    """按固定字段顺序提取白名单值,不将dataclass变成动态对象格式。115116    参数: record为两种历史记录之一。返回: 字段tuple。117    异常: TypeError为未支持记录。118    """119    # 类型必须精确,不支持子类扩展隐式改变格式。120    if type(record) is HistorySample:121        # CSV位置使用历史层固定标签,基础来源仍沿用v1;不扩大结果模型注册表。122        reference = record.source_ref123        if type(reference) is CSVSourceRef:124            reference = ('csv_source_ref', SourceRef(reference.session_id, reference.source_name, reference.field),125                         reference.path, reference.line_number, reference.time_text,126                         reference.value_text, reference.time_quantized)127        elif type(reference) is CANSourceRef:128            # CAN v3使用固定原帧位置;不把原yield序号误当文件物理行号。129            reference = ('can_source_ref', SourceRef(reference.session_id, reference.source_name, reference.field),130                         reference.path, reference.profile, reference.channel,131                         reference.signal, reference.frame_id, reference.is_extended, reference.is_fd,132                         reference.yield_sequence, reference.timestamp, reference.time_quantized)133        elif type(reference) is CANTextSourceRef:134            # 新文本引用单列v4,物理行与原时间不可降级成旧浮点秒定位。135            reference = ('text_can_source_ref', SourceRef(reference.session_id, reference.source_name, reference.field),136                         reference.path, reference.parser_version, reference.line_number,137                         reference.input_index, reference.record_sequence, reference.yield_sequence,138                         reference.raw_time, reference.time_quantized)139        return (record.batch_index, record.name, record.time_ns, record.sequence,140                record.array_value, record.value, record.unit, reference,141                record.original_present, record.original_value)142    if type(record) is HistoryProgress:143        return (record.batch_index, record.source, record.session_id, record.sample_count,144                record.watermark_ns, record.source_eof, record.observed_end_ns)145    raise TypeError('未知历史记录类型')146147148# 这些是固定结构的费用,不包含任何当前样本值或来源文本。149# 规则与codec._estimate逐字段相同:tuple每槽32,模型每字段256,原生叶子另加64。150_RECORD_STRUCTURE_BYTES = getsizeof((None,) * 7) + 7 * 32 + 5 * 64151_SCALAR_STRUCTURE_BYTES = 5 * 256 + 2 * 512 + 64 + getsizeof(True) + 64 + getsizeof(())152_SOURCE_STRUCTURE_BYTES = 3 * (256 + 64)153154155def estimate_record_bytes(record: HistoryRecord) -> int:156    """保守估算一行装箱、JSON结构和UTF8副本的临时RAM。157158    参数: record为已检查记录。返回: bytes估算,非全进程RSS。159    异常: 类型或深度错误传播。160    """161    # 系数覆盖Python容器、unicode及编码副本,长文本不按固定小行估计。162    if type(record) is RecordSample:163        value, ref = record.value, record.source_ref164        # 现役普通记录只持基本来源和普通标量;固定布局无需重复dataclass反射。165        # 任何扩展引用、枚举域/原码或非原生字段仍沿通用估算,保持类型及深度错误。166        if (type(value) is ScalarValue and type(ref) is SourceRef167                and type(value.dtype) is SignalDType and type(value.quality) is QualityFlag168                and type(value.valid) is bool and type(value.partial) is bool169                and type(value.labels) is tuple and not value.labels and value.code is None170                and type(value.value) in (int, float, bool, str, type(None))171                and type(record.name) is str and type(record.unit) is str172                and type(record.time_ns) is int and type(record.sequence) is int173                and type(record.enum_code) in (int, type(None))174                and type(ref.session_id) is str and type(ref.source_name) is str and type(ref.field) is str):175            # 文本和整数仍按实际对象大小收费,包括长诊断值与大序号;不缓存整行单价。176            scalar = getsizeof(value) + _SCALAR_STRUCTURE_BYTES + getsizeof(value.value)177            if value.partial:178                scalar += 256 + getsizeof(value.partial) + 64179            source = (getsizeof(ref) + _SOURCE_STRUCTURE_BYTES + getsizeof(ref.session_id)180                      + getsizeof(ref.source_name) + getsizeof(ref.field))181            return (_RECORD_STRUCTURE_BYTES + getsizeof(record.name) + getsizeof(record.time_ns)182                    + getsizeof(record.sequence) + getsizeof(record.unit) + getsizeof(record.enum_code)183                    + scalar + source) * 16 + 4096184        return _estimate((record.name,record.time_ns,record.sequence,record.value,record.unit,record.source_ref,record.enum_code))*16+4096185    return _estimate(record_values(record)) * 16 + 8192186187188def encode_record(record: HistoryRecord, max_bytes: int) -> bytes:189    """编码一条历史JSONL;基础v1、信号CSV v2、旧CAN v3、文本CAN v4。190191    参数: record为typed记录,max_bytes为单行上限。返回: 含换行UTF8 bytes。192    异常: ResultFormatError为超长/不支持结构。193    """194    # 固定record_type与版本,复用封闭模型标签;非有限诊断使用明确标签。195    reference_type = type(record.source_ref) if type(record) is HistorySample else None196    version = (4 if reference_type is CANTextSourceRef else 3 if reference_type is CANSourceRef197               else 2 if reference_type is CSVSourceRef else 1)198    packed = {'history_version': version, 'record_type': 'sample' if type(record) is HistorySample else 'progress',199              'values': _pack(record_values(record))}200    payload = (json.dumps(packed, ensure_ascii=False, allow_nan=False, separators=(',', ':'), sort_keys=True) + '\n').encode('utf-8')201    if len(payload) > max_bytes:202        raise ResultFormatError('历史单行超过有限读取上限')203    return payload204205206def decode_record(payload: bytes, max_bytes: int) -> HistoryRecord:207    """严格重建单行typed记录,拒绝重复键、额外字段和损坏换行。208209    参数: payload为含换行UTF8行,max_bytes为上限。返回: HistoryRecord。210    异常: ResultFormatError并保留原格式/模型异常链。211    """212    # readline由调用侧限长;这里再核对完整边界,不接收最后的半行。213    if len(payload) > max_bytes or not payload.endswith(b'\n'):214        raise ResultFormatError('历史行过长或未完整写入')215    try:216        packed = json.loads(payload, object_pairs_hook=_unique_object, parse_constant=_reject_constant)217        if type(packed) is not dict or set(packed) != {'history_version', 'record_type', 'values'} or type(packed['history_version']) is not int or packed['history_version'] not in (1, 2, 3, 4):218            raise ResultFormatError('历史版本或字段错误')219        values = _unpack(packed['values'])220        cls = {'sample': HistorySample, 'progress': HistoryProgress}.get(packed['record_type'])221        if cls is None or type(values) is not tuple:222            raise ResultFormatError('历史record_type或值容器错误')223        if packed['history_version'] == 2:224            # v2只能是明确CSV样本,未知标签/额外定位字段不能降级成基础来源。225            if cls is not HistorySample or len(values) != 10 or type(values[7]) is not tuple or len(values[7]) != 7 or values[7][0] != 'csv_source_ref' or type(values[7][1]) is not SourceRef:226                raise ResultFormatError('历史v2需要完整CSV固定定位')227            base = values[7][1]228            reference = CSVSourceRef(base.session_id, base.source_name, base.field, *values[7][2:])229            values = values[:7] + (reference,) + values[8:]230        elif packed['history_version'] == 3:231            # 固定tag、长度和基础身份齐全才恢复CAN,禁止退化或混用CSV标签。232            if (cls is not HistorySample or len(values) != 10 or type(values[7]) is not tuple233                    or len(values[7]) != 12 or values[7][0] != 'can_source_ref'234                    or type(values[7][1]) is not SourceRef):235                raise ResultFormatError('历史v3需要完整CAN固定定位')236            base = values[7][1]237            reference = CANSourceRef(base.session_id, base.source_name, base.field, *values[7][2:])238            values = values[:7] + (reference,) + values[8:]239        elif packed['history_version'] == 4:240            # 精确固定字段重建,禁止旧CAN/CSV引用换版本冒充文本物理行。241            if (cls is not HistorySample or len(values) != 10 or type(values[7]) is not tuple242                    or len(values[7]) != 10 or values[7][0] != 'text_can_source_ref'243                    or type(values[7][1]) is not SourceRef):244                raise ResultFormatError('历史v4需要完整文本CAN定位')245            base = values[7][1]246            reference = CANTextSourceRef(base.session_id, base.source_name, base.field, *values[7][2:])247            values = values[:7] + (reference,) + values[8:]248        elif cls is HistorySample and len(values) == 10 and type(values[7]) in (CANSourceRef,CANTextSourceRef):249            # 结果模型注册不能为历史v1另开CAN入口;CAN仅接受上述v3固定定位。250            raise ResultFormatError('CAN历史定位必须使用对应v3/v4固定格式')251        return cls(*values)252    except (TypeError, ValueError, OverflowError, UnicodeError, KeyError, IndexError) as exc:253        # 不把坏行当空批跳过,保留具体解析原因。254        raise ResultFormatError('历史记录结构/模型非法') from exc255
Chronaxis 用户手册 · 离线阅读