[migrate]:[FL-21][防雷分析普通/同跳计算接入ATP执行链路]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-07 19:30:47 +08:00
parent 90aba88da0
commit e21472bbd9
5 changed files with 1116 additions and 9 deletions
+4 -1
View File
@@ -89,7 +89,10 @@ class FlAnalysisJobCreateRequest(BaseModel):
job_name: str | None = Field(default=None, min_length=1, max_length=255)
job_type: FlAnalysisJobType = "normal"
external_adapter: FlAnalysisAdapter = "placeholder"
adapter_config_json: dict[str, Any] = Field(default_factory=dict)
adapter_config_json: dict[str, Any] = Field(
default_factory=dict,
description="normal/tongtiao 任务在 external_adapter=atp/wine 时可传 model_id、version_id/version_no、result_file、parameter_bindings 等 ATP 执行配置。",
)
execution_options_json: dict[str, Any] = Field(
default_factory=dict,
description="normal/tongtiao 任务可传波形、闪络判据以及波头/波尾扫描参数;mitigation 任务可传 source_job_id、selected_tower_ids、non_construction。",
+610
View File
@@ -0,0 +1,610 @@
from __future__ import annotations
import json
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from ..models.atp_model import AtpModel, AtpModelVersion
from ..models.base import utcnow
from ..models.fl_analysis import FlAnalysisJob, FlAnalysisTowerSnapshot
from ..schemas.atp_model import AtpSimulationRunRequest
from .atp_model_service import (
_resolve_engine_workdir,
_resolve_native_engine_executable,
_resolve_target_version,
_resolve_timeout,
_resolve_wine_engine_executable,
_safe_entry_filename,
_truncate_output,
get_model_by_id,
)
PLACEHOLDER_PATTERN = re.compile(r"{{\s*([^{}]+?)\s*}}")
DEFAULT_JSON_MARKERS = ("FL_ANALYSIS_RESULT_BEGIN", "FL_ANALYSIS_RESULT_END")
LEGACY_RESULT_KEY_MAP = {
"反击耐雷水平": "counterstrike_withstand_ka",
"反击跳闸率": "counterstrike_trip_rate",
"绕击耐雷水平": "shielding_withstand_ka",
"绕击跳闸率": "shielding_trip_rate",
"雷击风险等级": "risk_grade",
"风险等级": "risk_grade",
"单相闪络相": "flashover_phase",
"闪络相": "flashover_phase",
"主导相组": "dominant_phase_set",
"同跳跳闸率": "counterstrike_trip_rate",
}
NUMERIC_RESULT_KEYS = {
"counterstrike_withstand_ka",
"counterstrike_trip_rate",
"shielding_withstand_ka",
"shielding_trip_rate",
"score",
"risk_grade",
}
@dataclass(slots=True)
class ResolvedExternalWaveformJob:
adapter: str
model: AtpModel
version: AtpModelVersion
timeout_seconds: int
extra_args: list[str]
environment: dict[str, str]
contract: dict[str, Any]
parameter_bindings: dict[str, Any]
@dataclass(slots=True)
class ExternalTowerExecutionResult:
result_json: dict[str, Any]
engine_command: str
working_dir: str
stdout_text: str | None
stderr_text: str | None
def resolve_external_waveform_job(
db: Session,
*,
external_adapter: str,
adapter_config_json: dict[str, Any],
) -> ResolvedExternalWaveformJob:
adapter = str(external_adapter or "").strip().lower()
if adapter not in {"atp", "wine"}:
raise RuntimeError(f"Unsupported external adapter: {external_adapter}")
config = dict(adapter_config_json or {})
model_id = str(config.get("model_id") or "").strip()
if not model_id:
raise RuntimeError("外部 ATP/Wine 任务缺少 adapter_config_json.model_id")
model = get_model_by_id(db, model_id)
if model is None:
raise RuntimeError(f"指定的 ATP 模型不存在: {model_id}")
version_id = str(config.get("version_id") or "").strip() or None
version_no = _coerce_positive_int(config.get("version_no"))
version = _resolve_target_version(
db,
model=model,
payload=AtpSimulationRunRequest(version_id=version_id, version_no=version_no),
)
if not (version.atp_text or "").strip():
raise RuntimeError(f"ATP 模型版本缺少可执行模板: {model.code} v{version.version_no}")
manifest_contract = _read_object((version.artifact_manifest_json or {}).get("fl_analysis"))
adapter_contract = {
key: value
for key, value in config.items()
if key
in {
"result_file",
"result_format",
"result_labels",
"result_json_pointer",
"result_key_map",
"stdout_json_markers",
}
}
parameter_bindings = {
**_read_object(manifest_contract.get("parameter_bindings")),
**_read_object(config.get("parameter_bindings")),
}
contract = {
**manifest_contract,
**adapter_contract,
}
contract["parameter_bindings"] = parameter_bindings
timeout_seconds = _resolve_timeout(_coerce_positive_int(config.get("timeout_seconds")))
extra_args = [str(item).strip() for item in list(config.get("extra_args") or []) if str(item).strip()]
environment = {
str(key): str(value)
for key, value in _read_object(config.get("environment")).items()
if str(key).strip()
}
return ResolvedExternalWaveformJob(
adapter=adapter,
model=model,
version=version,
timeout_seconds=timeout_seconds,
extra_args=extra_args,
environment=environment,
contract=contract,
parameter_bindings=parameter_bindings,
)
def render_atp_template(
template: str,
*,
context: dict[str, Any],
parameter_bindings: dict[str, Any] | None = None,
) -> str:
bindings = parameter_bindings or {}
def replace_placeholder(match: re.Match[str]) -> str:
expression = match.group(1).strip()
return _render_placeholder(expression, context=context, parameter_bindings=bindings)
return PLACEHOLDER_PATTERN.sub(replace_placeholder, template)
def execute_external_waveform_tower_analysis(
resolved_job: ResolvedExternalWaveformJob,
*,
job: FlAnalysisJob,
snapshot: FlAnalysisTowerSnapshot,
execution_options: dict[str, Any],
baseline_result: dict[str, Any],
) -> ExternalTowerExecutionResult:
context = _build_template_context(
job=job,
snapshot=snapshot,
execution_options=execution_options,
baseline_result=baseline_result,
)
rendered_text = render_atp_template(
resolved_job.version.atp_text or "",
context=context,
parameter_bindings=resolved_job.parameter_bindings,
)
run_dir = _prepare_run_directory(job=job, snapshot=snapshot, resolved_job=resolved_job)
input_path = run_dir / _safe_entry_filename(
resolved_job.version.entry_file,
model_code=resolved_job.model.code,
version_no=resolved_job.version.version_no,
)
input_path.write_text(rendered_text, encoding="utf-8")
command = _build_command(
adapter=resolved_job.adapter,
input_path=input_path,
extra_args=resolved_job.extra_args,
)
env = os.environ.copy()
env.update(resolved_job.environment)
try:
result = subprocess.run(
command,
cwd=str(run_dir),
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=resolved_job.timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"{snapshot.tower_no} 外部 ATP/Wine 执行超时({resolved_job.timeout_seconds}s"
) from exc
except OSError as exc:
raise RuntimeError(f"{snapshot.tower_no} 外部 ATP/Wine 启动失败: {exc}") from exc
stdout_text = _truncate_output(result.stdout)
stderr_text = _truncate_output(result.stderr)
if result.returncode != 0:
raise RuntimeError(
f"{snapshot.tower_no} 外部 ATP/Wine 执行失败,退出码 {result.returncode}"
)
parsed_payload = _read_external_result_payload(
contract=resolved_job.contract,
stdout_text=result.stdout,
run_dir=run_dir,
)
merged_result = _merge_external_result(
baseline_result=baseline_result,
external_payload=parsed_payload,
resolved_job=resolved_job,
engine_command=" ".join(command),
working_dir=str(run_dir),
)
return ExternalTowerExecutionResult(
result_json=merged_result,
engine_command=" ".join(command),
working_dir=str(run_dir),
stdout_text=stdout_text,
stderr_text=stderr_text,
)
def _build_template_context(
*,
job: FlAnalysisJob,
snapshot: FlAnalysisTowerSnapshot,
execution_options: dict[str, Any],
baseline_result: dict[str, Any],
) -> dict[str, Any]:
return {
"job": {
"id": job.id,
"job_name": job.job_name,
"job_type": job.job_type,
"external_adapter": job.external_adapter,
},
"snapshot": {
"id": snapshot.id,
"seq_no": snapshot.seq_no,
"tower_no": snapshot.tower_no,
"tower_model": snapshot.tower_model,
"tower_type": snapshot.tower_type,
"longitude": snapshot.longitude,
"latitude": snapshot.latitude,
"altitude_m": snapshot.altitude_m,
"terrain": snapshot.terrain,
},
"base_tower": snapshot.base_tower_json or {},
"profile": snapshot.profile_json or {},
"execution_options": execution_options,
"workflow": baseline_result.get("workflow") or {},
"baseline_result": baseline_result,
}
def _prepare_run_directory(
*,
job: FlAnalysisJob,
snapshot: FlAnalysisTowerSnapshot,
resolved_job: ResolvedExternalWaveformJob,
) -> Path:
base_dir = _resolve_engine_workdir() / "fl-analysis" / job.id / snapshot.id
base_dir.mkdir(parents=True, exist_ok=True)
return base_dir
def _build_command(*, adapter: str, input_path: Path, extra_args: list[str]) -> list[str]:
if adapter == "wine":
wine_binary, engine_path, error = _resolve_wine_engine_executable()
if error or not wine_binary or not engine_path:
raise RuntimeError(error or "Wine ATP engine unavailable")
return [wine_binary, engine_path, str(input_path), *extra_args]
engine_path, error = _resolve_native_engine_executable()
if error or not engine_path:
raise RuntimeError(error or "Native ATP engine unavailable")
return [engine_path, str(input_path), *extra_args]
def _render_placeholder(
expression: str,
*,
context: dict[str, Any],
parameter_bindings: dict[str, Any],
) -> str:
if expression in parameter_bindings:
return _render_bound_placeholder(parameter_bindings[expression], context=context, name=expression)
path, spec, width, default, required = _parse_inline_expression(expression)
value = _read_path(context, path)
return _stringify_value(
value,
format_spec=spec,
width=width,
default=default,
required=required,
name=expression,
)
def _render_bound_placeholder(binding: Any, *, context: dict[str, Any], name: str) -> str:
if isinstance(binding, str):
path, spec, width, default, required = _parse_inline_expression(binding)
value = _read_path(context, path)
return _stringify_value(
value,
format_spec=spec,
width=width,
default=default,
required=required,
name=name,
)
mapping = _read_object(binding)
path = str(mapping.get("path") or mapping.get("source") or "").strip()
if not path:
raise RuntimeError(f"ATP 模板绑定缺少 path/source: {name}")
value = _read_path(context, path)
return _stringify_value(
value,
format_spec=str(mapping.get("format") or "").strip() or None,
width=_coerce_positive_int(mapping.get("width")),
default=mapping.get("default"),
required=bool(mapping.get("required", "default" not in mapping)),
name=name,
)
def _parse_inline_expression(expression: str) -> tuple[str, str | None, int | None, Any, bool]:
parts = [part.strip() for part in expression.split("|")]
path = parts[0]
spec = parts[1] if len(parts) > 1 and parts[1] else None
width = _coerce_positive_int(parts[2]) if len(parts) > 2 and parts[2] else None
if len(parts) > 3:
return path, spec, width, parts[3], False
return path, spec, width, None, True
def _stringify_value(
value: Any,
*,
format_spec: str | None,
width: int | None,
default: Any,
required: bool,
name: str,
) -> str:
if value is None:
if required:
raise RuntimeError(f"ATP 模板占位符缺少取值: {name}")
text = "" if default is None else str(default)
return text.rjust(width) if width else text
text: str
if format_spec:
text = _format_value(value, format_spec)
elif isinstance(value, bool):
text = "1" if value else "0"
else:
text = str(value)
return text.rjust(width) if width else text
def _format_value(value: Any, format_spec: str) -> str:
numeric_value = float(value)
normalized_spec = format_spec.strip()
if normalized_spec.startswith("F"):
precision = abs(int(normalized_spec[1:] or "0"))
text = f"{numeric_value:.{precision}f}"
if precision > 0:
text = text.rstrip("0").rstrip(".")
elif "." not in text:
text = f"{text}."
return text
if normalized_spec.startswith("E"):
precision = abs(int(normalized_spec[1:] or "0"))
return f"{numeric_value:.{precision}E}"
return format(value, normalized_spec)
def _read_external_result_payload(
*,
contract: dict[str, Any],
stdout_text: str,
run_dir: Path,
) -> dict[str, Any]:
result_file = str(contract.get("result_file") or "").strip()
source_text: str
if result_file:
result_path = (run_dir / result_file).resolve(strict=False)
if not result_path.is_relative_to(run_dir.resolve(strict=False)):
raise RuntimeError(f"外部结果文件越界: {result_file}")
if not result_path.exists():
raise RuntimeError(f"外部 ATP/Wine 未生成结果文件: {result_file}")
source_text = result_path.read_text(encoding="utf-8", errors="replace")
else:
source_text = stdout_text
payload = _parse_external_payload(source_text, contract)
pointer = str(contract.get("result_json_pointer") or "").strip()
if pointer:
pointed = _read_path({"payload": payload}, f"payload.{pointer}")
if not isinstance(pointed, dict):
raise RuntimeError(f"外部结果指针无效: {pointer}")
payload = pointed
if not isinstance(payload, dict):
raise RuntimeError("外部 ATP/Wine 结果不是 JSON 对象")
return _normalize_external_result_payload(payload, _read_object(contract.get("result_key_map")))
def _parse_external_payload(source_text: str, contract: dict[str, Any]) -> dict[str, Any]:
labels = [str(item).strip() for item in list(contract.get("result_labels") or []) if str(item).strip()]
if labels:
values = [item.strip() for item in re.split(r"[|\r\n]+", source_text) if item.strip()]
return {
label: _coerce_scalar(values[index]) if index < len(values) else None
for index, label in enumerate(labels)
}
result_format = str(contract.get("result_format") or "").strip().lower()
if result_format in {"key_value", "kv", "text"}:
return _parse_key_value_text(source_text)
markers = contract.get("stdout_json_markers")
if isinstance(markers, list) and len(markers) == 2:
candidate = _extract_marked_json(source_text, str(markers[0]), str(markers[1]))
if candidate is not None:
return json.loads(candidate)
candidate = source_text.strip()
if candidate.startswith("{") and candidate.endswith("}"):
return json.loads(candidate)
marked_json = _extract_marked_json(source_text, *DEFAULT_JSON_MARKERS)
if marked_json is not None:
return json.loads(marked_json)
raise RuntimeError("外部 ATP/Wine 结果无法解析为 JSON")
def _parse_key_value_text(source_text: str) -> dict[str, Any]:
result: dict[str, Any] = {}
for line in source_text.splitlines():
text = line.strip()
if not text:
continue
if "=" in text:
key, value = text.split("=", 1)
elif ":" in text:
key, value = text.split(":", 1)
else:
continue
result[key.strip()] = _coerce_scalar(value.strip())
return result
def _extract_marked_json(source_text: str, start_marker: str, end_marker: str) -> str | None:
start_index = source_text.find(start_marker)
if start_index < 0:
return None
start_index += len(start_marker)
end_index = source_text.find(end_marker, start_index)
if end_index < 0:
return None
return source_text[start_index:end_index].strip()
def _normalize_external_result_payload(
payload: dict[str, Any],
result_key_map: dict[str, Any],
) -> dict[str, Any]:
normalized: dict[str, Any] = {}
for raw_key, raw_value in payload.items():
key = str(result_key_map.get(raw_key) or raw_key).strip()
key = LEGACY_RESULT_KEY_MAP.get(key, key)
value = raw_value
if key in NUMERIC_RESULT_KEYS:
value = _coerce_scalar(raw_value)
normalized[key] = value
risk_level = normalized.get("risk_level")
if isinstance(risk_level, (int, float)) and "risk_grade" not in normalized:
normalized["risk_grade"] = int(risk_level)
normalized["risk_level"] = _risk_level_from_grade(int(risk_level))
if "risk_level" not in normalized and "risk_grade" in normalized:
grade = _coerce_positive_int(normalized.get("risk_grade")) or 1
normalized["risk_grade"] = grade
normalized["risk_level"] = _risk_level_from_grade(grade)
return normalized
def _merge_external_result(
*,
baseline_result: dict[str, Any],
external_payload: dict[str, Any],
resolved_job: ResolvedExternalWaveformJob,
engine_command: str,
working_dir: str,
) -> dict[str, Any]:
merged = dict(baseline_result)
workflow = {
**_read_object(baseline_result.get("workflow")),
**_read_object(external_payload.get("workflow")),
}
if workflow:
merged["workflow"] = workflow
for key, value in external_payload.items():
if key == "workflow" and isinstance(value, dict):
continue
merged[key] = value
if merged.get("selected_case") is None and baseline_result.get("selected_case") is not None:
merged["selected_case"] = baseline_result.get("selected_case")
if merged.get("summary_text") is None and baseline_result.get("summary_text") is not None:
merged["summary_text"] = baseline_result.get("summary_text")
if merged.get("cause_analysis") is None and baseline_result.get("cause_analysis") is not None:
merged["cause_analysis"] = baseline_result.get("cause_analysis")
if merged.get("mitigation_recommendation") is None and baseline_result.get("mitigation_recommendation") is not None:
merged["mitigation_recommendation"] = baseline_result.get("mitigation_recommendation")
external_execution = {
"adapter_status": "executed",
"adapter": resolved_job.adapter,
"model_id": resolved_job.model.id,
"model_code": resolved_job.model.code,
"model_name": resolved_job.model.name,
"version_id": resolved_job.version.id,
"version_no": resolved_job.version.version_no,
"engine_command": engine_command,
"working_dir": working_dir,
"executed_at": utcnow().isoformat(),
}
merged["external_execution"] = external_execution
merged["external_result_json"] = external_payload
return merged
def _read_path(context: dict[str, Any], path: str) -> Any:
current: Any = context
for segment in [item for item in path.split(".") if item]:
if isinstance(current, dict):
current = current.get(segment)
continue
if isinstance(current, list) and segment.isdigit():
index = int(segment)
if 0 <= index < len(current):
current = current[index]
continue
return None
return current
def _read_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return {str(key): item for key, item in value.items()}
return {}
def _coerce_positive_int(value: Any) -> int | None:
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _coerce_scalar(value: Any) -> Any:
if isinstance(value, (int, float, bool)) or value is None:
return value
text = str(value).strip()
if not text:
return text
lowered = text.lower()
if lowered in {"true", "false"}:
return lowered == "true"
try:
if any(char in text for char in {".", "e", "E"}):
return float(text)
return int(text)
except ValueError:
return text
def _risk_level_from_grade(grade: int) -> str:
if grade >= 3:
return "high"
if grade == 2:
return "medium"
return "low"
+84 -3
View File
@@ -27,6 +27,8 @@ from ..schemas.fl_analysis import (
FlAnalysisTowerResultListResponse,
FlAnalysisTowerResultSummary,
)
from .atp_model_service import _truncate_output
from .fl_analysis_external import execute_external_waveform_tower_analysis, resolve_external_waveform_job
from .fl_analysis_report import build_report_document, build_report_summary_payload
from .fl_analysis_rules import (
grade_mitigation_snapshot_payload,
@@ -180,6 +182,21 @@ def create_job(
)
or 0
)
if payload.job_type in {"normal", "tongtiao"}:
if payload.external_adapter in {"atp", "wine"}:
try:
resolve_external_waveform_job(
db,
external_adapter=payload.external_adapter,
adapter_config_json=payload.adapter_config_json or {},
)
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
elif payload.external_adapter != "placeholder":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="普通计算和同跳计算仅支持 placeholder/atp/wine 适配器",
)
if total_tower_count <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前线路没有可分析的杆塔数据")
@@ -362,6 +379,20 @@ def execute_job(job_id: str) -> None:
result_count = 0
summary = _new_result_summary()
tower_map = {tower.id: tower for tower in towers}
external_job = None
stdout_chunks: list[str] = []
stderr_chunks: list[str] = []
if job.job_type in {"normal", "tongtiao"} and job.external_adapter in {"atp", "wine"}:
external_job = resolve_external_waveform_job(
db,
external_adapter=job.external_adapter,
adapter_config_json=job.adapter_config_json or {},
)
summary["external_model_id"] = external_job.model.id
summary["external_model_code"] = external_job.model.code
summary["external_model_name"] = external_job.model.name
summary["external_version_id"] = external_job.version.id
summary["external_version_no"] = external_job.version.version_no
for snapshot in snapshots:
payload = {
"base_tower_json": snapshot.base_tower_json or {},
@@ -376,9 +407,43 @@ def execute_job(job_id: str) -> None:
non_construction=bool(execution_options.get("non_construction")),
)
elif job.job_type == "normal":
graded = grade_normal_snapshot_payload(payload, execution_options=execution_options)
baseline_result = grade_normal_snapshot_payload(payload, execution_options=execution_options)
if external_job is not None:
execution = execute_external_waveform_tower_analysis(
external_job,
job=job,
snapshot=snapshot,
execution_options=execution_options,
baseline_result=baseline_result,
)
graded = execution.result_json
run.engine_command = run.engine_command or execution.engine_command
run.working_dir = run.working_dir or execution.working_dir
if execution.stdout_text:
stdout_chunks.append(f"[{snapshot.tower_no}] {execution.stdout_text}")
if execution.stderr_text:
stderr_chunks.append(f"[{snapshot.tower_no}] {execution.stderr_text}")
else:
graded = baseline_result
elif job.job_type == "tongtiao":
graded = grade_tongtiao_snapshot_payload(payload, execution_options=execution_options)
baseline_result = grade_tongtiao_snapshot_payload(payload, execution_options=execution_options)
if external_job is not None:
execution = execute_external_waveform_tower_analysis(
external_job,
job=job,
snapshot=snapshot,
execution_options=execution_options,
baseline_result=baseline_result,
)
graded = execution.result_json
run.engine_command = run.engine_command or execution.engine_command
run.working_dir = run.working_dir or execution.working_dir
if execution.stdout_text:
stdout_chunks.append(f"[{snapshot.tower_no}] {execution.stdout_text}")
if execution.stderr_text:
stderr_chunks.append(f"[{snapshot.tower_no}] {execution.stderr_text}")
else:
graded = baseline_result
else:
graded = grade_snapshot_payload(payload)
db.add(
@@ -415,6 +480,12 @@ def execute_job(job_id: str) -> None:
summary["non_construction"] = bool(execution_options.get("non_construction"))
elif job.job_type in {"normal", "tongtiao"}:
summary["workflow"] = _workflow_summary_from_execution_options(execution_options)
if external_job is not None:
summary["external_engine_adapter"] = job.external_adapter
if stdout_chunks:
run.stdout_text = _truncate_output("\n\n".join(stdout_chunks))
if stderr_chunks:
run.stderr_text = _truncate_output("\n\n".join(stderr_chunks))
db.commit()
_finish_rule_based_run(
@@ -423,6 +494,7 @@ def execute_job(job_id: str) -> None:
run_id=run.id,
started_perf=started_perf,
summary=summary,
adapter_status="executed" if external_job is not None else "computed",
)
except Exception as exc:
@@ -448,6 +520,7 @@ def _finish_rule_based_run(
run_id: str,
started_perf: float,
summary: dict[str, Any],
adapter_status: str = "computed",
) -> None:
job = get_job_by_id(db, job_id)
run = db.execute(select(FlAnalysisRun).where(FlAnalysisRun.id == run_id)).scalar_one_or_none()
@@ -467,7 +540,7 @@ def _finish_rule_based_run(
job.error_message = None
job.result_summary_json = {
**summary,
"adapter_status": "computed",
"adapter_status": adapter_status,
"external_adapter": job.external_adapter,
}
job.finished_at = now
@@ -1236,6 +1309,14 @@ def _as_int(value: Any) -> int | None:
return None
def _as_float(value: Any) -> float | None:
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed
def _placeholder_message_for_adapter(adapter: str) -> str:
if adapter == "wine":
return "Wine 外部程序适配器已预留,真实执行链路尚未接入"