chronaxis.legacy.batch_wire
源码基线:8904c2f744951857659abd40aaa3535d57faf53c
1"""为已解码CAN记录定义有界批次字节包,不执行读取、解码或任意对象反序列化。23固定头保存版本、原始起始序号、原帧数、EOF和保留记录数。旧CXB1保全部原float644时间;按需CXB2按source/selected/none保留所需时间与完整回退计数,再逐条传严格JSON。5允许仅保留计划需要的记录,原序号不重排,未采时间绝不以零秒补齐。6单条仍受原64KiB限制;整包最多256KiB、最多4096帧。调用者须先预约工作空间。7本模块仅依赖标准库和既有模型,不打开文件、导入CAN库或持有预算/进程资源。8"""910from dataclasses import dataclass11from math import isfinite12import struct1314from .records import MAX_WIRE_BYTES, LegacyDecodedFrame, _decoded_from_packet15from .required_records import RequiredDecodedFrame, required_from_packet161718MAX_BATCH_BYTES = 262144 # 数据包上限;控制包仍使用原64KiB严格JSON。19MAX_BATCH_ROWS = 4096 # 许可的是原始帧数,包括不保留完整值的帧。20BATCH_MAGIC = b'CXB1' # 固定第一版批次格式,与旧JSON控制包可明确区分。21HEADER = struct.Struct('>4sQIBI') # magic/start/count/eof/selected_count。22DEMAND_MAGIC = b'CXB2' # 原帧计数独立于时间;明确不能按旧全时间包解释。23DEMAND_HEADER = struct.Struct('>4sQIBIBI') # scope 0=none/1=selected/2=source,末项完整回退数。24FILE_MAGIC = b'CXF1' # 连续输入批次的文件位置外壳;内部仍是CXB1/CXB2。25FILE_HEADER = struct.Struct('>4sI') # 每批只属于一个输入,空文件可跳过。26LENGTH = struct.Struct('>I') # 每条原记录的JSON长度,不隐含pickle或压缩。27MIN_BATCH_BYTES = HEADER.size + 8 + LENGTH.size + MAX_WIRE_BYTES282930@dataclass(frozen=True)31class LegacyDecodedBatch:32 """持有已核批次的全量时间和稀疏完整记录,借用至read下一次推进或关闭。3334 source的timestamps对应每个原yield,selected仅对应保留records,none为空;35 frame_count始终为原始跨度。records保严格递增原序号且可能为空。对象只能在解包完成后由内部建立,36 外部自行构造不构成输入已验证证明;保留借用对象的调用方另承担其内存费用。37 """3839 start_sequence: int # 本批第一个原yield序号,不是保留记录的密集序号。40 timestamps: tuple[float, ...] # 所有原yield的精确二进制浮点秒。41 records: tuple[LegacyDecodedFrame | RequiredDecodedFrame, ...] # source保完整原帧,其他保必要身份。42 raw_count: int | None = None # 新包独立原帧数;旧包None兼容原timestamps长度。43 time_scope: str = 'source' # source全来源,selected仅保留目标,none不采时间。44 full_check_count: int = 0 # 新auto包中非原生输入执行完整原帧检查的数量。45 protocol_version: int = 1 # 区分source全时间的新包与旧包,证明含义不可混用。4647 input_index: int = 0 # 当前批所在原序文件;单文件为0,不逐条复制路径。4849 @property50 def frame_count(self):51 """读取原帧数而非时间数量。参数: 无。返回: int。异常: 无。"""52 return len(self.timestamps) if self.raw_count is None else self.raw_count535455def encode_batch(start_sequence, timestamps, records, eof, byte_limit, *, raw_count=None, time_scope='source',56 input_check='full', full_check_count=0):57 """拼接已经逐条严格编码的记录,检查物理容量后才创建最终包。5859 参数: start_sequence为原起点;timestamps为有限float列表;records为已编码bytes列表;60 eof为真实原StopIteration标记;byte_limit为本次已获准字节上限;raw_count为原跨度,61 input_check/time_scope决定旧全时间或新按需包,full_check_count为非原生完整检查数。62 返回: bytes数据包。异常: 类型、容量或边界错误;不调用旧读取器或补帧。63 边界: 这是内部编码器,记录语义由原模型/编码器及接收端再次严格检查。64 """65 if input_check not in ('auto', 'full'):66 raise ValueError('输入检查范围需要auto/full')67 if input_check == 'full' and time_scope != 'source':68 raise ValueError('完整检查必须保留全来源时间')69 if time_scope != 'source' or input_check == 'auto':70 return _encode_required_batch(start_sequence, timestamps, records, eof, byte_limit,71 raw_count=raw_count, time_scope=time_scope, full_check_count=full_check_count)72 if raw_count is not None and (type(raw_count) is not int or raw_count != len(timestamps)):73 raise ValueError('全来源时间批次原帧计数必须与时间数量一致')74 if type(start_sequence) is not int or not 0 <= start_sequence < 2**64 or type(eof) is not bool:75 raise ValueError('批次起点或EOF类型非法')76 if (type(byte_limit) is not int or not MIN_BATCH_BYTES <= byte_limit <= MAX_BATCH_BYTES77 or not 0 <= len(timestamps) <= MAX_BATCH_ROWS or len(records) > len(timestamps)78 or start_sequence + len(timestamps) >= 2**64):79 raise ValueError('批次容量、计数或序号越界')80 if not timestamps and not eof:81 raise ValueError('空批次只能报告真实EOF')82 if any(type(t) is not float or not isfinite(t) for t in timestamps):83 raise ValueError('原报文timestamp需要有限float秒')84 if any(type(row) is not bytes or not 0 < len(row) <= MAX_WIRE_BYTES for row in records):85 raise ValueError('批次单条记录必须是原限额内bytes')86 size = HEADER.size + 8*len(timestamps) + sum(LENGTH.size+len(row) for row in records)87 if size > byte_limit:88 raise ValueError('批次编码超过已授权字节上限')89 # 拼接时保留原顺序;有限列表/最终副本由父预付的批次工作空间覆盖。90 chunks = [HEADER.pack(BATCH_MAGIC, start_sequence, len(timestamps), int(eof), len(records)),91 struct.pack('>'+str(len(timestamps))+'d', *timestamps)]92 for row in records:93 chunks.extend((LENGTH.pack(len(row)), row))94 return b''.join(chunks)959697def decode_batch(payload, *, expected_start, row_limit, byte_limit, frame_ids, time_scope='source',98 input_check='full'):99 """核物理边界、原序号、时间和投影完整性,解包后才交付不可变批次。100101 参数: payload为recv_bytes取得的受限bytes;expected_start/row_limit/byte_limit102 为本次唯一许可;frame_ids=None要求全量,tuple表示允许保留的ID;103 input_check/time_scope必须与READY确认策略相同,不能按另一个范围解释字节。104 返回: (LegacyDecodedBatch, eof);EOF仍须由read确认子退出和关闭才生效。105 异常: 原JSON/模型检查错误或ValueError为坏包、乱序、长度/时间不符。106 边界: 父先付费;本函数不依靠头内数量分配无限数组,也不忽略尾随字节。107 """108 if input_check not in ('auto', 'full'):109 raise ValueError('输入检查范围需要auto/full')110 if input_check == 'full' and time_scope != 'source':111 raise ValueError('完整检查必须保留全来源时间')112 if time_scope != 'source' or input_check == 'auto':113 return _decode_required_batch(payload, expected_start=expected_start, row_limit=row_limit,114 byte_limit=byte_limit, frame_ids=frame_ids, time_scope=time_scope)115 timestamps = records = record = None116 try:117 if type(payload) is not bytes or not HEADER.size <= len(payload) <= byte_limit:118 raise ValueError('批次字节长度非法')119 magic, start, count, eof, retained = HEADER.unpack_from(payload)120 if (magic != BATCH_MAGIC or start != expected_start or not 0 <= count <= row_limit121 or eof not in (0, 1) or retained > count or start+count >= 2**64122 or count == 0 and not eof):123 raise ValueError('批次头与本次许可不符')124 position = HEADER.size + count*8125 if position > len(payload):126 raise ValueError('批次时间数组被截断')127 timestamps = struct.unpack_from('>'+str(count)+'d', payload, HEADER.size)128 if any(not isfinite(value) for value in timestamps):129 raise ValueError('原报文timestamp需要有限float秒')130 records, previous = [], start-1131 try:132 for _ in range(retained):133 if position+LENGTH.size > len(payload):134 raise ValueError('批次记录长度被截断')135 size = LENGTH.unpack_from(payload, position)[0]136 position += LENGTH.size137 if not 0 < size <= MAX_WIRE_BYTES or position+size > len(payload):138 raise ValueError('批次记录超限或被截断')139 # 专用入口保UTF-8/重复键、完整frame/值及ITEM限额;能证明固定小包140 # 合法时只解析构造一次,复杂输入仍由原完整入口决定精确错误顺序。141 record = _decoded_from_packet(payload[position:position+size])142 position += size143 if not previous < record.sequence < start+count:144 raise ValueError('批次记录原序号乱序、重复或越界')145 if record.frame.timestamp.hex() != timestamps[record.sequence-start].hex():146 raise ValueError('批次原时间与记录时间不一致')147 if frame_ids is not None and record.frame.arbitration_id not in frame_ids:148 raise ValueError('批次返回未声明保留的报文ID')149 previous = record.sequence150 records.append(record)151 if position != len(payload) or frame_ids is None and retained != count:152 raise ValueError('批次存在尾随字节或全量记录缺项')153 return LegacyDecodedBatch(start, timestamps, tuple(records)), bool(eof)154 finally:155 # 失败异常可能由外层保存,不让已结束解包frame额外保活整批缓冲/模型。156 payload = timestamps = records = record = None157 finally:158 # 原错误可留在调用方;已结束调用的临时引用不继续占有整批对象。159 payload = timestamps = records = record = None160161162def record_payload_offset(batch):163 """定位已验证包中记录长度前缀区域。参数: batch为已解包批次。返回: 字节偏移。异常: ValueError为未知scope。"""164 if batch.protocol_version == 1 and batch.time_scope == 'source':165 return HEADER.size + 8*batch.frame_count166 if batch.protocol_version == 2 and batch.time_scope in ('none', 'selected', 'source'):167 return DEMAND_HEADER.size + (8*batch.frame_count if batch.time_scope == 'source' else 0)168 raise ValueError('未知批次时间范围')169170171def _encode_required_batch(start_sequence, timestamps, records, eof, byte_limit, *, raw_count, time_scope,172 full_check_count):173 """建立独立原帧计数的稀疏包,时间仅在目标JSON内。174175 参数: start_sequence/raw_count为原序跨度;timestamps在selected与目标数相同,none必须空;176 records为已严格编码的按需记录;其余限制沿原包。返回: bytes。异常: 类型或容量错误。177 """178 if time_scope not in ('none', 'selected', 'source'):179 raise ValueError('批次时间范围需要source/selected/none')180 if (type(start_sequence) is not int or not 0 <= start_sequence < 2**64 or type(eof) is not bool181 or type(raw_count) is not int or not 0 <= raw_count <= MAX_BATCH_ROWS182 or start_sequence+raw_count >= 2**64 or len(records) > raw_count183 or raw_count == 0 and not eof or type(full_check_count) is not int184 or not 0 <= full_check_count <= raw_count):185 raise ValueError('按需批次起点、原计数或EOF非法')186 if (type(byte_limit) is not int or not MIN_BATCH_BYTES <= byte_limit <= MAX_BATCH_BYTES187 or time_scope == 'none' and timestamps188 or time_scope == 'selected' and len(timestamps) != len(records)189 or time_scope == 'source' and len(timestamps) != raw_count):190 raise ValueError('按需批次容量或时间数量非法')191 if any(type(t) is not float or not isfinite(t) for t in timestamps):192 raise ValueError('所需报文timestamp需要有限float秒')193 if any(type(row) is not bytes or not 0 < len(row) <= MAX_WIRE_BYTES for row in records):194 raise ValueError('批次单条记录必须是原限额内bytes')195 time_bytes = 8*raw_count if time_scope == 'source' else 0196 if DEMAND_HEADER.size + time_bytes + sum(LENGTH.size+len(row) for row in records) > byte_limit:197 raise ValueError('批次编码超过已授权字节上限')198 # 不传输任何未使用的原时间数组;selected时间已经包含在目标记录内。199 chunks = [DEMAND_HEADER.pack(DEMAND_MAGIC, start_sequence, raw_count, int(eof), len(records),200 {'none': 0, 'selected': 1, 'source': 2}[time_scope], full_check_count)]201 if time_scope == 'source':202 chunks.append(struct.pack('>'+str(raw_count)+'d', *timestamps))203 for row in records:204 chunks.extend((LENGTH.pack(len(row)), row))205 return b''.join(chunks)206207208def _decode_required_batch(payload, *, expected_start, row_limit, byte_limit, frame_ids, time_scope):209 """严格核对稀疏包原跨度、scope、目标顺序及每条必要身份和值。210211 参数: 与decode_batch相同,time_scope必须为none/selected。返回: (批次,真实EOF候选)。212 异常: 未许可范围、坏包、JSON/模型或取消错误;不会用假时间凑原帧数量。213 """214 records = record = timestamps = None215 try:216 if (time_scope not in ('none', 'selected', 'source') or type(payload) is not bytes217 or not DEMAND_HEADER.size <= len(payload) <= byte_limit):218 raise ValueError('按需批次时间范围或字节长度非法')219 magic, start, count, eof, retained, scope, full_checks = DEMAND_HEADER.unpack_from(payload)220 if (magic != DEMAND_MAGIC or start != expected_start or not 0 <= count <= row_limit221 or scope != {'none': 0, 'selected': 1, 'source': 2}[time_scope] or eof not in (0, 1)222 or not 0 <= full_checks <= count223 or retained > count or start+count >= 2**64 or count == 0 and not eof):224 raise ValueError('按需批次头与本次许可不符')225 records, timestamps, previous, position = [], [], start-1, DEMAND_HEADER.size226 if time_scope == 'source':227 position += 8*count228 if position > len(payload):229 raise ValueError('批次时间数组被截断')230 timestamps = list(struct.unpack_from('>'+str(count)+'d', payload, DEMAND_HEADER.size))231 if any(not isfinite(value) for value in timestamps):232 raise ValueError('原报文timestamp需要有限float秒')233 for _ in range(retained):234 if position+LENGTH.size > len(payload):235 raise ValueError('批次记录长度被截断')236 size = LENGTH.unpack_from(payload, position)[0]237 position += LENGTH.size238 if not 0 < size <= MAX_WIRE_BYTES or position+size > len(payload):239 raise ValueError('批次记录超限或被截断')240 record = (_decoded_from_packet if time_scope == 'source' else required_from_packet)(241 payload[position:position+size])242 position += size243 if not previous < record.sequence < start+count:244 raise ValueError('批次记录原序号乱序、重复或越界')245 if frame_ids is not None and record.frame.arbitration_id not in frame_ids:246 raise ValueError('批次返回未声明保留的报文ID')247 if time_scope == 'none':248 if record.frame.timestamp is not None:249 raise ValueError('未采时间批次不能携带伪造或额外时间')250 elif time_scope == 'selected':251 if record.frame.timestamp is None:252 raise ValueError('所选时间批次缺少目标时间')253 timestamps.append(record.frame.timestamp)254 elif record.frame.timestamp.hex() != timestamps[record.sequence-start].hex():255 raise ValueError('批次原时间与记录时间不一致')256 previous = record.sequence257 records.append(record)258 if position != len(payload) or frame_ids is None and retained != count:259 raise ValueError('批次存在尾随字节或全量记录缺项')260 return LegacyDecodedBatch(start, tuple(timestamps), tuple(records), count, time_scope,261 full_checks, 2), bool(eof)262 finally:263 # 已结束异常frame不继续持有整批JSON、模型或传输包。264 payload = records = record = timestamps = None265