Chronaxis用户手册English
本页目录

chronaxis.results.frame_exports

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""把明确范围内的原帧写成新ASC或复用旧封装的BLF,同时保精确逐帧索引。23输入来自同次读取的新暂存,包含未参与分析和DBC未知的帧;不重新读日志/解码。4ASC时间用整数纳秒文本;BLF沿现有writer及float秒接口,其精度和错误帧能力限制5在清单记录,精确源时间和全部原字段保存在frames.jsonl。筛选使用分析坐标,6写出仍保原坐标,只有显式output_offset_ns才移动输出时间。关闭后独占发布目录。7"""89from dataclasses import dataclass, replace10from datetime import date, datetime, timedelta, timezone11from fractions import Fraction12import json13from math import isfinite1415from ..core.models import Interval16from ..core.validation import require_identifier, require_text17from ..execution.history import _metadata_bytes18from ..legacy.records import LegacyRawFrame, StoredRawFrame, frame_to_dict19from ..legacy.export_write import LegacyCANProcessWrite20from ..sources.raw_can import RawCANRecord21from ..sources.text_can_decode import DecodedCANRecord22from ..timebase.conversion import require_int64_ns, checked_add_ns, convert_time23from .budget import _reserve_owned_ram, _release_owned_ram, _cleanup_owner24from .export_files import ExportFiles252627@dataclass(frozen=True)28class FrameExportSpec:29    """选择目标格式及明确的输出时间平移;范围仍使用分析坐标,不随平移变化。"""3031    format: str = 'asc'32    output_offset_ns: int = 033    source_time_reference: str = 'relative'34    capture_date: str | None = None35    timezone_offset_minutes: int | None = None3637    def __post_init__(self):38        """核格式与时间。参数: 无。返回: None。异常: 不支持格式或非法偏移。39        """40        if self.format not in ('asc','blf') or type(self.output_offset_ns) is not int:41            raise ValueError('原帧导出需要asc/blf和整数偏移')42        require_int64_ns(self.output_offset_ns,'frame_export.output_offset_ns')43        # 时间基准是声明事实,不能用偏移或大数值绕过用户要求的真实日期。44        if self.source_time_reference not in ('relative','epoch'):45            raise ValueError('必须明确源时间为relative或epoch,不能按数字大小猜日期')46        if (self.capture_date is None)!=(self.timezone_offset_minutes is None):47            raise ValueError('采集日期和时区必须一起填写')48        if self.capture_date is not None:49            if (type(self.capture_date) is not str or len(self.capture_date)!=1050                    or date.fromisoformat(self.capture_date).isoformat()!=self.capture_date51                    or type(self.timezone_offset_minutes) is not int52                    or not -1439<=self.timezone_offset_minutes<=1439):53                raise ValueError('需要YYYY-MM-DD实际采集日期及合法整数时区分钟')54            if self.source_time_reference=='epoch':55                raise ValueError('已有绝对时间不能重复叠加采集日期')56        if self.format=='blf' and self.source_time_reference=='relative' and self.capture_date is None:57            raise ValueError('BLF导出前必须填写实际采集日期和时区')5859    def date_origin_ns(self):60        """把日内零点映射到实际当地午夜。参数: 无。返回: 精确UTC纳秒或0。异常: int64溢出。61        """62        if self.capture_date is None:63            return 064        # 用整数日/秒换算;机器本地时区及浮点timestamp都不参与日期映射。65        midnight=datetime.combine(date.fromisoformat(self.capture_date),datetime.min.time(),66            timezone(timedelta(minutes=self.timezone_offset_minutes)))67        delta=midnight-datetime(1970,1,1,tzinfo=timezone.utc)68        value=(delta.days*86400+delta.seconds)*10**969        require_int64_ns(value,'frame_export.date_origin_ns')70        return value717273@dataclass(frozen=True)74class ExportCANFrame:75    """统一真实/生成记录;保分析坐标、原输出坐标、完整原帧和有限原始定位。"""7677    sequence: int78    time_ns: int79    output_time_ns: int80    frame: LegacyRawFrame81    provenance: tuple[tuple[str,str | int | float | bool | None],...]82    frame_origin: str = 'passthrough'83    remote_dlc_known: bool = True  # 远程帧的长度是否由原记录明确给出;未知不能写成零。8485    def __post_init__(self):86        """核静态字段。参数: 无。返回: None。异常: 类型、范围、重复定位或非有限值。87        """88        if type(self.sequence) is not int or self.sequence<0 or type(self.frame) is not LegacyRawFrame:89            raise TypeError('导出帧需要非负原序及LegacyRawFrame')90        if type(self.remote_dlc_known) is not bool or not self.remote_dlc_known and not self.frame.is_remote_frame:91            raise ValueError('只有远程帧可以缺少DLC')92        require_int64_ns(self.time_ns); require_int64_ns(self.output_time_ns)93        if self.frame_origin not in ('passthrough','generated') or type(self.provenance) is not tuple or len(self.provenance)>32:94            raise ValueError('导出帧种类或定位非法')95        keys=set()96        for pair in self.provenance:97            if (type(pair) is not tuple or len(pair)!=2 or type(pair[0]) is not str or not pair[0]98                    or type(pair[1]) not in (str,int,float,bool,type(None))):99                raise TypeError('帧定位需要有限键值tuple')100            if pair[0] in keys or type(pair[1]) is float and not isfinite(pair[1]):101                raise ValueError('帧定位重复或非有限')102            keys.add(pair[0])103104105@dataclass(frozen=True)106class SkippedCANFrame:107    """保存重建未生成的网格点;不是原帧,不进入ASC/BLF,也不伪装成零载荷。"""108109    time_ns: int110    reason: str111    details: str112113    def __post_init__(self):114        """核有界诊断。参数: 无。返回: None。异常: 时间、原因或文本非法。115        """116        require_int64_ns(self.time_ns)117        if type(self.reason) is not str or not self.reason or type(self.details) is not str or len(self.details.encode('utf-8'))>60000:118            raise ValueError('未生成帧需要有限原因及JSON说明文本')119120121def as_export_frame(record, *, compact=False):122    """保既有原帧及原坐标,转换有限导出外壳。参数: 真实文本/暂存/已统一帧,compact不构造追溯。返回: ExportCANFrame。异常: 类型错。123    """124    if type(record) is ExportCANFrame:125        return record126    if type(record) is StoredRawFrame:127        # 旧BLF原float秒与分析offset分开,不把映射后的时间回写原报文。128        converted=convert_time(record.frame.timestamp,'s')129        return ExportCANFrame(record.sequence,record.time_ns,converted.time_ns,record.frame,130            () if compact else (('profile','sclicer-functions-v1'),('yield_sequence',record.sequence),131             ('timestamp',record.frame.timestamp),('time_quantized',converted.quantized)))132    if type(record) in (RawCANRecord,DecodedCANRecord):133        raw=record.raw if type(record) is DecodedCANRecord else record134        provenance=() if compact else (('path',str(raw.path)),('line_number',raw.line_number),('parser_version',raw.parser_version),135            ('record_sequence',raw.sequence),('raw_time',raw.raw_time),('time_quantized',raw.time_quantized))136        if not compact and type(record) is DecodedCANRecord:137            provenance+=(('input_index',record.input_index),('decode_status',record.status))138        # 分析offset只决定筛选位置;导出日期应叠加原始日内时间,不能叠加归零后的分析时间。139        source_ns=raw.time_ns if raw.source_time_ns is None else raw.source_time_ns140        return ExportCANFrame(record.sequence,raw.time_ns,source_ns,raw.frame,provenance,141            remote_dlc_known=not (raw.frame.is_remote_frame and raw.dlc_code is None))142    raise TypeError('帧导出需要同次原始/解码暂存记录')143144145def _asc_line(record,output_ns):146    """写标准ASC数据行及无法表达的原字段名单。参数: record、非负精确输出ns。返回: (bytes,losses)。异常: 格式不可表达。147    """148    frame=record.frame149    if frame.is_rx is None:150        raise ValueError('原始报文导出需要收发方向is_rx,不能把未知方向写为Tx或Rx')151    if output_ns<0 or type(frame.channel) is not int or not 0<=frame.channel<65536:152        raise ValueError('ASC需要非负时间和明确0基整数通道')153    seconds,nanos=divmod(output_ns,10**9)154    prefix=f'{seconds}.{nanos:09d}'155    channel=str(frame.channel+1); direction='Rx' if frame.is_rx else 'Tx'156    identifier=format(frame.arbitration_id,'x')+('x' if frame.is_extended_id else '')157    if not frame.is_extended_id and frame.arbitration_id>0x7ff:158        raise ValueError('ASC标准帧ID超出11位')159    if frame.is_error_frame:160        # 标准错误行不能保存数据帧附加字段;完整原值保在索引,清单明确格式损失。161        expected={'arbitration_id':0,'is_extended_id':False,'dlc':0,'data':b'',162                  'bitrate_switch':False,'error_state_indicator':False,'is_remote_frame':False}163        if not frame.is_fd:164            expected['is_rx']=True165        losses=tuple(k for k,v in expected.items() if getattr(frame,k)!=v)166        body=f'CANFD {channel} {direction} ErrorFrame' if frame.is_fd else f'{channel} ErrorFrame'167    else:168        losses=()169        if frame.is_remote_frame:170            if frame.is_fd or frame.data or not 0<=frame.dlc<=8:171                raise ValueError('ASC远程帧需要经典CAN、空数据和0至8 DLC')172            # ASC允许省略远程帧DLC;原记录没有提供时保持省略。173            suffix=f' {frame.dlc:x}' if record.remote_dlc_known else ''174            body=f'{channel} {identifier} {direction} r{suffix}'175        elif frame.is_fd:176            lengths=(0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64)177            if frame.dlc!=len(frame.data) or frame.dlc not in lengths:178                raise ValueError('ASC FD长度或DLC不一致')179            body=f'CANFD {channel} {direction} {identifier} {int(frame.bitrate_switch)} {int(frame.error_state_indicator)} {lengths.index(frame.dlc):x} {frame.dlc} '+frame.data.hex(' ')180        else:181            if frame.dlc!=len(frame.data) or frame.dlc>8:182                raise ValueError('ASC经典帧长度不一致')183            body=f'{channel} {identifier} {direction} d {frame.dlc:x} '+frame.data.hex(' ')184        if not frame.is_fd:185            losses=tuple(k for k in ('bitrate_switch','error_state_indicator') if getattr(frame,k))186    return (prefix+' '+body.rstrip()+'\n').encode('ascii'),losses187188189def export_can_frames(records,spec,interval,destination,budget,*,source,device_label,check_cancel=None,compact=False,integrity=True,file_consumer=None):190    """逐条筛选并写出原始范围,完整消费新暂存后发布。191192    参数: records同次可关闭原帧流;spec格式/偏移;interval半开分析范围;destination可复用公开输出根,内部file_consumer仍独占新目录;193        budget共享账本;source来源名;device_label设备;check_cancel取消回调;194        compact关闭逐帧副本/三时间/误差计算,仅保目标帧和必要清单。195    返回: 公开ExportManifest含实际目录/清单/文件路径;内部file_consumer模式返回Path。196    异常: 格式/时间/原历史/I/O/预算/取消错;持续清理失败保export_cleanup_owner。197    """198    if type(compact) is not bool:199        raise TypeError('compact必须为bool')200    if type(spec) is not FrameExportSpec or type(interval) is not Interval:201        raise TypeError('帧导出需要FrameExportSpec和Interval')202    require_identifier(source,'frame_export.source')203    require_text(device_label, 'device_label')204    if check_cancel is not None and not callable(check_cancel):205        raise ValueError('帧导出取消回调必须可调用')206    date_origin=spec.date_origin_ns()207    output_offset=checked_add_ns(date_origin,spec.output_offset_ns)208    if file_consumer is not None and not callable(file_consumer):209        raise TypeError('file_consumer须为回调')210    owner=ExportFiles(destination,budget,integrity=integrity,timestamped=file_consumer is None)211    try:212        if not compact:213            owner.open_file('frames.jsonl')214        if spec.format=='asc':215            owner.select_file('frames.asc')216            owner.append(b'base hex timestamps absolute\nno internal events logged\n')217        else:218            try:219                owner.worker=LegacyCANProcessWrite(budget,owner.temporary/'frames.blf')220            except BaseException as error:221                owner.worker=getattr(error,'can_process_write_cleanup_owner',None)222                raise223        owner.reader=iter(records)224        reconstruction=getattr(owner.reader,'export_metadata',None)225        if reconstruction is not None:226            _reserve_owned_ram(owner,_metadata_bytes(reconstruction)*16,'_fixed_cost')227        count=generated=limited=skipped=0228        max_float_error=0229        last=None230        for raw in owner.reader:231            if check_cancel is not None:232                check_cancel()233            _reserve_owned_ram(owner,1048576+(_metadata_bytes(raw.provenance)*16234                               if type(raw) is ExportCANFrame else len(raw.details.encode('utf-8'))*16235                               if type(raw) is SkippedCANFrame else 0),'_row_cost')236            try:237                if type(raw) is SkippedCANFrame:238                    if last is not None and raw.time_ns<last:239                        raise ValueError('未生成帧记录时间回退')240                    last=raw.time_ns241                    if interval.start_ns<=raw.time_ns<interval.end_ns:242                        if not compact:243                            owner.select_file('skipped_frames.jsonl')244                            owner.append((json.dumps({'time_ns':raw.time_ns,'reason':raw.reason,'details':raw.details},245                                ensure_ascii=False,separators=(',',':'))+'\n').encode('utf-8'))246                        skipped+=1247                    continue248                record=as_export_frame(raw,compact=True) if compact else as_export_frame(raw)249                if last is not None and record.time_ns<last:250                    raise ValueError('导出帧时间回退,不能重排原记录')251                last=record.time_ns252                if not interval.start_ns<=record.time_ns<interval.end_ns:253                    continue254                if record.frame.is_rx is None:255                    raise ValueError('原始报文导出需要收发方向is_rx')256                output_ns=checked_add_ns(record.output_time_ns,output_offset)257                if spec.format=='asc':258                    payload,losses=_asc_line(record,output_ns)259                    owner.select_file('frames.asc'); owner.append(payload)260                else:261                    # BLF写接口需要明确长度;不得把内部占位值0当作原始DLC。262                    if not record.remote_dlc_known:263                        raise ValueError('远程帧未提供DLC,不能无损导出BLF;请导出ASC或提供含明确DLC的输入')264                    # 不调用旧库的隐式“相对时间头归零”分支;缺日期先要求显式映射。265                    if output_ns<631152000*10**9:266                        raise ValueError('现有BLF写库不支持1990年以前的日期,不能改写为固定参考日期')267                    if type(record.frame.channel) is not int or not 0<=record.frame.channel<65535:268                        raise ValueError('BLF需要0至65534的明确整数通道,避免写库隐式改通道')269                    # 库接口仅接受float秒;记录转换误差,同时以整数索引保留精确真值。270                    timestamp=output_ns/10**9271                    if not compact:272                        delta=abs(Fraction(timestamp)*10**9-output_ns)273                        max_float_error=max(max_float_error,(delta.numerator+delta.denominator-1)//delta.denominator)274                    owner.worker.write(replace(record.frame,timestamp=timestamp))275                    losses=('error_frame_flags_or_payload',) if record.frame.is_error_frame else ()276                if not compact:277                    owner.select_file('frames.jsonl')278                    packet={'index':count,'source':source,'sequence':record.sequence,'analysis_time_ns':record.time_ns,279                        'source_time_ns':record.output_time_ns,'output_time_ns':output_ns,'frame_origin':record.frame_origin,280                        'provenance':dict(record.provenance),'frame':frame_to_dict(record.frame),'format_limits':losses}281                    if not record.remote_dlc_known:282                        packet['frame']['dlc']=None283                    owner.append((json.dumps(packet,ensure_ascii=False,allow_nan=False,separators=(',',':'))+'\n').encode('utf-8'))284                count+=1; generated+=record.frame_origin=='generated'; limited+=bool(losses)285            finally:286                _release_owned_ram(owner,owner._row_cost,'_row_cost')287        owner.close_reader()288        # 旧写进程真正结束后才能回读摘要;文件没关、摘要失败均不发布正式目录。289        if owner.worker is not None:290            owner.worker.finish()291            owner.worker=None292            owner.register_external('frames.blf')293        metadata={'frame_origin':'can_frames','device_label':device_label,'source':source,'format':spec.format,294            'range_ns':(interval.start_ns,interval.end_ns),'output_offset_ns':spec.output_offset_ns,295            'source_time_reference':spec.source_time_reference,'capture_date':spec.capture_date,296            'timezone_offset_minutes':spec.timezone_offset_minutes,'date_origin_ns':date_origin,297            'frames':count,'generated':generated,'passthrough':count-generated,'format_limited_frames':limited,298            'skipped_grid_points':skipped,299            'reconstruction':reconstruction,300            'time_precision':'integer_ns_text' if spec.format=='asc' else 'legacy_float_seconds',301            }302        if compact:303            metadata['row_format']='compact-v2'304        else:305            metadata.update(max_float_conversion_error_ns=max_float_error,exact_source_index='frames.jsonl')306        output=owner.finish(metadata)307        if file_consumer is not None: file_consumer(tuple(owner.files))308        return output309    except BaseException as error:310        owner._failed = True311        _cleanup_owner(owner,error,'export_cleanup_owner','原始报文导出')312        raise313
Chronaxis 用户手册 · 离线阅读