[feat]:[FL-69][接入 legacy ATP/EGM worker 适配链路]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-09 12:27:42 +08:00
parent d36aeb8636
commit d7f712e3c1
11 changed files with 1704 additions and 16 deletions
+6
View File
@@ -62,6 +62,12 @@ class Settings(BaseSettings):
atp_engine_workdir: str = "runs"
atp_engine_default_timeout_seconds: int = 600
atp_engine_max_timeout_seconds: int = 3600
atp_legacy_root: str = "./data/wine/ATP"
atp_tpbig_executable: str = "ATP/tpbig.exe"
atp_rjtzl_executable: str = "ATP/rjtzl.exe"
atp_template_root: str = "./data/wine/ATP/templates"
atp_run_root: str = "./data/wine/atp-runs"
atp_egm_subdir: str = "EGM"
initial_admin_email: str | None = None
initial_admin_user_id: str = "admin"
+1
View File
@@ -151,4 +151,5 @@ class AtpEngineStatusResponse(BaseModel):
workdir: str
default_timeout_seconds: int
max_timeout_seconds: int
checks: dict[str, dict[str, Any]] = Field(default_factory=dict)
error: str | None = None
+7 -2
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
FlAnalysisJobType = Literal["normal", "tongtiao", "risk", "mitigation", "report", "scenario"]
FlAnalysisJobStatus = Literal["pending", "queued", "running", "blocked", "success", "failed"]
FlAnalysisRunStatus = Literal["pending", "running", "blocked", "success", "failed"]
FlAnalysisAdapter = Literal["placeholder", "wine", "atp", "custom"]
FlAnalysisAdapter = Literal["placeholder", "wine", "atp", "legacy_atp", "custom"]
FlAnalysisCurrentWaveform = Literal["heidler", "double_slope", "double_exponential"]
FlAnalysisFlashoverMethod = Literal["guideline", "intersection", "leader_development"]
FlAnalysisAltitudeCorrection = Literal["none", "formula1", "formula2"]
@@ -91,7 +91,12 @@ class FlAnalysisJobCreateRequest(BaseModel):
external_adapter: FlAnalysisAdapter = "placeholder"
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 执行配置。",
description=(
"normal/tongtiao 任务在 external_adapter=atp/wine 时可传 model_id、version_id/version_no、"
"result_file、parameter_bindings 等 ATP 执行配置;"
"external_adapter=legacy_atp 时可传 model_root、template_id/template_subdir、calculation_mode、"
"use_egm、feature_bindings、result_file 等旧 ATP/EGM 资产配置。"
),
)
execution_options_json: dict[str, Any] = Field(
default_factory=dict,
+4
View File
@@ -35,6 +35,7 @@ from ..schemas.atp_model import (
AtpSimulationRunSummary,
)
from .push_service import publish_topic
from .legacy_atp_adapter import build_legacy_atp_status_checks
from .wine_probe import probe_wine_binary
@@ -183,6 +184,7 @@ def get_engine_status() -> AtpEngineStatusResponse:
mode = _resolve_engine_mode()
storage_root = str(_resolve_storage_root())
workdir = str(_resolve_engine_workdir())
checks = build_legacy_atp_status_checks()
if mode == "wine":
wine_binary, resolved_engine, error = _resolve_wine_engine_executable()
@@ -198,6 +200,7 @@ def get_engine_status() -> AtpEngineStatusResponse:
workdir=workdir,
default_timeout_seconds=settings.atp_engine_default_timeout_seconds,
max_timeout_seconds=settings.atp_engine_max_timeout_seconds,
checks=checks,
error=error,
)
@@ -211,6 +214,7 @@ def get_engine_status() -> AtpEngineStatusResponse:
workdir=workdir,
default_timeout_seconds=settings.atp_engine_default_timeout_seconds,
max_timeout_seconds=settings.atp_engine_max_timeout_seconds,
checks=checks,
error=error,
)
+65 -14
View File
@@ -33,6 +33,7 @@ from ..schemas.fl_analysis import (
)
from .atp_model_service import _truncate_output
from .fl_analysis_external import execute_external_waveform_tower_analysis, resolve_external_waveform_job
from .legacy_atp_adapter import execute_legacy_atp_tower_analysis, resolve_legacy_atp_job
from .fl_analysis_report import build_report_document, build_report_summary_payload
from .fl_analysis_rules import (
grade_mitigation_snapshot_payload,
@@ -215,10 +216,18 @@ def create_job(
)
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
elif external_adapter == "legacy_atp":
try:
resolve_legacy_atp_job(
adapter_config_json=adapter_config_json,
execution_options=execution_options,
)
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
elif external_adapter != "placeholder":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="普通计算和同跳计算仅支持 placeholder/atp/wine 适配器",
detail="普通计算和同跳计算仅支持 placeholder/atp/wine/legacy_atp 适配器",
)
if total_tower_count <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前线路没有可分析的杆塔数据")
@@ -433,19 +442,31 @@ def execute_job(job_id: str) -> None:
summary = _new_result_summary()
tower_map = {tower.id: tower for tower in towers}
external_job = None
legacy_external_job = None
stdout_chunks: list[str] = []
stderr_chunks: list[str] = []
if waveform_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
if waveform_job_type in {"normal", "tongtiao"}:
if 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
elif job.external_adapter == "legacy_atp":
legacy_external_job = resolve_legacy_atp_job(
adapter_config_json=job.adapter_config_json or {},
execution_options=execution_options,
)
summary["external_model_id"] = legacy_external_job.template_identifier
summary["external_model_code"] = legacy_external_job.calculation_mode
summary["external_model_name"] = legacy_external_job.template_dir.name
summary["external_version_id"] = legacy_external_job.template_identifier
summary["external_version_no"] = 1
for snapshot in snapshots:
payload = {
"base_tower_json": snapshot.base_tower_json or {},
@@ -478,6 +499,21 @@ def execute_job(job_id: str) -> None:
stdout_chunks.append(f"[{snapshot.tower_no}] {execution.stdout_text}")
if execution.stderr_text:
stderr_chunks.append(f"[{snapshot.tower_no}] {execution.stderr_text}")
elif legacy_external_job is not None:
execution = execute_legacy_atp_tower_analysis(
legacy_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 waveform_job_type == "tongtiao":
@@ -497,6 +533,21 @@ def execute_job(job_id: str) -> None:
stdout_chunks.append(f"[{snapshot.tower_no}] {execution.stdout_text}")
if execution.stderr_text:
stderr_chunks.append(f"[{snapshot.tower_no}] {execution.stderr_text}")
elif legacy_external_job is not None:
execution = execute_legacy_atp_tower_analysis(
legacy_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:
@@ -550,7 +601,7 @@ def execute_job(job_id: str) -> None:
summary["external_engine_adapter"] = job.external_adapter
elif waveform_job_type in {"normal", "tongtiao"}:
summary["workflow"] = _workflow_summary_from_execution_options(execution_options)
if external_job is not None:
if external_job is not None or legacy_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))
@@ -564,7 +615,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",
adapter_status="executed" if external_job is not None or legacy_external_job is not None else "computed",
)
except Exception as exc:
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.core import database as core_database
from app.core.config import get_settings
from app.core.database import Base
from app.models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun
from app.schemas.atp_model import AtpSimulationRunRequest
from app.services import atp_model_service
def _build_sessionmaker():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
bind=engine,
tables=[
AtpModel.__table__,
AtpModelVersion.__table__,
AtpSimulationRun.__table__,
],
)
return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
def test_run_model_version_dry_run_records_worker_command(monkeypatch, tmp_path) -> None:
testing_session = _build_sessionmaker()
monkeypatch.setattr(core_database, "SessionLocal", testing_session)
monkeypatch.setattr(atp_model_service, "_publish_change", lambda *args, **kwargs: None)
monkeypatch.setattr(atp_model_service, "_resolve_storage_root", lambda: tmp_path / "storage")
monkeypatch.setattr(atp_model_service, "_resolve_engine_workdir", lambda: tmp_path / "runs")
monkeypatch.setattr(
atp_model_service,
"_resolve_wine_engine_executable",
lambda: ("/usr/bin/wine", "/tmp/tpbig.exe", None),
)
session: Session = testing_session()
try:
model = AtpModel(
code="ATP-DRY-001",
name="Dry Run ATP",
source_type="atp",
status="enabled",
latest_version_no=1,
active_version_no=1,
)
session.add(model)
session.flush()
version = AtpModelVersion(
model_id=model.id,
version_no=1,
status="released",
entry_file="case.atp",
atp_text="BEGIN ATP CASE",
content_hash="dry-hash-v1",
)
session.add(version)
session.commit()
result = atp_model_service.run_model_version(
session,
model_id=model.id,
payload=AtpSimulationRunRequest(version_id=version.id, dry_run=True),
actor_user_id="tester",
)
assert result.status == "success"
assert result.engine_command is not None
assert result.engine_command.startswith("/usr/bin/wine /tmp/tpbig.exe ")
assert result.engine_command.endswith("/case.atp")
assert result.working_dir is not None
assert result.stdout_text is not None
saved = session.execute(select(AtpSimulationRun).where(AtpSimulationRun.id == result.id)).scalar_one()
assert saved.status == "success"
assert saved.exit_code == 0
assert saved.error_message is None
assert "dry_run" in (saved.stdout_text or "")
finally:
session.close()
def test_get_engine_status_includes_legacy_asset_checks(monkeypatch, tmp_path) -> None:
allowed_root = tmp_path / "wine-root"
template_root = allowed_root / "ATP" / "templates"
template_root.mkdir(parents=True)
(template_root / "EGM").mkdir()
(allowed_root / "ATP").mkdir(exist_ok=True)
(allowed_root / "ATP" / "tpbig.exe").write_text("binary", encoding="utf-8")
(allowed_root / "ATP" / "rjtzl.exe").write_text("binary", encoding="utf-8")
settings = get_settings()
monkeypatch.setattr(settings, "wine_allowed_root", str(allowed_root))
monkeypatch.setattr(settings, "atp_legacy_root", str(allowed_root / "ATP"))
monkeypatch.setattr(settings, "atp_template_root", str(template_root))
monkeypatch.setattr(settings, "atp_run_root", str(allowed_root / "runs"))
monkeypatch.setattr(settings, "atp_tpbig_executable", "ATP/tpbig.exe")
monkeypatch.setattr(settings, "atp_rjtzl_executable", "ATP/rjtzl.exe")
monkeypatch.setattr(atp_model_service, "_resolve_wine_engine_executable", lambda: ("/usr/bin/wine", "/tmp/tpbig.exe", None))
result = atp_model_service.get_engine_status()
assert "legacy_root" in result.checks
assert result.checks["legacy_root"]["available"] is True
assert result.checks["tpbig_executable"]["available"] is True
assert result.checks["rjtzl_executable"]["available"] is True
assert result.checks["egm_subdir"]["available"] is True
@@ -0,0 +1,182 @@
from __future__ import annotations
from types import SimpleNamespace
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.core.database import Base
from app.models.fl_analysis import FlAnalysisJob, FlAnalysisRun, FlAnalysisTowerResult, FlAnalysisTowerSnapshot
from app.models.line import Line
from app.models.line_tower import LineTower
from app.models.tower_profile import TowerProfile
from app.schemas.fl_analysis import FlAnalysisJobCreateRequest
from app.services import fl_analysis_service
def _build_sessionmaker():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
bind=engine,
tables=[
Line.__table__,
LineTower.__table__,
TowerProfile.__table__,
FlAnalysisJob.__table__,
FlAnalysisRun.__table__,
FlAnalysisTowerSnapshot.__table__,
FlAnalysisTowerResult.__table__,
],
)
return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
def test_execute_job_runs_legacy_atp_adapter(monkeypatch, tmp_path) -> None:
testing_session = _build_sessionmaker()
session: Session = testing_session()
try:
monkeypatch.setattr(fl_analysis_service, "SessionLocal", testing_session)
monkeypatch.setattr(fl_analysis_service, "_publish_change", lambda *args, **kwargs: None)
monkeypatch.setattr(
fl_analysis_service,
"resolve_legacy_atp_job",
lambda **_: SimpleNamespace(
template_identifier="fanji-template",
calculation_mode="fanji",
template_dir=tmp_path / "fanji",
),
)
monkeypatch.setattr(
fl_analysis_service,
"execute_legacy_atp_tower_analysis",
lambda *_args, **kwargs: SimpleNamespace(
result_json={
**kwargs["baseline_result"],
"risk_level": "medium",
"risk_grade": 2,
"summary_text": "legacy ATP执行完成",
"counterstrike_withstand_ka": 18.5,
"external_execution": {
"adapter": "legacy_atp",
"template_identifier": "fanji-template",
},
},
engine_command="wine /data/wine/ATP/tpbig.exe sample.atp",
working_dir=str(tmp_path / "runs" / "tower-1"),
stdout_text="legacy stdout",
stderr_text="legacy stderr",
),
)
line = Line(
code="L-LEGACY-1",
name="Legacy线路",
voltage_kv=220,
lightning_param_json={"雷电流幅值a": 31.0, "雷电流幅值b": 2.6},
)
session.add(line)
session.flush()
tower = LineTower(
line_id=line.id,
seq_no=1,
tower_no="N1",
tower_model="220-TEST-ZX",
tower_type="直线",
altitude_m=1680.0,
ground_resistance_ohm=12.0,
lightning_density=3.2,
span_large_m=260.0,
slope_1=3.0,
slope_2=1.5,
circuit_geometry_json={
"I": {
"phase_spacing_m": {"upper": 9.0, "middle": 4.5, "lower": 8.5},
"phase_height_m": {"upper": 29.0, "middle": 31.0, "lower": 25.0},
},
"lightning_wire": {
"left_mid_distance_m": 9.0,
"right_mid_distance_m": 9.0,
"height_m": 41.0,
},
"insulator_length_mm": 4200.0,
},
lightning_result_json={},
)
session.add(tower)
session.flush()
session.add(
TowerProfile(
tower_id=tower.id,
structure_kind="直线",
arrester_a="",
arrester_b="",
arrester_c="",
shield_wire_height_m=41.0,
insulator_length_m=4200.0,
current_a=31.0,
current_b=2.6,
current_type="Heidler",
current_head_time_us=2.6,
current_tail_time_us=50.0,
)
)
session.commit()
created = fl_analysis_service.create_job(
session,
FlAnalysisJobCreateRequest(
line_id=line.id,
job_name="普通计算-LegacyATP",
job_type="normal",
external_adapter="legacy_atp",
adapter_config_json={"template_subdir": "fanji"},
execution_options_json={
"current_waveform": "double_slope",
"flashover_method": "intersection",
"altitude_correction": "formula1",
"induced_voltage_formula": "formula2",
"head_time_min_us": 2.4,
"head_time_max_us": 2.6,
"head_time_step_us": 0.2,
"tail_time_min_us": 45.0,
"tail_time_max_us": 50.0,
"tail_time_step_us": 5.0,
},
),
actor=SimpleNamespace(id="tester"),
)
session.close()
fl_analysis_service.execute_job(created.job.id)
verify_session: Session = testing_session()
try:
saved_job = fl_analysis_service.get_job_by_id(verify_session, created.job.id)
assert saved_job is not None
assert saved_job.status == "success"
assert saved_job.result_summary_json["adapter_status"] == "executed"
assert saved_job.result_summary_json["external_engine_adapter"] == "legacy_atp"
assert saved_job.result_summary_json["external_model_id"] == "fanji-template"
result_row = verify_session.execute(
select(FlAnalysisTowerResult).where(FlAnalysisTowerResult.job_id == created.job.id)
).scalar_one()
assert result_row.risk_level == "medium"
assert result_row.summary_text == "legacy ATP执行完成"
assert result_row.result_json["counterstrike_withstand_ka"] == 18.5
assert result_row.result_json["external_execution"]["adapter"] == "legacy_atp"
saved_run = verify_session.execute(
select(FlAnalysisRun).where(FlAnalysisRun.job_id == created.job.id)
).scalar_one()
assert saved_run.status == "success"
assert saved_run.runner_kind == "legacy_atp"
assert saved_run.engine_command == "wine /data/wine/ATP/tpbig.exe sample.atp"
assert saved_run.stdout_text is not None
assert saved_run.stderr_text is not None
finally:
verify_session.close()
finally:
session.close()
@@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
import pytest
from app.core.config import get_settings
from app.services import legacy_atp_adapter
def test_resolve_legacy_atp_job_rejects_paths_outside_allowed_root(monkeypatch, tmp_path: Path) -> None:
settings = get_settings()
monkeypatch.setattr(settings, "wine_allowed_root", str(tmp_path / "wine-root"))
monkeypatch.setattr(settings, "atp_legacy_root", str(tmp_path / "outside" / "ATP"))
monkeypatch.setattr(settings, "atp_template_root", str(tmp_path / "outside" / "ATP" / "templates"))
monkeypatch.setattr(settings, "atp_run_root", str(tmp_path / "wine-root" / "runs"))
with pytest.raises(RuntimeError, match="legacy_root必须位于"):
legacy_atp_adapter.resolve_legacy_atp_job(adapter_config_json={}, execution_options={})
def test_resolve_legacy_atp_job_reports_missing_egm_directory(monkeypatch, tmp_path: Path) -> None:
allowed_root = tmp_path / "wine-root"
template_root = allowed_root / "ATP" / "templates"
template_dir = template_root / "raoji1"
template_dir.mkdir(parents=True)
(template_dir / "sample.atp").write_text("0123456789" * 200, encoding="utf-8")
(allowed_root / "ATP" / "rjtzl.exe").write_text("binary", encoding="utf-8")
(allowed_root / "ATP" / "tpbig.exe").write_text("binary", encoding="utf-8")
settings = get_settings()
monkeypatch.setattr(settings, "wine_allowed_root", str(allowed_root))
monkeypatch.setattr(settings, "atp_legacy_root", str(allowed_root / "ATP"))
monkeypatch.setattr(settings, "atp_template_root", str(template_root))
monkeypatch.setattr(settings, "atp_run_root", str(allowed_root / "runs"))
monkeypatch.setattr(settings, "atp_rjtzl_executable", "ATP/rjtzl.exe")
monkeypatch.setattr(settings, "atp_tpbig_executable", "ATP/tpbig.exe")
with pytest.raises(RuntimeError, match="EGM子目录不存在"):
legacy_atp_adapter.resolve_legacy_atp_job(
adapter_config_json={
"calculation_mode": "raoji1",
"template_subdir": "raoji1",
"use_egm": True,
},
execution_options={},
)
def test_build_legacy_atp_status_checks_marks_missing_binaries(monkeypatch, tmp_path: Path) -> None:
allowed_root = tmp_path / "wine-root"
template_root = allowed_root / "ATP" / "templates"
template_root.mkdir(parents=True)
(template_root / "EGM").mkdir(parents=True)
settings = get_settings()
monkeypatch.setattr(settings, "wine_allowed_root", str(allowed_root))
monkeypatch.setattr(settings, "wine_binary_path", "wine")
monkeypatch.setattr(settings, "atp_legacy_root", str(allowed_root / "ATP"))
monkeypatch.setattr(settings, "atp_template_root", str(template_root))
monkeypatch.setattr(settings, "atp_run_root", str(allowed_root / "runs"))
monkeypatch.setattr(settings, "atp_tpbig_executable", "ATP/tpbig.exe")
monkeypatch.setattr(settings, "atp_rjtzl_executable", "ATP/rjtzl.exe")
monkeypatch.setattr(legacy_atp_adapter, "_resolve_binary", lambda _raw: None)
checks = legacy_atp_adapter.build_legacy_atp_status_checks()
assert checks["wine"]["available"] is False
assert checks["tpbig_executable"]["available"] is False
assert checks["rjtzl_executable"]["available"] is False
assert checks["egm_subdir"]["available"] is True
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from app.services.legacy_atp_adapter import apply_feature_bindings, load_feature_bindings_from_setting_file
def test_load_feature_bindings_from_gb18030_setting_file_and_apply_offsets(tmp_path: Path) -> None:
setting_path = tmp_path / "features.txt"
setting_path.write_bytes(
(
"# 中文注释\n"
"GROUND_RES,10,6,F1,base_tower.ground_resistance_ohm\n"
"TOWER_NO,30,4,,snapshot.tower_no\n"
).encode("gb18030")
)
bindings = load_feature_bindings_from_setting_file(setting_path)
assert len(bindings) == 2
assert bindings[0].name == "GROUND_RES"
assert bindings[0].offset == 10
assert bindings[0].length == 6
assert bindings[0].format_spec == "F1"
model_path = tmp_path / "sample.atp"
original = list("BEGIN" + (" " * 1595))
model_path.write_text("".join(original), encoding="utf-8")
apply_feature_bindings(
run_dir=tmp_path,
model_file="sample.atp",
bindings=bindings,
context={
"base_tower": {"ground_resistance_ohm": 12.34},
"snapshot": {"tower_no": "N01"},
},
minimum_model_size_bytes=1024,
required_keywords=["BEGIN"],
)
updated = model_path.read_text(encoding="utf-8")
assert updated[10:16] == " 12.3"
assert updated[30:34] == " N01"
+64
View File
@@ -81,3 +81,67 @@
- 风险与关注点:
- 本次变更仅影响文件管理页列表展示,不涉及后端索引同步逻辑,也不影响其它复用该接口的页面读取 `synced_at` 字段。
## Work Log - ATP/EGM/Wine 生产计算链路接入 legacy_atp worker 适配(2026-06-09
- 背景:
- Issue `FL-69` 要求把 `fl-knowledge` 的旧 ATP/EGM 生产计算协议接入 `fquiz`,并确保真实计算执行在 worker 中异步完成。
- 现状是:
- `fl_analysis_service` 已有 Celery worker 链路,但 `external_adapter` 仅支持 `placeholder/atp/wine`
- 通用 `fl_analysis_external.py` 只覆盖“ATP 模板渲染 + 子进程执行”的简化协议,不承接旧 ATP 目录协议、特征写入和 EGM/rjtzl.exe。
- `atp_model_service.get_engine_status()` 只能返回 ATP engine 总体可用性,不能细分 `tpbig.exe/rjtzl.exe/legacy_root/EGM子目录` 等资产状态。
- 本次处理:
- `api/app/services/legacy_atp_adapter.py`
- 新增独立 legacy ATP/EGM 业务适配层,不把旧链路逻辑混入通用 `wine_service.py`
- 支持:
- `WINE_ALLOWED_ROOT` 下的 legacy 资产路径校验。
- 旧模板目录解析、`fanji/raoji1/raoji2/raoji3` 模式选择。
- ATP 特征配置读取与定长 offset/length 写入。
- `gb18030/gbk/utf-8-sig/utf-8` 配置与结果文件解码。
- `tpbig.exe``rjtzl.exe` 调用。
- EGM 子目录存在性校验、`rjtzl.exe运行失败,所得绕击跳闸率无效` 错误归一化。
- 结果文件 / stdout 解析并映射到现有防雷分析结果结构。
- 同时补了 legacy 资产状态检查构建函数,给 ATP 状态接口复用。
- `api/app/services/fl_analysis_service.py`
- 扩展 `external_adapter` 支持 `legacy_atp`
-`normal/tongtiao` worker 执行链路中直接调用 `resolve_legacy_atp_job()` / `execute_legacy_atp_tower_analysis()`
- `scenario` 复算场景会沿用基线任务的 `external_adapter + adapter_config_json`,因此也能复用 `legacy_atp`
- 保持长耗时执行仍在现有 `execute_fl_analysis_job` worker 中完成,API 侧只负责校验和派发。
- `api/app/services/atp_model_service.py`
- ATP 状态响应新增 `checks` 明细,细分返回:
- `wine`
- `legacy_root`
- `template_root`
- `run_root`
- `tpbig_executable`
- `rjtzl_executable`
- `egm_subdir`
- `api/app/core/config.py`
- 新增 legacy ATP/EGM 资产配置项:
- `atp_legacy_root`
- `atp_tpbig_executable`
- `atp_rjtzl_executable`
- `atp_template_root`
- `atp_run_root`
- `atp_egm_subdir`
- `api/app/schemas/fl_analysis.py` / `api/app/schemas/atp_model.py`
- 扩展 adapter 枚举和 ATP 状态响应字段,补齐接口契约。
- 新增测试:
- `api/tests/test_legacy_atp_adapter_paths.py`
- `api/tests/test_legacy_atp_feature_set.py`
- `api/tests/test_fl_analysis_legacy_atp_adapter.py`
- `api/tests/test_atp_engine_task.py`
- 验证:
- `python3 -m py_compile app/services/legacy_atp_adapter.py app/services/fl_analysis_service.py app/services/atp_model_service.py app/schemas/fl_analysis.py app/schemas/atp_model.py app/core/config.py tests/test_legacy_atp_adapter_paths.py tests/test_legacy_atp_feature_set.py tests/test_fl_analysis_legacy_atp_adapter.py tests/test_atp_engine_task.py`
- 通过
- `UV_CACHE_DIR=/tmp/uv-cache uv run --with-requirements requirements.txt --with pytest --no-project env PYTHONPATH=. python -m pytest tests/test_atp_engine_task.py tests/test_wine_probe.py tests/test_legacy_atp_adapter_paths.py tests/test_legacy_atp_feature_set.py tests/test_fl_analysis_legacy_atp_adapter.py tests/test_fl_analysis_external.py tests/test_async_dispatch_services.py tests/test_atp_model_service.py tests/test_fl_analysis_service.py tests/test_fl_analysis_schema.py`
- `19 passed, 1 warning`
- warning 为既有 SQLAlchemy relationship overlap 提示,与本次改动无直接关系。
- `git diff --check`
- 通过
- 风险与关注点:
- 当前仓库内仍没有 `tpbig.exe``rjtzl.exe`、旧 ATP 模型目录和 EGM 子目录等真实生产运行资产;本次交付的是代码层接入、状态检测和可测的 worker 执行协议。
- 上线前仍需在容器/部署环境提供实际 legacy 资产挂载,并用真实资产做 smoke test。