Chronaxis用户手册English
本页目录

chronaxis.results.manifest

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""定义结果清单中的运行事实和已发布输出身份。23输入为执行侧明确提供的时间/身份/后端,输出为冻结元数据模型。4不会推断CAN、DBC或同步关系;软件/Python版本、资源统计和输出由结果层补齐。5调用者须提供真实信息,模型校验不能证明真实日志已经验收。6"""78# 清单字段冻结;带时区时间及int64会话边界与核心协议一致。9from dataclasses import dataclass, asdict, field, fields10from datetime import datetime11from math import isfinite, isnan12from pathlib import Path13from sys import getsizeof14from .._version import software_version15from ..core.validation import require_text, require_identifier16from ..core.models import Interval17from ..core.binding import SignalDefinition18from ..core.types import QualityFlag19from .binding_metadata import PresetBindingMetadata, PresetRoleMetadata20from .can_metadata import CANSourceMetadata, CANUnitCheck, CANFrameFormatNotice21from .text_can_metadata import TextCANSourceMetadata22from .threshold_metadata import ThresholdDurationMetadata,SummaryThresholdDuration23from .input_diagnostics import InputDiagnostic24from .analysis_outcome import AnalysisOutcome, RuleOutcome25from ..legacy.records import LegacyCANField262728@dataclass(frozen=True)29class RunMetadata:30    """保存运行身份、实际后端、时间解释、会话边界与兼容限制。3132    started_at须含时区;session_boundaries为(session_id,start_ns,end_ns)tuple。33    映射/DBC/依赖事实未涉及时使用空tuple,不能把空值当成验证通过。34    字段只保存明确输入;新层资源/输出/结束时间由实际执行结果补充。35    signal_bindings保本次实际定义,preset_bindings保最终角色;可选语义仍是声明,36    角色契约通过也不表示物理事实已测量。can_sources保存同次真实CAN读取/暂存/37    单位核对的结构化事实;text_can_sources保存新文本解析/DBC/观测事实。38    未提供的详细事实不作为当前运行的验证证明。39    """4041    run_id: str42    input_identity: str | None43    plan_hash: str44    backend: str45    config_version: int46    source_time_interpretation: str47    session_boundaries: tuple[tuple[str, int, int], ...]48    started_at: str49    mapping_hashes: tuple[str, ...] = ()50    dbc_hashes: tuple[str, ...] = ()51    parsing_policy: str = 'decoded-results'52    worker_config: str = 'single-thread'53    dependency_versions: tuple[tuple[str, str], ...] = ()54    limitations: tuple[str, ...] = ()55    software_version: str = field(default_factory=software_version)  # 构造本次元数据时读取版本;显式历史值保持。56    device_label: str = 'pending'57    signal_bindings: tuple[SignalDefinition, ...] = ()  # 本次实际完整定义,不含样本或输入行。58    preset_bindings: tuple[PresetBindingMetadata, ...] = ()  # 原预设版本及最终角色。59    can_sources: tuple[CANSourceMetadata, ...] = ()  # 同次实际CAN read事实,不含样本或伪造DBC哈希。60    session_ids: tuple[str, ...] = ()  # 无时间请求仍明确会话身份,绝不伪造边界。61    text_can_sources: tuple[TextCANSourceMetadata,...] = ()  # 新文本入口同次DBC/解析/观测事实。62    threshold_durations: tuple[SummaryThresholdDuration, ...] = ()  # 仅显式阈值预设的同次状态累计。63    detailed_diagnostics: bool = False  # 按显式请求输出额外质量明细和摘要。64    retain_work: bool = False  # 显式现场留存请求,不默认保留工作文件。65    input_diagnostics: tuple[InputDiagnostic,...] = ()  # 仅按请求记录同次所需输入范围。66    trace_requested: bool = False  # input_trace表独立于贡献和哈希。67    integrity_diagnostics: bool = False  # 摘要独立于逐条追溯。68    frame_format_notices: tuple[tuple[str, CANFrameFormatNotice], ...] = ()  # 同次已核差异始终提示,独立于可选诊断。6970    analysis_outcome: AnalysisOutcome | None = None  # 同次推进的轻量业务结果概况,非逐条追溯。71    aggregation_definitions: tuple = ()  # 本次组级总体、单位及排除政策,每项只登记一次。7273    def __post_init__(self) -> None:74        """验证清单文本与明确会话范围,不访问外部输入。7576        参数: 无。返回: None。异常: TypeError/ValueError为结构或事实格式错误。77        """78        if self.analysis_outcome is not None and type(self.analysis_outcome) is not AnalysisOutcome:79            raise TypeError('分析结果概况需要AnalysisOutcome或None')80        if self.aggregation_definitions:81            from ..planning.aggregations import AggregationPlan82            if type(self.aggregation_definitions) is not tuple or any(type(a) is not AggregationPlan for a in self.aggregation_definitions):83                raise TypeError('聚合清单需要已冻结的聚合定义')84        # 必填文本不能用空字符串表示已经取得的运行身份。85        for name in ('run_id', 'plan_hash', 'source_time_interpretation',86                     'parsing_policy', 'worker_config', 'software_version', 'device_label'):87            require_text(getattr(self, name), name)88        if self.backend not in ('reference', 'numpy') or type(self.config_version) is not int or self.config_version != 4:89            raise ValueError('结果清单需实际reference/numpy及配置版本4')90        if (self.input_identity is not None):91            require_text(self.input_identity, 'input_identity')92        if type(self.detailed_diagnostics) is not bool or type(self.retain_work) is not bool:93            raise TypeError('诊断/现场保留需要bool')94        if type(self.trace_requested) is not bool or type(self.integrity_diagnostics) is not bool:95            raise TypeError('输入诊断开关需要bool')96        if (type(self.input_diagnostics) is not tuple97                or any(type(item) is not InputDiagnostic for item in self.input_diagnostics)):98            raise TypeError('输入诊断需InputDiagnostic tuple')99        if self.input_diagnostics and not (self.trace_requested or self.integrity_diagnostics):100            raise ValueError('未请求不能附输入诊断')101        if (type(self.frame_format_notices) is not tuple or any(type(pair) is not tuple or len(pair) != 2102                or type(pair[1]) is not CANFrameFormatNotice for pair in self.frame_format_notices)):103            raise TypeError('帧型提示需要来源与CANFrameFormatNotice的tuple')104        for source, notice in self.frame_format_notices:105            require_identifier(source, 'frame_format.source')106        if len({(source, notice.alias) for source, notice in self.frame_format_notices}) != len(self.frame_format_notices):107            raise ValueError('同来源信号的帧型提示不能重复')108        require_text(self.started_at, 'started_at')109        if datetime.fromisoformat(self.started_at).utcoffset() is None:110            raise ValueError('started_at必须带时区')111        # 会话边界只验证半开范围,空会话允许start=end;不自动联合或平移。112        if type(self.session_boundaries) is not tuple:113            raise TypeError('session_boundaries需tuple')114        for boundary in self.session_boundaries:115            if type(boundary) is not tuple or len(boundary) != 3:116                raise TypeError('会话边界需(session_id,start,end)')117            require_identifier(boundary[0], 'session_id')118            # 清单直接JSON序列化,固定原生整数,bool及NumPy容器不能偷偷换类型。119            if type(boundary[1]) is not int or type(boundary[2]) is not int:120                raise TypeError('清单会话边界需原生int纳秒')121            Interval(boundary[1], boundary[2])122        for values in (self.mapping_hashes, self.dbc_hashes, self.limitations):123            if type(values) is not tuple or any(type(value) is not str or not value for value in values):124                raise TypeError('清单映射/限制需要非空文本tuple')125        if type(self.dependency_versions) is not tuple or any(type(item) is not tuple or len(item) != 2126                or any(type(value) is not str or not value for value in item) for item in self.dependency_versions):127            raise TypeError('依赖版本需(name,version)tuple')128129        # 新快照只接不可变定义和有限角色记录,拒绝自由字典或原样本模型。130        if (type(self.signal_bindings) is not tuple131                or any(type(item) is not SignalDefinition for item in self.signal_bindings)132                or type(self.preset_bindings) is not tuple133                or any(type(item) is not PresetBindingMetadata for item in self.preset_bindings)):134            raise TypeError('运行绑定清单需SignalDefinition及PresetBindingMetadata tuple')135        names = {item.name for item in self.signal_bindings}136        if len(names) != len(self.signal_bindings):137            raise ValueError('运行绑定清单不能重复别名')138        if len({item.preset_id for item in self.preset_bindings}) != len(self.preset_bindings):139            raise ValueError('运行绑定清单不能重复预设ID')140        if any(role.alias not in names for preset in self.preset_bindings for role in preset.roles):141            raise ValueError('预设最终角色必须引用本清单实际信号别名')142        if (type(self.threshold_durations) is not tuple143                or any(type(item) is not (SummaryThresholdDuration)144                       for item in self.threshold_durations)):145            raise TypeError('阈值汇总需ThresholdDurationMetadata tuple')146        preset_ids = {item.preset_id for item in self.preset_bindings if item.preset_type == 'threshold_excursion'}147        if (len({item.preset_id for item in self.threshold_durations}) != len(self.threshold_durations)148                or any((item.rule_id != item.preset_id + '_activity')149                       for item in self.threshold_durations)):150            raise ValueError('阈值汇总必须对应本次预设及生成规则,不能重复')151        if type(self.session_ids) is not tuple or len(set(self.session_ids)) != len(self.session_ids):152            raise TypeError('独立会话身份需要无重复tuple')153        for session_id in self.session_ids:154            require_identifier(session_id, 'session_id')155        # CAN仅接受登记冻结模型;会话、来源和单位事实必须关联本次原绑定,不能借旧任务别名。156        if type(self.text_can_sources) is not tuple or any(type(item) is not TextCANSourceMetadata for item in self.text_can_sources):157            raise TypeError('文本CAN清单需要TextCANSourceMetadata tuple')158        if len({item.source for item in self.text_can_sources})!=len(self.text_can_sources):159            raise ValueError('文本CAN清单来源重复')160        for source in self.text_can_sources:161            if source.session_id not in ({b[0] for b in self.session_boundaries}|set(self.session_ids)):162                raise ValueError('文本CAN清单会话错误')163            expected={s.name:s.input_unit for s in self.signal_bindings if s.source==source.source}164            if {c.alias:c.input_unit for c in source.unit_checks}!=expected or len(source.unit_checks)!=len(expected):165                raise ValueError('文本CAN单位清单必须覆盖实际绑定')166        if type(self.can_sources) is not tuple or any(type(item) is not CANSourceMetadata for item in self.can_sources):167            raise TypeError('CAN运行清单需要CANSourceMetadata tuple')168        if len({item.source for item in self.can_sources}) != len(self.can_sources):169            raise ValueError('CAN运行清单来源重复')170        # 空默认不为旧运行再构造来源/单位索引,实际CAN存在时才核本次对应关系。171        if self.can_sources:172            sessions = {boundary[0] for boundary in self.session_boundaries} | set(self.session_ids)173            definitions = {item.name: item for item in self.signal_bindings}174            for source in self.can_sources:175                if source.session_id not in sessions:176                    raise ValueError('CAN运行清单会话不属于本次session_boundaries')177                expected = {name for name, definition in definitions.items() if definition.source == source.source}178                if {check.alias for check in source.unit_checks} != expected:179                    raise ValueError('CAN单位清单必须恰好覆盖同来源本次实际绑定别名')180                for check in source.unit_checks:181                    definition = definitions.get(check.alias)182                    if (definition is None or definition.source != source.source183                            or definition.input_unit != check.input_unit):184                        raise ValueError('CAN单位核对别名/来源/input_unit与本次原声明不同')185                    if source.decode_scope == 'requested':186                        # requested可服务普通/混合/导出计划;只核实际需求,不由结果模式猜范围。187                        # 仍要求原完整CAN定位和通道吻合,固定ID必须覆盖每个实际所需字段。188                        # 实际需要CAN字段才导入来源包,保持纯预算/旧默认元数据的轻量导入。189                        from ..sources.can_spec import can_field_from_key190                        selected_field = can_field_from_key(definition.field)191                        if (selected_field.channel != source.channel192                                or selected_field.frame_id not in source.decode_frame_ids):193                            raise ValueError('requested固定解码集合须覆盖本次实际绑定字段及同来源通道')194                for notice in source.frame_format_notices:195                    # 提示必须满足本次信号定位;省略的帧型允许由同次实际读取补齐。196                    from ..sources.can_spec import can_field_from_key197                    definition = definitions.get(notice.alias)198                    field = None if definition is None else can_field_from_key(definition.field)199                    if (definition is None or definition.source != source.source200                            or (field.channel,field.frame_id,field.signal)!=(notice.channel,notice.frame_id,notice.signal)201                            or field.is_extended is not None and field.is_extended!=notice.is_extended202                            or field.is_fd is not None and field.is_fd!=notice.requested_is_fd):203                        raise ValueError('CAN帧型提示必须对应本次原信号完整定位')204205    def estimated_bytes(self) -> int:206        """在asdict/JSON复制前估算元数据与其临时对象开销。207208        参数: 无。返回: 保守bytes估算。异常: 无,类型已经校验。209        """210        # 文本/tuple结构只读遍历,转换副本成本以16倍和固定余量保守覆盖。211        # 空默认新字段不增加旧金额;非空定义/角色/CAN事实在asdict复制前递归覆盖。212        return 8192 + 16 * sum(_metadata_size(getattr(self, field.name)) + 256 for field in fields(self)213            if field.name not in ('signal_bindings', 'preset_bindings', 'can_sources', 'session_ids','text_can_sources','threshold_durations','detailed_diagnostics','retain_work','input_diagnostics','trace_requested','integrity_diagnostics','frame_format_notices','analysis_outcome','aggregation_definitions')214            or getattr(self, field.name))215216    def to_dict(self) -> dict:217        """返回独立JSON兼容元数据,调用侧必须先预留estimated_bytes。218219        参数: 无。220        返回: 独立JSON兼容dict。detailed_diagnostics=False时仅输出运行身份、221            后端、配置版本、时间解释、运行设置及适用的边界/限制/请求诊断;222            signal_bindings、preset_bindings、can_sources等即使非空也不输出。223            detailed_diagnostics=True才展开详细模型;空的可选明细继续按分支省略。224            模型持有字段不等于默认序列化一定包含该键。225        异常: 无预期异常;调用方须在复制前承担estimated_bytes预算。226        """227        if (not self.detailed_diagnostics):228            # 从生成处只复制必要运行信息,不先展开完整绑定/来源后再隐藏。229            result = {name: getattr(self, name) for name in ('run_id', 'plan_hash', 'backend',230                'config_version', 'started_at', 'device_label', 'software_version',231                'source_time_interpretation', 'parsing_policy', 'worker_config', 'retain_work', 'detailed_diagnostics')}232            for name in ('input_identity', 'session_boundaries', 'session_ids', 'limitations', 'dependency_versions'):233                if getattr(self, name):234                    result[name] = getattr(self, name)235            if self.threshold_durations:236                result['threshold_durations']=tuple(item.to_dict() for item in self.threshold_durations)237            if self.trace_requested or self.integrity_diagnostics:238                result['diagnostics']={'trace':self.trace_requested,'integrity':self.integrity_diagnostics}239                result['input_diagnostics']=[item.to_dict() for item in self.input_diagnostics]240            if self.frame_format_notices:241                result['frame_format_notices']=[dict(source=source, **asdict(notice)) for source, notice in self.frame_format_notices]242            if self.analysis_outcome is not None:243                result['analysis_outcome']=self.analysis_outcome.to_dict()244            if self.aggregation_definitions:245                result['aggregation_definitions']=[asdict(a) for a in self.aggregation_definitions]246            return result247        result = asdict(self)248        if not self.aggregation_definitions:249            result.pop('aggregation_definitions')250        if self.analysis_outcome is None:251            result.pop('analysis_outcome')252        else:253            result['analysis_outcome']=self.analysis_outcome.to_dict()254        if self.frame_format_notices:255            result['frame_format_notices']=[dict(source=source, **asdict(notice)) for source, notice in self.frame_format_notices]256        else:257            result.pop('frame_format_notices')258        if self.input_identity is None:259            result.pop('input_identity')  # 未计算的全量输入摘要不以占位文字冒充。260        if self.threshold_durations:261            result['threshold_durations'] = tuple(item.to_dict() for item in self.threshold_durations)262        else:263            result.pop('threshold_durations')264        if not self.text_can_sources:265            result.pop('text_can_sources')266        if not self.session_ids:267            result.pop('session_ids')268        # 只有实际存在的详细事实才展开对应字段。269        if self.signal_bindings:270            for row, definition in zip(result['signal_bindings'], self.signal_bindings):271                # dtype转明确文本;无效值里的非有限诊断用既有显式标签,不输出裸NaN。272                row['dtype'] = definition.dtype.value273                row['invalid_values'] = tuple({'diagnostic_float': 'nan' if isnan(value) else274                    'inf' if value > 0 else '-inf'} if type(value) is float and not isfinite(value) else value275                    for value in definition.invalid_values)276                row['semantic_status'] = 'not_provided' if definition.semantic_id is None else 'declared'277                # 原基础策略拒绝全部非零质量位;声明名称不代表物理语义已核实。278                row['quality_policy'] = {'mode': 'reject_nonzero',279                    'forbidden_flags': tuple(flag.name for flag in QualityFlag),280                    'forbidden_mask': sum(int(flag) for flag in QualityFlag)}281        else:282            result.pop('signal_bindings')283        if not self.preset_bindings:284            result.pop('preset_bindings')285        if not self.can_sources:286            result.pop('can_sources')287        else:288            # 仅实际差异增加提示;原无差异CAN清单的键和值完全保持。289            for row, source in zip(result['can_sources'], self.can_sources):290                if not source.frame_format_notices:291                    row.pop('frame_format_notices')292                if source.input_check == 'full' and source.check_policy_version is None:293                    for key in ('input_check', 'time_scope', 'check_policy_version', 'retained_record_count',294                                'full_check_count', 'selected_first_ns', 'selected_last_ns'):295                        row.pop(key)296                if source.decode_scope == 'all':297                    # 新默认字段既不改变旧JSON,也不增加旧估算费用。298                    row.pop('decode_scope')299                    row.pop('decode_frame_ids')300                if source.decoded_values_policy == 'all_values-v1':301                    # 兼容旧all与旧requested全字段事实,默认值不改变清单键或费用。302                    row.pop('decoded_values_policy')303        return result304305306def _metadata_size(value) -> int:307    """统计已校验元数据树的基础对象大小,不产生转换树。308309    参数: value为元数据字段。返回: 基础bytes估算。异常: 无。310    """311    # 仅遍历已验证的有限模型,不泛化为raw_config或任意dataclass序列化。312    from ..planning.aggregations import AggregationPlan313    if type(value) is AggregationPlan:314        return getsizeof(value)+sum(_metadata_size(getattr(value,key)) for key in value.__dataclass_fields__)315    if type(value) in (SignalDefinition, PresetBindingMetadata, PresetRoleMetadata,316                      CANSourceMetadata, CANUnitCheck, CANFrameFormatNotice,TextCANSourceMetadata,InputDiagnostic,317                      AnalysisOutcome, RuleOutcome):318        return getsizeof(value) + sum(_metadata_size(getattr(value, field.name)) + 32 for field in fields(value)319            if type(value) is not CANSourceMetadata or (320                (field.name != 'frame_format_notices' or value.frame_format_notices)321                and (field.name not in ('decode_scope', 'decode_frame_ids') or value.decode_scope != 'all')322                and (field.name != 'decoded_values_policy' or value.decoded_values_policy != 'all_values-v1')323                and (field.name not in ('input_check', 'time_scope', 'check_policy_version',324                    'retained_record_count', 'full_check_count', 'selected_first_ns', 'selected_last_ns')325                    or value.check_policy_version is not None or value.input_check != 'full')))326327    if type(value) is tuple:328        return getsizeof(value) + sum(_metadata_size(item) + 32 for item in value)329    return getsizeof(value)330331332@dataclass(frozen=True)333class OutputManifest:334    """保存成功发布目录、清单和固定白名单表行数;legacy只在启用时出现。"""335336    run_id: str337    output_dir: Path338    manifest_path: Path339    output_files: tuple[Path, ...]340    row_counts: tuple[tuple[str, int], ...]341    status: str = 'completed'342343    def __post_init__(self) -> None:344        """校验返回清单身份,不凭构造调用证明目录已发布。345346        参数: 无。返回: None。异常: TypeError/ValueError为字段错误。347        """348        require_text(self.run_id, 'run_id')349        if self.status != 'completed' or not isinstance(self.output_dir, Path) or not isinstance(self.manifest_path, Path):350            raise ValueError('OutputManifest需completed和Path')351        if type(self.output_files) is not tuple or any(not isinstance(path, Path) for path in self.output_files):352            raise TypeError('输出路径需tuple Path')353        if type(self.row_counts) is not tuple or any(type(item) is not tuple or len(item) != 2354                or item[0] not in ('events', 'segments', 'calculations', 'message_calculations', 'legacy_contributions', 'sequences','contributions','input_trace','selections','aggregations')355                or type(item[1]) is not int or item[1] < 0 for item in self.row_counts):356            raise ValueError('输出行数需固定表及非负原生int')357358359@dataclass(frozen=True)360class ExportManifest:361    """独立信号或原帧导出的实际输出路径;调用者无需猜测时间文件名。"""362363    output_dir: Path364    manifest_path: Path365    output_files: tuple[Path, ...]366367    def __post_init__(self):368        """核对实际路径类型和所属目录。参数: 无。返回: None。异常: 类型或路径错,不访问文件。"""369        if not isinstance(self.output_dir, Path) or not isinstance(self.manifest_path, Path):370            raise TypeError('导出返回目录和清单必须为Path')371        if type(self.output_files) is not tuple or any(not isinstance(p, Path) for p in self.output_files):372            raise TypeError('导出文件必须为Path tuple')373        if any(p.parent != self.output_dir for p in (self.manifest_path, *self.output_files)):374            raise ValueError('导出返回路径必须属于本次输出根')375
Chronaxis 用户手册 · 离线阅读