chronaxis.planning.config_groups
源码基线:8904c2f744951857659abd40aaa3535d57faf53c
1"""把当前配置的就地分组送入既有解析器,不改变时间或数值规则。23输入为JSON配置副本,输出独立副本。reader集中读取参数,conversion集中换算,4interval跟随实际消费者,scope对象明确引用规则或窗口。仅复制配置,不读输入、5不推断设备同步、不生成逐帧追溯。反向函数用于配置工具输出当前唯一公共形式。6"""7from copy import deepcopy8from functools import wraps9import re10from .diagnostic_collection import usable_node, collect_node_error, current_collection11from .checks import object_at, array_at, text_at, timezone_at, PlanValidationError1213CONVERSION = ('input_unit', 'scale', 'offset')14READER_FLAT = ('encoding', 'delimiter', 'missing_values', 'decode_scope', 'input_check')15NODES = ('rules', 'windows', 'calculations', 'sequences', 'transforms')161718def public_errors(function):19 """将内部字段错误还原到用户声明位置。参数: 计划构建方法。返回: 包装方法。异常: 原PlanValidationError。2021 不改变错误代码和原因,不读取或保存输入;结构阶段对旧名的拒绝保留旧名位置。22 """23 @wraps(function)24 def checked(cls, config, *args, **kwargs):25 """调用原解析器,仅在错误退出时修正已归组字段的路径。"""26 try:27 return function(cls, config, *args, **kwargs)28 except PlanValidationError as error:29 combined = error30 for error in combined.diagnostics:31 if error.code == 'unknown_field' or type(config) is not dict:32 continue33 path = error.path34 path = re.sub(r'(\$\.sources\[\d+\])\.(?:parser|reader_options)(?=\.|$)', r'\1.reader', path)35 path = re.sub(r'(\$\.sources\[\d+\])\.(encoding|delimiter|missing_values|decode_scope|input_check)(?=\.|$)', r'\1.reader.\2', path)36 path = re.sub(r'(\$\.signals\[\d+\](?:\.signals\[\d+\])?)\.(input_unit|scale|offset)(?=\.|$)', r'\1.conversion.\2', path)37 path = re.sub(r'(\.time)\.origin(?=\.|$)', r'\1.clock', path)38 path = re.sub(r'(\.resampling)\.origin(?=\.|$)', r'\1.start', path)39 path = path.replace('$.outputs.can_frames.timezone_offset_minutes', '$.outputs.can_frames.timezone')40 path = re.sub(r'(\$\.outputs\.(?:can_frames|signal_csv))\.window(?=\.|$)', r'\1.scope.window', path)41 match = re.match(r'^\$\.intervals\.overrides\.([^\.]+)(.*)$', path)42 if match:43 for section in NODES:44 nodes = config.get(section, [])45 if type(nodes) is not list: continue46 for index, node in enumerate(nodes):47 if type(node) is dict and node.get('name') == match[1] and 'interval' in node:48 path = f'$.{section}[{index}].interval' + match[2]49 error.path = path50 error.refresh_text()51 combined.path = combined.diagnostics[0].path52 combined.refresh_text()53 raise54 return checked555657def _leaves(signals, path='$.signals'):58 """遍历叶信号及声明路径。参数: 数组。返回: (dict,path)。异常: 错误数组/对象。"""59 for index, item in enumerate(array_at(signals, path)):60 child_path = f'{path}[{index}]'61 if type(item) is not dict:62 raise PlanValidationError(child_path, '信号声明必须为对象')63 if 'signals' in item:64 yield from _leaves(item['signals'], child_path + '.signals')65 else:66 yield item, child_path676869def normalize_configuration(config):70 """解析当前公共分组。参数: 已验证JSON字典。返回: 独立内部字典。异常: 公共结构错误。7172 只接受当前拼写,旧扁平参数明确拒绝;内部parser/reader_options等仅供既有73 解析器消费,不作为第二套用户配置入口。调用应在预设展开之前。74 """75 root = deepcopy(config)76 for index, source in enumerate(array_at(root['sources'], '$.sources')):77 path = f'$.sources[{index}]'78 if not usable_node(path, source):79 continue80 try:81 path = f'$.sources[{index}]'82 if type(source) is not dict:83 raise PlanValidationError(path, '来源必须为对象')84 for key in ('parser', 'reader_options', *READER_FLAT):85 if key in source:86 raise PlanValidationError(path + '.' + key, '读取参数请放在reader对象内', 'unknown_field')87 if 'reader' in source:88 reader = dict(object_at(source.pop('reader'), path + '.reader', (),89 (*READER_FLAT, 'decode_policy', 'version', 'layout', 'time_format', 'id_base',90 'length_base', 'channel_origin', 'payload_style', 'rollover_min_drop')))91 # 信号表读取参数和CAN解码参数分别交给各自的原校验器。92 for key in ('decode_scope', 'input_check'):93 if key in reader: source[key] = reader.pop(key)94 if 'decode_policy' in reader:95 source['reader_options'] = {'decode_policy': reader.pop('decode_policy')}96 if source.get('source_type') == 'signal_csv':97 source.update(reader)98 elif reader:99 source['parser'] = reader100 clock = source.get('time')101 if type(clock) is dict and 'origin' in clock:102 raise PlanValidationError(path + '.time.origin', '来源时间基准名称请使用clock', 'unknown_field')103 if type(clock) is dict and 'clock' in clock:104 clock['origin'] = clock.pop('clock')105 if type(clock) is dict and 'timezone_offset_minutes' in clock:106 raise PlanValidationError(path + '.time.timezone_offset_minutes',107 '时区统一使用timezone,例如+08:00', 'unknown_field')108 except PlanValidationError as error:109 collect_node_error(error, path, source)110 for signal, path in _leaves(root['signals']):111 if not usable_node(path, signal):112 continue113 try:114 for key in CONVERSION:115 if key in signal:116 raise PlanValidationError(path + '.' + key, '信号换算请放在conversion对象内', 'unknown_field')117 if 'conversion' in signal:118 signal.update(object_at(signal.pop('conversion'), path + '.conversion', (), CONVERSION))119 except PlanValidationError as error:120 collect_node_error(error, path, signal)121 intervals = dict(object_at(root.get('intervals', {}), '$.intervals', (), ('default',)))122 overrides = {}123 for section in NODES:124 for index, node in enumerate(array_at(root.get(section, []), '$.' + section)):125 path = f'$.{section}[{index}]'126 if type(node) is not dict:127 raise PlanValidationError(path, '节点必须为对象')128 if 'interval' in node:129 name = text_at(node.get('name'), path + '.name', identifier=True)130 if name in overrides:131 raise PlanValidationError(path + '.name', '间隔消费者名称重复')132 # 原间隔解析器随后检查模式、驱动、正周期和消费者是否适用。133 overrides[name] = node.pop('interval')134 if section == 'calculations' and 'scope' in node:135 scope = node['scope']136 if scope == 'session':137 continue138 scope = object_at(scope, path + '.scope', (), ('rule', 'window'))139 if len(scope) != 1:140 raise PlanValidationError(path + '.scope', 'scope需要且只能引用一个rule或window')141 key = next(iter(scope))142 target = text_at(scope[key], path + '.scope.' + key, identifier=True)143 node['scope'] = ('event:' if key == 'rule' else 'segment:') + target144 if overrides: intervals['overrides'] = overrides145 if intervals or 'intervals' in root: root['intervals'] = intervals146 for kind in ('signal_csv', 'can_frames'):147 outputs = root.get('outputs', {})148 output = outputs.get(kind) if type(outputs) is dict else None149 if type(output) is not dict: continue150 path = '$.outputs.' + kind151 if kind == 'can_frames':152 if 'timezone_offset_minutes' in output:153 raise PlanValidationError(path + '.timezone_offset_minutes', '时区请使用timezone,例如+08:00', 'unknown_field')154 if 'timezone' in output:155 zone = timezone_at(output.pop('timezone'), path + '.timezone')156 offset = None if zone is None else zone.utcoffset(None)157 if zone is not None and offset is None:158 raise PlanValidationError(path + '.timezone', '原帧导出需要UTC或固定时区,例如+08:00')159 output['timezone_offset_minutes'] = None if offset is None else int(offset.total_seconds() // 60)160 grid = output.get('resampling')161 if type(grid) is dict:162 if 'origin' in grid:163 raise PlanValidationError(path + '.resampling.origin', '采样网格起点请使用start', 'unknown_field')164 if 'start' in grid: grid['origin'] = grid.pop('start')165 if 'window' in output:166 raise PlanValidationError(path + '.window', '窗口引用请写scope: {window: 名称}', 'unknown_field')167 scope = output.get('scope', 'session')168 if scope == 'session': continue169 scope = object_at(scope, path + '.scope', (), ('window', 'windows'))170 if len(scope) != 1:171 raise PlanValidationError(path + '.scope', '导出scope需要一个window引用或windows: all')172 output['scope'] = 'segments'173 if 'window' in scope:174 output['window'] = text_at(scope['window'], path + '.scope.window', identifier=True)175 elif kind != 'can_frames' or scope['windows'] != 'all':176 raise PlanValidationError(path + '.scope.windows', '仅原帧导出支持windows: all')177 return root178179180def public_configuration(config):181 """把生成器内部结构转成当前公共配置。参数: 配置字典。返回: 独立字典。异常: 结构错误。182183 不读取文件,不改变自由metadata或业务值;已使用新分组的内容原样保留。184 用于预设展开和resolve,把内部字段还原为当前公开配置结构。185 """186 root = deepcopy(config)187 for source in root.get('sources', []):188 reader = dict(source.get('reader', {}))189 for key in ('parser', 'reader_options'):190 reader.update(source.pop(key, {}))191 for key in READER_FLAT:192 if key in source: reader[key] = source.pop(key)193 if reader: source['reader'] = reader194 clock = source.get('time', {})195 if 'origin' in clock: clock['clock'] = clock.pop('origin')196 if 'timezone_offset_minutes' in clock:197 minutes = clock.pop('timezone_offset_minutes')198 if type(minutes) is not int or not -1439 <= minutes <= 1439:199 raise PlanValidationError('$.sources.time.timezone', '固定时区分钟数无效')200 clock['timezone'] = ('+' if minutes >= 0 else '-') + f'{abs(minutes)//60:02}:{abs(minutes)%60:02}'201 for signal, _ in _leaves(root.get('signals', [])):202 conversion = dict(signal.get('conversion', {}))203 for key in CONVERSION:204 if key in signal: conversion[key] = signal.pop(key)205 if conversion: signal['conversion'] = conversion206 intervals = root.get('intervals', {})207 overrides = intervals.pop('overrides', {}) if type(intervals) is dict else {}208 for section in NODES:209 for node in root.get(section, []):210 if node.get('name') in overrides:211 node['interval'] = overrides.pop(node['name'])212 scope = node.get('scope')213 if section == 'calculations' and type(scope) is str and ':' in scope:214 kind, target = scope.split(':', 1)215 if kind in ('event', 'segment'):216 node['scope'] = {'rule' if kind == 'event' else 'window': target}217 # 无对应节点不能丢弃声明,让原错误继续可见而不是静默删配置。218 if overrides: intervals['overrides'] = overrides219 for kind in ('signal_csv', 'can_frames'):220 outputs = root.get('outputs', {})221 output = outputs.get(kind) if type(outputs) is dict else None222 if type(output) is not dict: continue223 if kind == 'can_frames' and 'timezone_offset_minutes' in output:224 minutes = output.pop('timezone_offset_minutes')225 output['timezone'] = None if minutes is None else ('+' if minutes >= 0 else '-') + f'{abs(minutes)//60:02}:{abs(minutes)%60:02}'226 grid = output.get('resampling')227 if type(grid) is dict and 'origin' in grid: grid['start'] = grid.pop('origin')228 if output.get('scope') != 'segments': continue229 window = output.pop('window', None)230 output['scope'] = {'windows': 'all'} if window is None else {'window': window}231 return root232