chronaxis.planning.nodes
源码基线:8904c2f744951857659abd40aaa3535d57faf53c
1"""检查计算条件、窗口和指标配置,保存静态依赖与边界声明。23输入是节点配置及当前信号绑定,输出RulePlan、WindowPlan和CalculationPlan。4表达式只允许受控语法,条件结果必须为布尔类型;窗口区分持续活动与时间点,5指标区分全会话、原事件和合并片段范围。检查引用、类型、单位及同步声明后,6节点按用户顺序保存。本模块不读来源或DBC,不运行时序规则或积分;7可配置的节点不等于运行接入完成,实际能力由计划报告说明。8"""910# 类型与静态单位校验均来自已验核心,不运行样本数值。11from ..core.expression import validate_expression12from ..core.calculations import CalculationDefinition, calculation_conversion13from ..core.models import Interval14from ..core.types import SignalDType as D15from ..core.unit_registry import resolve_unit16from ..core.window_delta import WindowDeltaSpec17from ..core.counts_v4 import COUNT_OPERATIONS, OBJECT_COUNTS18from ..core.moments import MOMENTS, WITH_DDOF, moment_ddof19from ..core.quantiles import SAMPLE_QUANTILES, quantile_probability20from ..core.stability import StabilitySpec21from ..core.start_end import StartEndSpec22# 严格JSON字段与可定位异常。23from .diagnostic_collection import usable_node, collect_node_error24from .checks import PlanValidationError, array_at, choice_at, duration_at, model_at, number_at, object_at, text_at25from .models import CalculationPlan, RulePlan, WindowPlan262728def expression_at(text, path, bindings, derived, *, boolean=False, unit=None):29 """检查表达式声明,并将语法、类型或时钟错误关联到配置位置。3031 参数:32 text (str): 使用已绑定简单别名的表达式文本。33 path (str): 表达式对应的配置位置。34 bindings (DeclarationBindings): 当前会话的名称、来源和时间声明关系。35 derived (tuple): 全部派生定义,供依赖展开和循环检查。36 boolean (bool): 默认False;True时要求结果为布尔类型。37 unit (str | None): 可选输出单位,默认None,按表达式推导。38 返回:39 StaticExpression。保留类型、单位和依赖,尚未读取数据或执行计算。40 异常:41 PlanValidationError: 受控语法、名称、依赖、类型、单位或同步声明不合法。42 """43 from .diagnostic_collection import require_expression_dependencies, expression_scope44 require_expression_dependencies(text, path, derived)45 bindings, derived = expression_scope(text, bindings, derived)46 # 所有静态配置表达式走同一严格白名单。47 result = model_at(validate_expression, path, text, bindings, derived=derived, unit=unit)48 # 不借数值truthiness生成条件。49 if boolean and result.root.dtype is not D.BOOL:50 # 编译完成也必须符合所在节点角色。51 raise PlanValidationError(path, 'condition必须为bool表达式')52 # 调用者可保存依赖/单位/静态身份。53 return result545556def register_id(raw, path, ids) -> str:57 """将一个合法节点ID登记到本计划的名称集合。5859 参数:60 raw (str): 用户声明的节点ID。61 path (str): ID对应的配置位置。62 ids (set[str]): 当前计划局部集合,供规则、窗口和指标共用。63 返回:64 str。原ID保持不变,并加入ids。65 异常:66 PlanValidationError: ID非法或已经存在,不自动改名或覆盖。67 副作用:68 只修改传入的局部集合,不注册全局名称。69 """70 # ID也禁止关键字/函数名称。71 name = text_at(raw, path, identifier=True)72 # 不同节点类型也不能悄悄使用同一稳定计划ID。73 if name in ids:74 # 第二定义位置准确报告。75 raise PlanValidationError(path, f'计划ID重复:{name}')76 # 只更新解析器局部集合,不用全局注册表。77 ids.add(name)78 # 不自动添加后缀。79 return name808182def parse_rules(value, bindings, derived, ids) -> tuple[RulePlan, ...]:83 """检查普通布尔规则或数值窗口规则,保存参数与静态依赖。8485 参数:86 value (list): 规则配置数组。87 bindings (DeclarationBindings): 当前来源和名称声明关系。88 derived (tuple): 完整派生定义。89 ids (set[str]): 本计划节点ID集合,成功登记的规则ID加入其中。90 返回:91 tuple[RulePlan, ...]。保持规则顺序,参数按kind互斥;不产生事件或活动状态。92 异常:93 PlanValidationError: 字段、ID、kind、条件类型、初始触发或持续参数错误。94 """95 # 不排序规则,结果保持声明次序。96 result = []97 # 每种kind只接受自己的字段,不以大并集容忍无效参数。98 for index, raw in enumerate(array_at(value, '$.rules')):99 # 精确数组项路径。100 path = f'$.rules[{index}]'101 if not usable_node(path, raw):102 continue103 try:104 path = f'$.rules[{index}]'105 # 基本对象先检查,防止任意对象方法调用。106 if type(raw) is not dict:107 # 不将字符串规则当自由DSL。108 raise PlanValidationError(path, 'rule必须为对象')109 # 仅接受下列支持的规则类型,未知类型在加载配置时拒绝。110 rule_type = choice_at(raw.get('rule_type'), ('condition_duration', 'rising_edge', 'falling_edge', 'start_only', 'window_delta', 'debounce', 'hysteresis', 'start_end'), path + '.rule_type')111 if rule_type == 'start_end':112 raw = object_at(raw, path, ('name', 'rule_type', 'start_condition', 'end_condition'), ('timeout', 'emit_initial'))113 rule_id = register_id(raw['name'], path + '.name', ids)114 condition = expression_at(raw['start_condition'], path + '.start_condition', bindings, derived, boolean=True)115 end_condition = expression_at(raw['end_condition'], path + '.end_condition', bindings, derived, boolean=True)116 model_at(bindings.require_synchronized, path, tuple(sorted(set(condition.dependencies + end_condition.dependencies))))117 timeout = raw.get('timeout')118 timeout_ns = None if timeout is None else duration_at(timeout, path + '.timeout', positive=True)119 spec = StartEndSpec(timeout_ns, choice_at(raw.get('emit_initial', True), (False, True), path + '.emit_initial'))120 result.append(model_at(RulePlan, path, rule_id, rule_type, condition, None, None, False, None,121 exit_condition=end_condition, start_end=spec))122 continue123 if rule_type in ('debounce', 'hysteresis'):124 # 迟滞用明确的两个BOOL条件;普通防抖只需要一个条件和相反方向。125 required = ('name', 'rule_type', 'condition') if rule_type == 'debounce' else ('name', 'rule_type', 'enter_condition', 'exit_condition')126 raw = object_at(raw, path, required, ('enter_delay', 'exit_delay'))127 rule_id = register_id(raw['name'], path + '.name', ids)128 field = 'condition' if rule_type == 'debounce' else 'enter_condition'129 condition = expression_at(raw[field], path + '.' + field, bindings, derived, boolean=True)130 exit_condition = (expression_at(raw['exit_condition'], path + '.exit_condition', bindings, derived, boolean=True)131 if rule_type == 'hysteresis' else None)132 dependencies = condition.dependencies + (() if exit_condition is None else exit_condition.dependencies)133 # 两条件即使分别合法,联合判断仍须证明时钟可比较。134 model_at(bindings.require_synchronized, path, tuple(sorted(set(dependencies))))135 spec = StabilitySpec(duration_at(raw.get('enter_delay', '0s'), path + '.enter_delay'),136 duration_at(raw.get('exit_delay', '0s'), path + '.exit_delay'))137 result.append(model_at(RulePlan, path, rule_id, rule_type, condition, None, None, False, None,138 stability=spec, exit_condition=exit_condition))139 continue140 if rule_type == 'window_delta':141 # 数值窗口先生成条件,重触发另由边沿检测承担;不向DSL加入有状态函数。142 raw = object_at(raw, path, ('name', 'rule_type', 'signal', 'window', 'direction', 'threshold'),143 ('monotonic', 'coverage_policy', 'emit_initial', 'retrigger'))144 rule_id = register_id(raw['name'], path + '.name', ids)145 signal = expression_at(raw['signal'], path + '.signal', bindings, derived)146 spec = model_at(WindowDeltaSpec, path,147 duration_at(raw['window'], path + '.window', positive=True),148 number_at(raw['threshold'], path + '.threshold'),149 choice_at(raw['direction'], ('rise', 'fall', 'any'), path + '.direction'),150 choice_at(raw.get('monotonic', False), (False, True), path + '.monotonic'),151 choice_at(raw.get('coverage_policy', 'full'), ('full',), path + '.coverage_policy'))152 emit = choice_at(raw.get('emit_initial', False), (False, True), path + '.emit_initial')153 retrigger = choice_at(raw.get('retrigger', 'edge'), ('edge', 'once'), path + '.retrigger')154 result.append(model_at(RulePlan, path, rule_id, rule_type, signal, None, None, emit, None, spec, retrigger))155 continue156 # condition_duration与start_only有不同必须持续参数。157 required = ('name', 'rule_type', 'condition') + (('minimum_duration', 'duration_comparison') if rule_type == 'condition_duration' else ('window_duration',) if rule_type == 'start_only' else ())158 # condition_duration不接emit_initial,避免错误活动政策。159 raw = object_at(raw, path, required, ('selection_origin',) if rule_type == 'condition_duration' else ('emit_initial',))160 # ID和DSL都静态校验,不读取来源。161 rule_id = register_id(raw['name'], path + '.name', ids)162 # 条件必须为bool,并检查依赖来源是否声明共同时间基准;这不核实真实时钟误差。163 condition = expression_at(raw['condition'], path + '.condition', bindings, derived, boolean=True)164 # 仅condition_duration使用选择起点。165 selection = choice_at(raw.get('selection_origin', 'onset'), ('onset', 'confirmed'), path + '.selection_origin') if rule_type == 'condition_duration' else None166 # 开关严格bool,不能把文本false当True。167 emit = choice_at(raw.get('emit_initial', False), (False, True), path + '.emit_initial')168 # falling_edge不支持初始已知TRUE触发。169 if rule_type == 'falling_edge' and emit:170 # 不偷换首次状态的下降沿含义。171 raise PlanValidationError(path + '.emit_initial', 'falling不接受emit_initial=true')172 # condition_duration允许0,固定窗口必须正数。173 duration = duration_at(raw['minimum_duration'], path + '.minimum_duration') if rule_type == 'condition_duration' else None174 # 固定抑制不能以0长度循环重触发。175 fixed = duration_at(raw['window_duration'], path + '.window_duration', positive=True) if rule_type == 'start_only' else None176 # 保存声明,不在解析器内创建时序活动状态。177 comparison = (choice_at(raw['duration_comparison'], ('>=', '>'), path + '.duration_comparison')178 if rule_type == 'condition_duration' else '>=')179 result.append(RulePlan(rule_id, rule_type, condition, duration, selection, emit, fixed,180 duration_comparison=comparison))181 except PlanValidationError as error:182 collect_node_error(error, path, raw)183 # 不保留用户可变数组。184 return tuple(result)185186187def parse_windows(value, rules, ids) -> tuple[WindowPlan, ...]:188 """检查事件窗口锚点、前后扩展和合并参数,不改变原事件声明。189190 参数:191 value (list): 窗口配置数组。192 rules (tuple[RulePlan, ...]): 可引用的已检查规则。193 ids (set[str]): 当前计划节点ID集合。194 返回:195 tuple[WindowPlan, ...]。持续规则只能使用activity锚点,其余使用point。196 exit_delay要求activity且post为0,start_only的post须等于固定窗口时长。197 异常:198 PlanValidationError: 引用、ID、锚点、扩展时间或合并参数冲突。199 副作用:200 登记窗口ID,但不运行合并或生成片段。201 """202 # 引用只允许当前计划规则。203 catalog = {item.rule_id: item for item in rules}204 # 结果保持声明顺序。205 result = []206 # 每项范围和合并约束都先于执行。207 for index, raw in enumerate(array_at(value, '$.windows')):208 # 错误关联具体窗口。209 path = f'$.windows[{index}]'210 if not usable_node(path, raw):211 continue212 try:213 path = f'$.windows[{index}]'214 # 没有自由metadata或未定义延迟参数。215 raw = object_at(raw, path, ('name', 'rule'), ('anchor', 'pre', 'post', 'merge', 'exit_delay'))216 # 窗口ID不能与其他节点冲突。217 window_id = register_id(raw['name'], path + '.name', ids)218 # 事件关联不是Python属性语法。219 event = text_at(raw['rule'], path + '.rule', identifier=True)220 # 未知规则不能通过空窗口回退。221 if event not in catalog:222 # 不自动生成新规则或选择首个事件。223 raise PlanValidationError(path + '.rule', f'未知规则:{event}','invalid_reference',224 hint='填写rules中已声明的name:'+', '.join(catalog))225 # 活动锚点和真实point严格区分。226 # condition_duration及迟滞/防抖输出活动;窗口变化与普通边沿输出点。227 expected = 'activity' if catalog[event].temporal_rule_type == 'condition_duration' else 'point'228 # 事件已唯一决定锚点,只有显式填写时才需要核对一致性。229 anchor = choice_at(raw.get('anchor', expected), ('point', 'activity'), path + '.anchor')230 # 不能靠post窗口把point假称活动。231 if anchor != expected:232 # 保存事件本来语义。233 raise PlanValidationError(path + '.anchor', f'{catalog[event].rule_type}要求anchor={expected}')234 # pre/post不允许负数,包括量化前负小值。235 pre = duration_at(raw.get('pre', '0s'), path + '.pre')236 # 固定窗口抑制以post声明为准。237 # start_only的窗口继承规则时长,其他窗口未扩展的一侧为零。238 default_post = f'{catalog[event].window_duration_ns}ns' if catalog[event].rule_type == 'start_only' else '0s'239 post = duration_at(raw.get('post', default_post), path + '.post')240 # 合并政策只接受设计明确列出的种类。241 merge = choice_at(raw.get('merge', 'none'), ('none', 'overlap', 'overlap_and_adjacent', 'exit_delay'), path + '.merge')242 # exit_delay不能与其他merge模式一起隐藏。243 if (merge == 'exit_delay') != ('exit_delay' in raw):244 # 默认值不能使缺关键参数悄悄成功。245 raise PlanValidationError(path + '.exit_delay', 'exit_delay字段仅且必须用于同名merge模式')246 # exit_delay活动只扩合并上下文,不写回原事件统计区间。247 if merge == 'exit_delay' and (anchor != 'activity' or post != 0):248 # 不允许post再次扩大single分组。249 raise PlanValidationError(path + '.merge', 'exit_delay要求activity且post=0')250 # 关联start_only固定post不能改变抑制时长。251 if catalog[event].rule_type == 'start_only' and post != catalog[event].window_duration_ns:252 # 终点到期不自动重触发的运行政策由WINDOW验证。253 raise PlanValidationError(path + '.post', 'start_only关联post必须等于window_duration')254 # 延迟存在才解析,保留未使用None。255 delay = duration_at(raw['exit_delay'], path + '.exit_delay') if merge == 'exit_delay' else None256 # 不运行活动或合并事件,仅保存参数。257 result.append(WindowPlan(window_id, event, anchor, pre, post, merge, delay))258 except PlanValidationError as error:259 collect_node_error(error, path, raw)260 # 完整冻结声明顺序。261 return tuple(result)262263264def parse_calculations(value, bindings, derived, rules, windows, execution, ids) -> tuple[CalculationPlan, ...]:265 """检查指标统计范围、信号、条件、单位和覆盖政策。266267 参数:268 value (list): 指标配置数组。269 bindings (DeclarationBindings): 当前名称及来源同步声明。270 derived (tuple): 完整派生定义,用于静态依赖展开。271 rules (tuple[RulePlan, ...]): 原事件范围可引用的规则。272 windows (tuple[WindowPlan, ...]): 片段范围可引用的窗口。273 execution (ExecutionPlan): 提供默认partial_policy。274 ids (set[str]): 当前计划节点ID集合。275 返回:276 tuple[CalculationPlan, ...]。保存全会话、原事件或片段范围,明确样本统计、277 时间统计和选择行等口径;只检查声明,不生成数值或结果行。278 异常:279 PlanValidationError: 算子、ID、引用、信号类型、条件、单位或覆盖政策错误。280 处理流程:281 范围条件、数值信号和选择条件的原始依赖一起检查同步声明。282 select保留原单位;积分仅接受左侧保持'zoh';其他算子拒绝method字段。283 """284 # 当前计划范围引用不继承其他任务。285 rule_map, window_map = {item.rule_id: item for item in rules}, {item.window_id: item for item in windows}286 # 节点顺序与声明一致。287 result = []288 # 逐配置节点处理,不是逐样本数值计算。289 for index, raw in enumerate(array_at(value, '$.calculations')):290 # 数组错误位置明确。291 path = f'$.calculations[{index}]'292 if not usable_node(path, raw):293 continue294 try:295 path = f'$.calculations[{index}]'296 # 非integral的method字段稍后明确拒绝。297 version = getattr(bindings, 'config_version', 4)298 count_target = None299 is_object = isinstance(raw, dict) and raw.get('operation') in OBJECT_COUNTS300 if is_object:301 target_key = 'window' if raw['operation'] == 'segment_count' else 'rule'302 raw = object_at(raw, path, ('name', 'operation', target_key), ('scope', 'unit', 'partial_policy'))303 count_target = text_at(raw[target_key], path + '.' + target_key)304 targets = window_map if target_key == 'window' else rule_map305 if count_target not in targets:306 raise PlanValidationError(path + '.' + target_key, '对象计数引用未声明的规则或窗口')307 if target_key == 'rule':308 activity = rule_map[count_target].temporal_rule_type == 'condition_duration'309 if activity != (raw['operation'] == 'activity_count'):310 raise PlanValidationError(path + '.rule', '被计规则须产生相应的点事件或活动')311 else:312 optional = ('scope', 'expr', 'condition', 'unit', 'method', 'partial_policy', 'minimum_coverage')313 if isinstance(raw, dict) and raw.get('operation') in WITH_DDOF:314 optional += ('ddof',)315 if isinstance(raw, dict) and raw.get('operation') == 'sample_quantile':316 optional += ('q',)317 raw = object_at(raw, path, ('name', 'operation'), optional)318 # 所有节点ID唯一,不能覆盖窗口或规则。319 calculation_id = register_id(raw['name'], path + '.name', ids)320 # 模糊mean/min/max不进入执行计划。321 supported = ('select', 'sample_minimum', 'sample_maximum', 'sample_mean', 'min_time', 'max_time', 'time_weighted_mean', 'duration', 'integral')322 extensions = bindings.extensions.reductions if bindings.extensions is not None else {}323 operation = choice_at(raw['operation'], supported + (COUNT_OPERATIONS + MOMENTS + SAMPLE_QUANTILES) + tuple(extensions), path + '.operation')324 hosted=extensions.get(operation)325 q = raw.get('q')326 if operation in SAMPLE_QUANTILES:327 model_at(quantile_probability, path + '.q', operation, q)328 ddof = model_at(moment_ddof, path + '.ddof', operation,329 number_at(raw['ddof'], path + '.ddof', integer=True) if 'ddof' in raw else None)330 # scope是配置前缀语法,不交给PythonDSL解析。331 scope = text_at(raw.get('scope', 'session'), path + '.scope')332 if is_object and scope != 'session':333 raise PlanValidationError(path + '.scope', '对象计数的首版范围为session')334 # 定义统计范围的条件依赖也加入来源同步声明检查。335 dependencies = set()336 # 全会话范围使用session,只有事件或片段范围需要引用ID。337 if scope != 'session':338 # 仅支持明确event/segment前缀和现有ID。339 prefix, separator, target = scope.partition(':')340 # 引用前缀和目标必须正确且唯一。341 if not separator or prefix not in ('event', 'segment') or target not in (rule_map if prefix == 'event' else window_map):342 # 不自动选首个事件/片段。343 raise PlanValidationError(path + '.scope', 'scope须为session或已定义的{rule: 名称}/{window: 名称}',344 'invalid_reference',hint='核对统计范围;可用值:'+', '.join(345 ['session']+['{rule: '+name+'}' for name in rule_map]+['{window: '+name+'}' for name in window_map]))346 # segment实际由窗口所关联规则产生。347 event = target if prefix == 'event' else window_map[target].event348 # 指标值与产生范围的条件也必须同步。349 dependencies.update(rule_map[event].dependencies)350 # 明确选择政策,不将低覆盖补成已知。351 partial = choice_at(raw.get('partial_policy', execution.partial_policy), ('report', 'strict'), path + '.partial_policy')352 # minimum_coverage只作质量门槛,保留原数值。353 coverage = number_at(raw.get('minimum_coverage', 0), path + '.minimum_coverage', minimum=0, maximum=1)354 # condition可选,但存在时必须BOOLDSL。355 condition = expression_at(raw['condition'], path + '.condition', bindings, derived, boolean=True) if 'condition' in raw else None356 # expr统一接收简单别名或白名单表达式;依赖和单位仍按现有编译器检查。357 name = text_at(raw['expr'], path + '.expr') if 'expr' in raw else None358 # 只编译受限表达式,不执行Python或属性访问。359 signal = expression_at(name, path + '.expr', bindings, derived,unit=hosted.input.unit if hosted else None) if name is not None else None360 if hosted and signal is not None and signal.root.dtype is not hosted.input.dtype:361 raise PlanValidationError(path+'.expr','宿主归约输入 dtype 与描述不符')362 # duration选择BOOL信号或明确条件,避免两个持续条件含义歧义。363 if is_object:364 unit = choice_at(raw.get('unit', '1'), ('1',), path + '.unit')365 elif operation == 'duration':366 # BOOL值不经过数值truthiness。367 if (signal is None) == (condition is None) or signal is not None and signal.root.dtype is not D.BOOL:368 # 一项必要条件必须明确。369 raise PlanValidationError(path, 'duration必须且只能提供BOOL expr或condition')370 # 设计首版持续结果统一秒。371 unit = choice_at(raw.get('unit', 's'), ('s',), path + '.unit')372 else:373 # select和数值统计都必须有实际列声明。374 if signal is None:375 # 条件本身不能替代数值输入。376 raise PlanValidationError(path + '.expr', '该指标必须提供expr')377 # select允许保留BOOL/ENUM行,其余数值口径明确排除。378 if operation != 'select' and signal.root.dtype not in (D.INT64, D.UINT64, D.FLOAT64):379 # 不把业务标签或bool隐式数值化。380 raise PlanValidationError(path + '.expr', '数值指标必须int64/uint64/float64')381 # 选择行流与标量使用同一完整转换描述符检查目标单位。382 version = getattr(bindings, 'config_version', 4)383 if operation in ('integral', 'sample_rms', 'time_rms'):384 # 默认显示规范化不能绕过摄氏直接积分限制;显式to_unit(K)是用户已选尺度。385 input_root = signal.root386 while input_root.operator == 'unit_convert' and not input_root.unit_explicit:387 input_root = input_root.args[0]388 input_unit = model_at(resolve_unit, path + '.expr', input_root.unit)389 if input_unit is not None and input_unit.offset:390 raise PlanValidationError(path + '.expr', f'摄氏绝对温度{operation}请先显式to_unit(...,"K")')391 if hosted is not None:392 # 已选真实样本的单位转换在输入表达式完成;归约输出可能属于不同量纲。393 unit_spec = CalculationDefinition(calculation_id,'sample_minimum',Interval(0,0),394 output_unit=raw.get('unit',hosted.output.unit),config_version=4)395 output = model_at(calculation_conversion,path+'.unit',unit_spec,hosted.output.unit)396 model_at(output.dtype,path+'.unit',hosted.output.dtype)397 unit=output.unit398 else:399 spec = model_at(CalculationDefinition, path, calculation_id, operation, Interval(0, 0),400 partial, raw.get('unit'), config_version=version, ddof=ddof, q=q)401 conversion = model_at(calculation_conversion, path + '.unit', spec, signal.root.unit)402 # 在开始读取前拒绝BOOL/ENUM换数值单位,目标值类型由系数静态决定。403 if operation == 'select':404 model_at(conversion.dtype, path + '.unit', signal.root.dtype)405 unit = conversion.unit406 # method只允许积分zoh,不静默忽略其他算子字段。407 if 'method' in raw and operation != 'integral':408 # 模糊method可能改变业务统计含义。409 raise PlanValidationError(path + '.method', 'method仅用于integral')410 # 首版积分固定左侧保持,不开放未实现插值。411 method = choice_at(raw.get('method', 'zoh'), ('zoh',), path + '.method') if operation == 'integral' else None412 # 值与条件依赖都包含在当前节点同步证明中。413 for expression in (signal, condition):414 # 未配置条件不制造额外来源。415 if expression is not None:416 # 使用已经展开的原信号依赖。417 dependencies.update(expression.dependencies)418 # 条件和统计范围跨来源时,也须声明共同时间起点。419 model_at(bindings.require_synchronized, path, tuple(sorted(dependencies)))420 # 保存明确统计范围,窗口合并不能改变原事件指标的区间。421 result.append(CalculationPlan(calculation_id, scope, operation, signal, condition, unit, partial, coverage,422 method, count_target, ddof=ddof, q=q))423 except PlanValidationError as error:424 collect_node_error(error, path, raw)425 # 后续运行只读这些声明,不持有用户数组。426 return tuple(result)427