Chronaxis用户手册English
本页目录

chronaxis.presets

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""校验领域预设v1的角色契约,展开为可序列化的类型化公共声明。23输入PresetSpec与当前SignalDefinition tuple,输出PlanFragment;不读取来源或执行分析。4先严格校验参数,再显式优先解析角色/检查转换后契约,最后生成简单别名表达式和节点。5候选语义名只属于本模块的版本化契约,不是通用核心领域目录或行业标准。6声明会交给AnalysisPlan正常编译;不使用eval、不推断方向/位置/阈值或补造真实数据。7输入/输出均不可变;JSON序列化返回独立副本。执行、资源预算和报文导出由执行层负责。8"""910# 算法与公开声明模型分开,避免AnalysisPlan/构建器循环。11from .core.binding import SignalDefinition12from .core.expression import DerivedDefinition13from .core.types import SignalDType as D14from .core.windows import WindowSpec15from .planning.checks import (PlanValidationError, choice_at, duration_at, json_value,16                              array_at, number_at, object_at, text_at)17from .planning.preset_models import (RoleContract, RoleBinding, PresetSpec, PresetRule,18                                    PresetCalculation, PlanFragment, CHARGE_ROLES, FAULT_ROLES, COMMAND_ROLES)19from .planning.sequences import SequencePlan20from .timebase.conversion import INT64_MIN, INT64_MAX212223def _parameters(spec, signals=()):24    """按kind检查工程参数,保持原数值并将时长转换为整数ns。2526    参数: spec为PresetSpec;参数值来自独立JSON快照。27    返回: 新dict,仅内部使用已校验参数,不作为公开无类型片段。28    异常: PlanValidationError为缺项、未知参数、bool伪数值、非有限或错误范围。29    """30    # 将不可变快照还原为局部原生对象,严格拒绝未声明参数。31    path = '$.preset.parameters'32    if spec.preset_type == 'charge_session':33        raw = object_at(json_value(spec.parameters), path, ('mode_value', 'current_min', 'minimum_duration'),34                        ('compute_energy', 'compute_charge', 'minimum_coverage', 'partial_policy'))35        result = {'mode_value': number_at(raw['mode_value'], path + '.mode_value', integer=True),36                  'current_min': number_at(raw['current_min'], path + '.current_min'),37                  'minimum_duration': duration_at(raw['minimum_duration'], path + '.minimum_duration'),38                  'compute_energy': choice_at(raw.get('compute_energy', False), (False, True), path + '.compute_energy'),39                  'compute_charge': choice_at(raw.get('compute_charge', False), (False, True), path + '.compute_charge'),40                  'minimum_coverage': number_at(raw.get('minimum_coverage', 0), path + '.minimum_coverage', minimum=0, maximum=1),41                  'partial_policy': choice_at(raw['partial_policy'], ('report', 'strict'), path + '.partial_policy') if 'partial_policy' in raw else None}42    elif spec.preset_type == 'fault_context':43        raw = object_at(json_value(spec.parameters), path, (), ('pre', 'post', 'emit_initial'))44        result = {'pre': duration_at(raw.get('pre', '0s'), path + '.pre'), 'post': duration_at(raw.get('post', '0s'), path + '.post'),45                  'emit_initial': choice_at(raw.get('emit_initial', False), (False, True), path + '.emit_initial')}46        if result['pre'] == result['post'] == 0:47            raise PlanValidationError(path, 'fault_context至少一个pre/post为正')48    elif spec.preset_type == 'threshold_excursion':49        # v1明确高阈值进入、低阈值退出;拒绝重叠,不能在阈值相等时猜优先级。50        raw = object_at(json_value(spec.parameters), path, ('enter_threshold', 'exit_threshold'),51                        ('unit', 'enter_delay', 'exit_delay'))52        if 'unit' in raw:53            unit = text_at(raw['unit'], path + '.unit')54        else:55            alias = spec.role_bindings.get('signal')56            candidates = [item for item in signals if item.name == alias]57            if len(candidates) != 1:58                raise PlanValidationError('$.preset.role_bindings.signal', '请指定唯一的已绑定信号以继承单位')59            unit = candidates[0].unit60        result = dict(unit=unit,61            enter_threshold=number_at(raw['enter_threshold'], path + '.enter_threshold'),62            exit_threshold=number_at(raw['exit_threshold'], path + '.exit_threshold'),63            enter_delay=duration_at(raw.get('enter_delay', '0ns'), path + '.enter_delay'),64            exit_delay=duration_at(raw.get('exit_delay', '0ns'), path + '.exit_delay'))65        if result['enter_threshold'] <= result['exit_threshold']:66            raise PlanValidationError(path, '进入阈值必须大于退出阈值;v1高于进入、低于退出,均含等号')67    else:68        raw = object_at(json_value(spec.parameters), path, ('max_delay',), ('min_delay', 'emit_initial', 'max_pending'))69        result = dict(min_delay=duration_at(raw.get('min_delay', '0s'), path + '.min_delay'),70            max_delay=duration_at(raw['max_delay'], path + '.max_delay'),71            emit_initial=choice_at(raw.get('emit_initial', False), (False, True), path + '.emit_initial'),72            max_pending=number_at(raw.get('max_pending', 65536), path + '.max_pending', integer=True, minimum=1))73        if result['min_delay'] > result['max_delay']:74            raise PlanValidationError(path, 'min_delay不能大于max_delay')75    return result767778def _mismatches(definition, contract, *, generic_unit=False):79    """列出转换后声明未满足的角色字段,不能用自由metadata放宽质量。8081    参数: definition为SignalDefinition;contract为本v1角色;generic_unit允许明确同名无转换的自定义单位。82    返回: tuple[str, ...],空表示字段契约通过,不证明真实数据正确。83    异常: 无主动异常;输入类型由公共解析入口证明。84    """85    # 逐字段诊断方便缺项/歧义时列出候选不满足原因。86    errors = []87    if definition.dtype not in contract.dtypes:88        errors.append('dtype须为' + '/'.join(item.value for item in contract.dtypes))89    identity_unit = (generic_unit and definition.input_unit == definition.unit90                     and definition.scale == 1 and definition.offset == 0 and definition.direction_factor == 1)91    if definition.unit != contract.unit or definition.unit_status != 'verified' and not identity_unit:92        errors.append(f'目标unit须为{contract.unit}且关系verified')93    if contract.sign_convention is not None and definition.sign_convention != contract.sign_convention:94        errors.append('sign_convention须为' + contract.sign_convention)95    if contract.measurement_location is not None and definition.measurement_location != contract.measurement_location:96        errors.append('measurement_location须为' + contract.measurement_location)97    # DBC默认有效期在运行启动时核实;声明阶段不为角色校验额外加载DBC。98    if (definition.valid_for_ns is not None and definition.valid_for_ns <= 099            or definition.sample_policy != 'backward_hold' or definition.identity()[-1] != contract.forbidden_quality):100        errors.append('正valid_for/backward_hold/固定基础质量策略未满足')101    return tuple(errors)102103104def resolve_preset_roles(spec: PresetSpec, signals: tuple[SignalDefinition, ...]) -> tuple[RoleBinding, ...]:105    """显式优先解析本次实际角色,否则按版本化语义契约寻找唯一合格候选。106107    参数: spec为公开不可变预设;signals为当前计划SignalDefinition tuple。108    返回: RoleBinding tuple,保留原定义与explicit/semantic_unique路径。109    异常: PlanValidationError为类型、重复别名、未知角色、缺项、候选歧义或契约冲突。110    边界: 显式别名无需semantic_id,仍检查类型/单位/方向/位置/质量;不选第一个候选。111    """112    if not isinstance(spec, PresetSpec) or not isinstance(signals, tuple) or any(not isinstance(item, SignalDefinition) for item in signals):113        raise PlanValidationError('$.preset', '需要PresetSpec与SignalDefinition tuple')114    parameters = _parameters(spec, signals)115    if spec.preset_type == 'threshold_excursion':116        # 任意物理量显式绑定;未填单位则继承该信号,不猜温度等领域名称。117        if 'signal' not in spec.role_bindings:118            raise PlanValidationError('$.preset.role_bindings.signal', 'threshold_excursion必须显式指定signal角色')119        from .planning.preset_models import RoleContract120        contracts = (RoleContract('signal', (D.INT64, D.UINT64, D.FLOAT64), parameters['unit'], 'threshold.signal'),)121    else:122        contracts = {'charge_session': CHARGE_ROLES, 'fault_context': FAULT_ROLES,123                     'command_response': COMMAND_ROLES}[spec.preset_type]124    catalog = {item.name: item for item in signals}125    if len(catalog) != len(signals):126        raise PlanValidationError('$.signals', 'signal别名重复')127    unknown = set(spec.role_bindings) - {item.role for item in contracts}128    if unknown:129        raise PlanValidationError('$.preset.role_bindings', '未知角色:' + ', '.join(sorted(unknown)))130    result = []131    for contract in contracts:132        # voltage只在请求能量或明确提供该可选绑定时检查,不强迫无能量任务配置电压。133        if contract.role == 'voltage' and not parameters['compute_energy'] and contract.role not in spec.role_bindings:134            continue135        path = '$.preset.role_bindings.' + contract.role136        if contract.role in spec.role_bindings:137            alias = spec.role_bindings[contract.role]138            if alias not in catalog:139                raise PlanValidationError(path, f'未知别名:{alias};可用:{", ".join(catalog)}')140            definition = catalog[alias]141            errors = _mismatches(definition, contract, generic_unit=spec.preset_type == 'threshold_excursion')142            if errors:143                raise PlanValidationError(path, f'{alias}契约不满足:{"; ".join(errors)}')144            result.append(RoleBinding(contract, definition, 'explicit'))145        else:146            # 只取本模块公开候选标识/版本,所有合格候选统计完才决定。147            candidates = [item for item in signals if item.semantic_id == contract.semantic_id]148            valid = [item for item in candidates if item.semantic_contract_version == contract.version and not _mismatches(item, contract)]149            if len(valid) != 1:150                details = '; '.join(item.name + ': ' + ', '.join(_mismatches(item, contract) +151                           (() if item.semantic_contract_version == contract.version else ('semantic_contract_version须为1',))) for item in candidates)152                reason = '歧义候选:' + ', '.join(item.name for item in valid) if valid else '缺少符合契约的候选'153                raise PlanValidationError(path, reason + f'({contract.semantic_id} v1);' + details)154            result.append(RoleBinding(contract, valid[0], 'semantic_unique'))155    return tuple(result)156157158def build_preset(spec: PresetSpec, signals: tuple[SignalDefinition, ...]) -> PlanFragment:159    """校验v1预设并展开公共声明,不执行I/O或数值分析。160161    参数: spec为PresetSpec,保存id/preset_type/version/显式角色及参数;signals为当前类型化信号tuple。162    返回: PlanFragment,生成请求的功率/Wh/Ah、报警、阈值迟滞/逐段时长或响应配对。163    异常: PlanValidationError为参数/角色契约、模式编码范围及生成ID与信号冲突。164    流程: 检查角色→生成简单别名DSL→构造类型化节点→拒绝当前可见ID冲突。165        用户其他节点/多个预设的冲突由AnalysisPlan合入时继续检查。166    """167    resolved = resolve_preset_roles(spec, signals)168    roles = {item.contract.role: item.definition for item in resolved}169    parameters = _parameters(spec, signals)170    if spec.preset_type == 'charge_session':171        mode, current = roles['mode'], roles['current']172        minimum, maximum = (INT64_MIN, INT64_MAX) if mode.dtype is D.INT64 else (0, 2**64-1)173        code = parameters['mode_value']174        if not minimum <= code <= maximum or mode.valid_range is not None and not mode.valid_range[0] <= code <= mode.valid_range[1]:175            raise PlanValidationError('$.preset.parameters.mode_value', '模式编码超出已声明dtype/valid_range')176        # 只拼接已验证别名与原生有限数值字面量,不能注入属性或任意函数。177        condition = f'{mode.name} == {code!r} and {current.name} > {parameters["current_min"]!r}'178        rule = PresetRule(spec.id + '_activity', 'condition_duration', condition, parameters['minimum_duration'], 'onset')179        derived, calculations = [], []180        for requested, suffix, signal, unit in (181                (parameters['compute_energy'], 'energy', spec.id + '_power', 'Wh'),182                (parameters['compute_charge'], 'charge', current.name, 'Ah')):183            if requested:184                calculations.append(PresetCalculation(spec.id + '_' + suffix, 'event:' + rule.id, signal, unit,185                                           parameters['minimum_coverage'], parameters['partial_policy']))186        if parameters['compute_energy']:187            derived.append(DerivedDefinition(spec.id + '_power', roles['voltage'].name + ' * ' + current.name, 'W'))188        fragment = PlanFragment(tuple(derived), (rule,), (), tuple(calculations))189    elif spec.preset_type == 'fault_context':190        rule = PresetRule(spec.id + '_alarm', 'rising_edge', roles['alarm'].name, emit_initial=parameters['emit_initial'])191        window = WindowSpec(spec.id + '_context', rule.id, 'point', parameters['pre'], parameters['post'])192        fragment = PlanFragment((), (rule,), (window,), ())193    elif spec.preset_type == 'threshold_excursion':194        # 普通迟滞规则负责全部状态,逐段时长复用原duration核;总计由同一次状态推进保存。195        alias = roles['signal'].name196        rule = PresetRule(spec.id + '_activity', 'hysteresis', None,197            enter_condition=f'{alias} >= {parameters["enter_threshold"]!r}',198            exit_condition=f'{alias} <= {parameters["exit_threshold"]!r}',199            enter_delay_ns=parameters['enter_delay'], exit_delay_ns=parameters['exit_delay'])200        calculation = PresetCalculation(spec.id + '_duration', 'event:' + rule.id, None, 's',201                              operation='duration', condition='True')202        fragment = PlanFragment(rules=(rule,), calculations=(calculation,))203    else:204        # 布尔角色变为两条普通rising_edge规则,配对仍走公开SequencePlan,没有隐藏计算器。205        request = PresetRule(spec.id + '_request', 'rising_edge', roles['request'].name, emit_initial=parameters['emit_initial'])206        response = PresetRule(spec.id + '_response', 'rising_edge', roles['response'].name, emit_initial=parameters['emit_initial'])207        sequence = SequencePlan(spec.id + '_pair', request.id, response.id,208            parameters['min_delay'], parameters['max_delay'], parameters['max_pending'])209        fragment = PlanFragment(rules=(request, response), sequences=(sequence,))210    # 不自动改生成名称,当前signal碰撞在公共构建器立即拒绝。211    generated = [item.name for item in fragment.derived] + [item.id for item in fragment.rules] + [item.window_id for item in fragment.windows] + [item.id for item in fragment.calculations]212    generated.extend(item.sequence_id for item in fragment.sequences)213    conflicts = set(generated) & {item.name for item in signals}214    if conflicts:215        raise PlanValidationError('$.preset.name', '生成ID冲突:' + ', '.join(sorted(conflicts)))216    return fragment217218219def expand_presets(config, signals):220    """在独立配置副本中合入预设片段,保留用户节点并拒绝全部生成ID冲突。221222    参数: config为dict,是调用方复制后的配置映射;本函数原地合入生成节点。223        signals为tuple[SignalDefinition, ...],提供当前信号定义和预设角色匹配依据。224    返回: (PresetSpec tuple, 各预设RoleBinding tuple),供计划保留原声明及角色追溯。225    异常: PlanValidationError为预设/用户结构或生成ID与用户/其他预设重名。226    副作用: 仅修改传入的局部derived/rules/windows/calculations/sequences;不修改raw_config快照或来源。227    """228    raw_derived = config.get('derived', {})229    if type(raw_derived) is not dict:230        raise PlanValidationError('$.derived', 'derived必须为别名对象')231    # 全部当前用户名称及节点ID都保留,生成项不得隐藏覆盖。232    occupied = {item.name for item in signals} | set(raw_derived)233    arrays = {}234    for key in ('rules', 'windows', 'calculations', 'sequences'):235        arrays[key] = array_at(config.get(key, []), '$.' + key)236        for index, row in enumerate(arrays[key]):237            if type(row) is dict and 'id' in row:238                raise PlanValidationError(f'$.{key}[{index}].id', '名称字段已统一为name,请替换id', 'unknown_field')239            if type(row) is not dict or 'name' not in row:240                raise PlanValidationError(f'$.{key}[{index}]', '节点必须包含name')241            occupied.add(text_at(row['name'], f'$.{key}[{index}].name', identifier=True))242    specs, bindings, preset_ids = [], [], set()243    for index, raw in enumerate(array_at(config.get('presets', []), '$.presets')):244        path = f'$.presets[{index}]'245        try:246            spec = PresetSpec.from_dict(raw)247            if spec.id in preset_ids:248                raise PlanValidationError('$.preset.name', 'preset id重复:' + spec.id)249            fragment = build_preset(spec, signals)250            bound = resolve_preset_roles(spec, signals)251            # to_dict承诺可直接合入用户配置;内部展开再转换到同一解析器形式。252            from .planning.config_groups import normalize_configuration253            serialized = normalize_configuration({'sources': [], 'signals': [], **fragment.to_dict()})254            generated = set(serialized['derived']) | {item['name'] for key in arrays for item in serialized.get(key, ())}255            if generated & occupied:256                raise PlanValidationError('$.preset.name', '生成ID冲突:' + ', '.join(sorted(generated & occupied)))257        except PlanValidationError as error:258            # 外层配置位置替换统一公共spec路径,不丢角色/参数诊断。259            raise PlanValidationError(error.path.replace('$.preset', path, 1), error.message, error.code) from error260        # 校验整个片段后再合入,失败不发布半个公开计划。261        raw_derived.update(serialized['derived'])262        for key in arrays:263            arrays[key].extend(serialized.get(key, ()))264        occupied.update(generated)265        preset_ids.add(spec.id)266        specs.append(spec)267        bindings.append(bound)268    config['derived'] = raw_derived269    config.update(arrays)270    return tuple(specs), tuple(bindings)271
Chronaxis 用户手册 · 离线阅读