From 72b6b63154aa3dde64a67a1c7c181015f7519edb Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 20 Jul 2026 14:42:18 +0800 Subject: [PATCH] Integrate RC1 fusion model routing --- .env.example | 4 +- .gitignore | 2 + Dockerfile | 4 +- app/__init__.py | 2 +- app/config.py | 6 +- app/prediction.py | 274 ++++++++++++++++++++------------------- app/routes.py | 2 + docker-compose.yml | 2 +- example.xlsx | Bin 36183 -> 43387 bytes requirements.txt | 4 +- templates/home.html | 2 +- tests/test_prediction.py | 98 ++++++++++---- 12 files changed, 232 insertions(+), 168 deletions(-) diff --git a/.env.example b/.env.example index 9b191c1..ee3c4fd 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,8 @@ APP_TIMEZONE=Asia/Shanghai # Default: 16 MiB MAX_UPLOAD_BYTES=16777216 -# Default model path inside the Docker image. -MODEL_PATH=/app/my_survival_forest_model_quxi-10-0331.joblib +# Default RC1 fusion model core directory inside the Docker image. +FUSION_MODEL_CORE_DIR=/app/model_core # Keep public registration closed by default. ALLOW_REGISTRATION=false diff --git a/.gitignore b/.gitignore index adafec4..6dc4a93 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ static/images/ pipe_survival_0331.db # Large generated or local model artifacts +model_core/ +wheelhouse/ *.joblib *.pkl *.pickle diff --git a/Dockerfile b/Dockerfile index b2e09b2..bd872de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN apt-get update \ COPY requirements.txt . -RUN conda create -n demo python=3.12 -y \ +RUN mamba create -n demo python=3.12 -y \ && conda run -n demo python -m pip install --no-cache-dir -r requirements.txt \ && conda clean -afy @@ -23,7 +23,7 @@ COPY static ./static COPY main.py . COPY example.xlsx . COPY 20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf . -COPY my_survival_forest_model_quxi-10-0331.joblib . +COPY model_core ./model_core RUN mkdir -p data static/images uploads diff --git a/app/__init__.py b/app/__init__.py index 92027f2..9dbe372 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -39,7 +39,7 @@ def create_app(config_object: type[Config] = Config, *, load_model_on_start: boo if load_model_on_start: try: - app.config["RSF_MODEL"] = load_model(app.config["MODEL_PATH"]) + app.config["RSF_MODEL"] = load_model(app.config["FUSION_MODEL_CORE_DIR"]) logging.info("模型加载成功") except Exception as exc: app.config["RSF_MODEL"] = None diff --git a/app/config.py b/app/config.py index f675d33..f6c813b 100644 --- a/app/config.py +++ b/app/config.py @@ -37,9 +37,9 @@ class Config: MAX_CONTENT_LENGTH = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024) SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" - MODEL_PATH = os.environ.get( - "MODEL_PATH", - str(BASE_DIR / "my_survival_forest_model_quxi-10-0331.joblib"), + FUSION_MODEL_CORE_DIR = os.environ.get( + "FUSION_MODEL_CORE_DIR", + str(BASE_DIR / "model_core"), ) ALLOW_REGISTRATION = env_bool("ALLOW_REGISTRATION", False) ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin" diff --git a/app/prediction.py b/app/prediction.py index 127c026..bda2fe4 100644 --- a/app/prediction.py +++ b/app/prediction.py @@ -1,18 +1,16 @@ from __future__ import annotations import logging -import os +import sys import uuid from dataclasses import dataclass from pathlib import Path from typing import Any -import joblib import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt -import numpy as np import pandas as pd from matplotlib import font_manager, rcParams from werkzeug.datastructures import FileStorage @@ -59,9 +57,12 @@ FEATURES = [ ID_COLUMN = "管道编号" PIPE_AGE_COLUMN = "管龄(年)" LEGACY_PIPE_AGE_COLUMN = "管龄" -STATUS_COLUMN = "状态" -EVENT_AGE_COLUMN = "事件/观察管龄(年)" COLUMN_ALIASES = { + "ID": ID_COLUMN, + "管段ID": ID_COLUMN, + "管段编号": ID_COLUMN, + "当前管龄": PIPE_AGE_COLUMN, + "当前管龄(年)": PIPE_AGE_COLUMN, "管径(mm)": "管径", "管径(mm)": "管径", "流速(m/s)": "流速", @@ -70,48 +71,42 @@ COLUMN_ALIASES = { "温度(℃)": "温度", "年均降雨量(mm)": "降雨量", "降雨量(mm)": "降雨量", + "结构性缺陷": "结构缺陷", + "功能性缺陷": "功能缺陷", } INPUT_COLUMNS = [ ID_COLUMN, PIPE_AGE_COLUMN, - STATUS_COLUMN, - EVENT_AGE_COLUMN, *FEATURES, ] +REQUIRED_INPUT_COLUMNS = [ID_COLUMN, PIPE_AGE_COLUMN, "管材", "管径"] CHART_DISPLAY_LIMIT = 10 MATERIAL_COLUMN = "管材" -MATERIAL_CODE_OPTIONS = [ - (1, "镀锌"), - (2, "钢塑"), - (3, "铝塑"), - (4, "PPR"), - (5, "PE"), - (6, "UPVC"), - (7, "铸铁"), - (8, "预应力"), - (9, "自应力"), - (10, "玻璃钢夹砂"), - (11, "钢管"), - (12, "钢套混凝土管"), - (13, "球墨铸铁"), - (14, "其他"), -] -MATERIAL_ALIAS_TO_CODE = { - str(code): code - for code, _ in MATERIAL_CODE_OPTIONS +MATERIAL_CODE_TO_NAME = { + 1: "镀锌", + 2: "钢塑", + 3: "铝塑", + 4: "PPR", + 5: "PE", + 6: "UPVC", + 7: "铸铁", + 11: "钢管", } -MATERIAL_ALIAS_TO_CODE.update( - { - name.casefold(): code - for code, name in MATERIAL_CODE_OPTIONS - } -) -MATERIAL_ALIAS_TO_CODE.update( - { - f"{code}-{name}".casefold(): code - for code, name in MATERIAL_CODE_OPTIONS - } -) +LOCATION_CODE_TO_NAME = { + 1: "绿化带", + 2: "行人道", + 3: "非机动车道", + 4: "小区内", + 6: "市政道路", + 7: "桥管", +} +MATERIAL_ALIASES = {str(code): name for code, name in MATERIAL_CODE_TO_NAME.items()} +MATERIAL_ALIASES.update({name.casefold(): name for name in MATERIAL_CODE_TO_NAME.values()}) +MATERIAL_ALIASES.update({f"{code}-{name}".casefold(): name for code, name in MATERIAL_CODE_TO_NAME.items()}) +LOCATION_ALIASES = {str(code): name for code, name in LOCATION_CODE_TO_NAME.items()} +LOCATION_ALIASES.update({name.casefold(): name for name in LOCATION_CODE_TO_NAME.values()}) +LOCATION_ALIASES.update({f"{code}-{name}".casefold(): name for code, name in LOCATION_CODE_TO_NAME.items()}) +LOCATION_ALIASES["人行道"] = "行人道" SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"} CHINESE_FONT_PROP = None DEFECT_GRADE_VALUES = {"无": 0.0, "轻度": 1.0, "中度": 3.0, "严重": 5.0} @@ -134,30 +129,7 @@ class PredictionArtifacts: sample_count: int summary_rows: list[dict[str, Any]] analysis_text: str - - -class ModelBundleAdapter: - """Expose a bundled preprocessor and survival model as one predictor.""" - - def __init__(self, preprocessor, model) -> None: - self.preprocessor = preprocessor - self.model = model - - def _transform(self, frame: pd.DataFrame): - values = frame[FEATURES].copy() - values[MATERIAL_COLUMN] = values[MATERIAL_COLUMN].map( - lambda value: f"M{int(value)}" if pd.notna(value) else value - ) - values["位置"] = values["位置"].map( - lambda value: f"L{int(value)}" if pd.notna(value) else value - ) - return self.preprocessor.transform(values.to_numpy(dtype=object)).astype(np.float32) - - def predict_survival_function(self, frame: pd.DataFrame): - return self.model.predict_survival_function(self._transform(frame)) - - def predict(self, frame: pd.DataFrame): - return self.model.predict(self._transform(frame)) + model_version: str def configure_matplotlib_fonts(): @@ -190,24 +162,18 @@ def chinese_font_kwargs() -> dict[str, Any]: return {"fontproperties": CHINESE_FONT_PROP} if CHINESE_FONT_PROP else {} -def load_model(model_path: str): - if not os.path.exists(model_path): - raise FileNotFoundError(f"未找到模型文件: {model_path}") - model = joblib.load(model_path) - if isinstance(model, dict): - if not {"preprocessor", "model"}.issubset(model): - raise ValueError("模型包缺少 preprocessor 或 model。") - model = ModelBundleAdapter(model["preprocessor"], model["model"]) - feature_names = getattr(model, "feature_names_in_", None) - if feature_names is not None and list(feature_names) != FEATURES: - raise ValueError( - "模型输入特征与系统配置不一致: " - f"model={list(feature_names)}, app={FEATURES}" - ) - n_features = getattr(model, "n_features_in_", None) - if n_features is not None and int(n_features) != len(FEATURES): - raise ValueError(f"模型特征数量不一致: model={n_features}, app={len(FEATURES)}") - return model +def load_model(core_dir: str): + core_path = Path(core_dir) + if not core_path.exists(): + raise FileNotFoundError(f"未找到模型核心目录: {core_path}") + if not (core_path / "frozen_fusion_inference.py").exists(): + raise FileNotFoundError(f"模型核心目录缺少 frozen_fusion_inference.py: {core_path}") + if str(core_path) not in sys.path: + sys.path.insert(0, str(core_path)) + + from frozen_fusion_inference import FrozenFusionPredictor + + return FrozenFusionPredictor(core_path) def safe_unlink(path: Path) -> None: @@ -339,31 +305,62 @@ def fill_defects_from_detail_sheet(path: Path, df: pd.DataFrame) -> pd.DataFrame def validate_input_frame(df: pd.DataFrame) -> None: if df.empty: raise PredictionError("上传文件没有可预测的数据。") - missing = [col for col in INPUT_COLUMNS if col not in df.columns] + missing = [col for col in REQUIRED_INPUT_COLUMNS if col not in df.columns] if missing: raise PredictionError(f"缺少必要字段: {', '.join(missing)}") -def normalize_material_code(value: Any) -> int: +def normalize_category_value( + value: Any, + aliases: dict[str, str], + label: str, + *, + required: bool = False, +) -> str | pd.NA: if pd.isna(value): - raise PredictionError("管材不能为空。") - if isinstance(value, str): - key = value.strip().casefold() - else: + if required: + raise PredictionError(f"{label}不能为空。") + return pd.NA + text = str(value).strip() + if text == "": + if required: + raise PredictionError(f"{label}不能为空。") + return pd.NA + key = text.casefold() + try: numeric_value = float(value) if not numeric_value.is_integer(): - raise PredictionError(f"管材编码无效: {value}") + raise ValueError key = str(int(numeric_value)) - code = MATERIAL_ALIAS_TO_CODE.get(key) - if code is None: - raise PredictionError(f"管材编码无效: {value}") - return code + except (TypeError, ValueError): + pass + normalized = aliases.get(key) + if normalized is None: + raise PredictionError(f"{label}超出RC1支持范围: {value}") + return normalized def prepare_model_features(df: pd.DataFrame) -> pd.DataFrame: - x_test = df[FEATURES].copy() - x_test[MATERIAL_COLUMN] = x_test[MATERIAL_COLUMN].map(normalize_material_code) - return x_test + result = pd.DataFrame(index=df.index) + result["ID"] = df[ID_COLUMN] + result["Current Age"] = df[PIPE_AGE_COLUMN] + result["Material"] = df[MATERIAL_COLUMN].map( + lambda value: normalize_category_value(value, MATERIAL_ALIASES, "管材", required=True) + ) + result["Diameter"] = df["管径"] + result["Flow Velocity"] = df["流速"] if "流速" in df.columns else pd.NA + result["Pressure"] = df["压力"] if "压力" in df.columns else pd.NA + result["Temperature"] = df["温度"] if "温度" in df.columns else pd.NA + result["Precipitation"] = df["降雨量"] if "降雨量" in df.columns else pd.NA + if "位置" in df.columns: + result["Location"] = df["位置"].map( + lambda value: normalize_category_value(value, LOCATION_ALIASES, "位置") + ) + else: + result["Location"] = pd.NA + result["Structural Defects"] = df["结构缺陷"] if "结构缺陷" in df.columns else pd.NA + result["Functional Defects"] = df["功能缺陷"] if "功能缺陷" in df.columns else pd.NA + return result def grade_info(probability: float) -> tuple[str, str, str]: @@ -389,22 +386,22 @@ def interpolate_probability(times: list[float], probs: list[float], target: floa return float(probs[-1]) -def estimate_remaining_life(times: list[float], probs: list[float]) -> float: - for t, p in zip(times, probs): - if p <= 0.5: - return float(t) - return float(times[-1]) if times else 0.0 +def estimate_remaining_life(times: list[float], health_states: list[float], current_age: float = 0.0) -> float | None: + for age, health_state in zip(times, health_states): + if age >= current_age and health_state <= 0.5: + return max(float(age) - current_age, 0.0) + return None def make_analysis_text(summary_rows: list[dict[str, Any]]) -> str: if not summary_rows: return "当前结果为空,暂无可供解释的样本。" - worst = min(summary_rows, key=lambda x: x["health_probability"]) - best = max(summary_rows, key=lambda x: x["health_probability"]) + worst = min(summary_rows, key=lambda x: x["health_state"]) + best = max(summary_rows, key=lambda x: x["health_state"]) return ( - "阶梯状曲线表示模型对不同管道随时间推移维持在安全健康状态概率的动态预测。" + "曲线表示模型对不同管道随管龄变化的健康状态动态预测。" f"当前样本中风险最高管道为 {worst['pipe_id']}({worst['grade_label']})," - f"健康概率最高管道为 {best['pipe_id']}({best['health_probability']:.1%})。" + f"当前健康状态最高管道为 {best['pipe_id']}({best['health_state']:.1%})。" ) @@ -427,18 +424,21 @@ def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArti validate_input_frame(df) x_test = prepare_model_features(df) try: - curves = model.predict_survival_function(x_test) + predictions = model.predict(x_test, variant="defect_sensitive") + except ValueError as exc: + logging.exception("预测输入校验失败: %s", exc) + raise PredictionError(str(exc)) except Exception as exc: logging.exception("预测失败: %s", exc) raise PredictionError("模型预测失败,请检查输入字段类型是否正确。", 500) image_filename = f"plot_{user_id}_{run_id}.png" image_path = IMAGE_DIR / image_filename - summary_rows, summary_sheet_rows = render_survival_chart(df, curves, image_path) + summary_rows, summary_sheet_rows = render_survival_chart(predictions, image_path) safe_stem = Path(saved_filename).stem excel_path = user_dir / f"{safe_stem}_pre.xlsx" - write_prediction_workbook(excel_path, curves, summary_rows, summary_sheet_rows) + write_prediction_workbook(excel_path, predictions, summary_rows, summary_sheet_rows) return PredictionArtifacts( original_filename=original_filename, saved_path=original_path, @@ -448,36 +448,45 @@ def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArti sample_count=len(summary_rows), summary_rows=summary_rows, analysis_text=make_analysis_text(summary_rows), + model_version=str(getattr(model, "config", {}).get("version", "")), ) except PredictionError: safe_unlink(original_path) raise -def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: +def float_list(values: Any) -> list[float]: + return [float(value) for value in list(values)] + + +def format_years(value: float | None, current_age: float, max_age: float) -> str | float: + if value is None: + return f">{max(max_age - current_age, 0.0):g}" + return round(float(value), 3) + + +def render_survival_chart(predictions: list[dict[str, Any]], image_path: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: plt.figure(figsize=(10, 5.6)) summary_rows: list[dict[str, Any]] = [] summary_sheet_rows: list[dict[str, Any]] = [] display_note = f"说明:图中仅展示前{CHART_DISPLAY_LIMIT}条管道的示例数据,不足{CHART_DISPLAY_LIMIT}条则全部展示;完整结果请下载电子表格。" - for i, curve in enumerate(curves): - times = [float(x) for x in list(curve.x)] - probs = [float(y) for y in list(curve.y)] - pipe_id = normalize_id_value(df.iloc[i][ID_COLUMN], f"Pipe_{i+1:03d}") - raw_pipe_age = df.iloc[i][PIPE_AGE_COLUMN] if PIPE_AGE_COLUMN in df.columns else None - pipe_age_value = float(raw_pipe_age) if pd.notna(raw_pipe_age) else 0.0 - pipe_age = f"{raw_pipe_age} 年" if pd.notna(raw_pipe_age) else "-" - health_probability = interpolate_probability(times, probs, pipe_age_value) - health_risk = 1.0 - health_probability - remaining_life = estimate_remaining_life(times, probs) - grade_label, grade_desc, grade_class = grade_info(health_probability) + for i, prediction in enumerate(predictions): + times = float_list(prediction["pipe_age"]) + health_states = float_list(prediction["health_state"]) + pipe_id = normalize_id_value(prediction.get("ID"), f"Pipe_{i+1:03d}") + current_age = float(prediction["current_age"]) + pipe_age = f"{current_age:g} 年" + current_health_state = float(prediction["current_health_state"]) + remaining_life = estimate_remaining_life(times, health_states, current_age) + grade_label, grade_desc, grade_class = grade_info(current_health_state) + max_age = max(times) if times else current_age summary_rows.append( { "pipe_id": pipe_id, "pipe_age": pipe_age, - "health_probability": health_probability, - "health_risk": health_risk, + "health_state": current_health_state, "remaining_life": remaining_life, "grade_label": grade_label, "grade_desc": grade_desc, @@ -488,16 +497,18 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l { ID_COLUMN: pipe_id, PIPE_AGE_COLUMN: pipe_age, - "健康风险值": health_risk, + "当前健康状态": current_health_state, + "健康等级": grade_label, + "预计剩余寿命(年)": format_years(remaining_life, current_age, max_age), } ) if i < CHART_DISPLAY_LIMIT: - plt.step(times, probs, where="post", linewidth=2, label=pipe_id) + plt.step(times, health_states, where="post", linewidth=2, label=pipe_id) font_kwargs = chinese_font_kwargs() plt.xlabel("管龄(年)", **font_kwargs) - plt.ylabel("健康风险", **font_kwargs) - plt.title("管道的剩余寿命分析图", **font_kwargs) + plt.ylabel("管道健康状态", **font_kwargs) + plt.title("供水管道健康状态曲线", **font_kwargs) plt.figtext(0.5, 0.02, display_note, ha="center", fontsize=9, color="#475569", **font_kwargs) plt.grid(alpha=0.18) if min(len(summary_rows), CHART_DISPLAY_LIMIT) <= 12: @@ -514,18 +525,18 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l def write_prediction_workbook( excel_path: Path, - curves, + predictions: list[dict[str, Any]], summary_rows: list[dict[str, Any]], summary_sheet_rows: list[dict[str, Any]], ) -> None: chart_times: set[float] = set() chart_series: list[tuple[str, dict[float, float]]] = [] - for i, curve in enumerate(curves): - times = [float(x) for x in list(curve.x)] - probs = [float(y) for y in list(curve.y)] + for i, prediction in enumerate(predictions): + times = float_list(prediction["pipe_age"]) + health_states = float_list(prediction["health_state"]) pipe_id = summary_rows[i]["pipe_id"] chart_times.update(times) - chart_series.append((pipe_id, dict(zip(times, probs)))) + chart_series.append((pipe_id, dict(zip(times, health_states)))) sorted_times = sorted(chart_times) sample_columns = ["管龄(年)", *[pipe_id for pipe_id, _ in chart_series]] @@ -535,7 +546,10 @@ def write_prediction_workbook( ] with pd.ExcelWriter(excel_path, engine="openpyxl") as writer: - summary_df = pd.DataFrame(summary_sheet_rows, columns=[ID_COLUMN, PIPE_AGE_COLUMN, "健康风险值"]) + summary_df = pd.DataFrame( + summary_sheet_rows, + columns=[ID_COLUMN, PIPE_AGE_COLUMN, "当前健康状态", "健康等级", "预计剩余寿命(年)"], + ) sample_df = pd.DataFrame(sample_data_rows, columns=sample_columns) if ID_COLUMN in summary_df.columns: summary_df[ID_COLUMN] = summary_df[ID_COLUMN].astype("string") diff --git a/app/routes.py b/app/routes.py index 54004dd..d1edb31 100644 --- a/app/routes.py +++ b/app/routes.py @@ -139,6 +139,7 @@ def prediction_result_payload(artifacts, record: UploadRecord) -> dict: "sample_count": int(artifacts.sample_count), "summary_rows": artifacts.summary_rows[:6], "analysis_text": artifacts.analysis_text, + "model_version": artifacts.model_version, } @@ -437,5 +438,6 @@ def predict(): "result_url": last_result["result_url"], "sample_count": last_result["sample_count"], "original_filename": artifacts.original_filename, + "model_version": last_result["model_version"], } ) diff --git a/docker-compose.yml b/docker-compose.yml index 77f1262..a27ef44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: DEBUG: ${DEBUG:-false} APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai} MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216} - MODEL_PATH: ${MODEL_PATH:-/app/my_survival_forest_model_quxi-10-0331.joblib} + FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core} ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-30} ports: diff --git a/example.xlsx b/example.xlsx index 6566ace4383f6ff8517bc4ca00d8fcda3255d57c..a370edfbcae01931ad0ae09aa44e0b38c9a58405 100644 GIT binary patch literal 43387 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
aC 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 HevJMDEdnMO literal 36183 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 diff --git a/requirements.txt b/requirements.txt index 0dfab2c..eccf471 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,5 +14,5 @@ openpyxl==3.1.5 XlsxWriter==3.2.9 xlrd==2.0.2 -scikit-learn==1.9.0 -scikit-survival==0.28.0 +scikit-learn==1.8.0 +scikit-survival==0.27.0 diff --git a/templates/home.html b/templates/home.html index 15727cf..e1be3ea 100644 --- a/templates/home.html +++ b/templates/home.html @@ -101,7 +101,7 @@
必填基础信息
-
管道编号、管龄、状态、管材、管径
+
管道编号、管龄、管材、管径
选填历史信息
diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 0156937..0c2c445 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -28,10 +28,21 @@ from app.prediction import ( ) -class DummyCurve: - def __init__(self, x: list[float], y: list[float]) -> None: - self.x = x - self.y = y +def dummy_prediction( + pipe_id: str = "P001", + pipe_age: list[float] | None = None, + health_state: list[float] | None = None, + current_age: float = 12.0, + current_health_state: float = 0.4, +) -> dict: + return { + "ID": pipe_id, + "current_age": current_age, + "current_health_state": current_health_state, + "current_health_grade": "II级", + "pipe_age": pipe_age or [1, 10, 12], + "health_state": health_state or [0.9, 0.7, 0.4], + } class PredictionHelpersTest(unittest.TestCase): @@ -91,7 +102,7 @@ class PredictionHelpersTest(unittest.TestCase): template = workbook.active template.title = "Template" template.append(INPUT_COLUMNS) - template.append(["001", 5, 0, 5, 1, 100, 1.2, 0.4, 20, 800, 1, "=缺陷计算!E2", "=缺陷计算!J2"]) + template.append(["001", 5, 1, 100, 1, 1.2, 0.4, 20, 800, "=缺陷计算!E2", "=缺陷计算!J2"]) detail = workbook.create_sheet("缺陷计算") detail.append([ @@ -114,48 +125,79 @@ class PredictionHelpersTest(unittest.TestCase): self.assertAlmostEqual(float(df.loc[0, "结构缺陷"]), 3.8) self.assertAlmostEqual(float(df.loc[0, "功能缺陷"]), 1.6) - def test_prepare_model_features_maps_material_aliases_to_codes(self) -> None: + def test_prepare_model_features_maps_material_aliases_to_rc1_names(self) -> None: rows = [] - for value in ["镀锌", "2-钢塑", 13]: + for value in ["镀锌", "2-钢塑", 11]: row = {feature: 1 for feature in FEATURES} row["管材"] = value + row[ID_COLUMN] = "P001" + row[PIPE_AGE_COLUMN] = 12 rows.append(row) df = pd.DataFrame(rows) x_test = prepare_model_features(df) - self.assertEqual(x_test["管材"].tolist(), [1, 2, 13]) + self.assertEqual(x_test["Material"].tolist(), ["镀锌", "钢塑", "钢管"]) def test_prepare_model_features_rejects_invalid_material_alias(self) -> None: row = {feature: 1 for feature in FEATURES} row["管材"] = "未知管材" + row[ID_COLUMN] = "P001" + row[PIPE_AGE_COLUMN] = 12 df = pd.DataFrame([row]) with self.assertRaises(PredictionError) as ctx: prepare_model_features(df) - self.assertIn("管材编码无效", ctx.exception.message) + self.assertIn("管材超出RC1支持范围", ctx.exception.message) + + def test_prepare_model_features_rejects_unsupported_legacy_material_code(self) -> None: + row = {feature: 1 for feature in FEATURES} + row["管材"] = 13 + row[ID_COLUMN] = "P001" + row[PIPE_AGE_COLUMN] = 12 + df = pd.DataFrame([row]) + + with self.assertRaises(PredictionError) as ctx: + prepare_model_features(df) + + self.assertIn("管材超出RC1支持范围", ctx.exception.message) + + def test_prepare_model_features_maps_location_aliases_to_rc1_names(self) -> None: + row = {feature: 1 for feature in FEATURES} + row["管材"] = 5 + row["位置"] = "2-行人道" + row[ID_COLUMN] = "P001" + row[PIPE_AGE_COLUMN] = 12 + df = pd.DataFrame([row]) + + x_test = prepare_model_features(df) + + self.assertEqual(x_test["Location"].tolist(), ["行人道"]) def test_prediction_workbook_keeps_sample_data_in_one_sheet(self) -> None: - curves = [ - DummyCurve([1, 2], [0.9, 0.7]), - DummyCurve([1, 2], [0.8, 0.6]), + predictions = [ + dummy_prediction("P001", [1, 2], [0.9, 0.7], 10, 0.7), + dummy_prediction("P002", [1, 2], [0.8, 0.6], 12, 0.6), ] summary_rows = [{"pipe_id": "P001"}, {"pipe_id": "P002"}] summary_sheet_rows = [ - {ID_COLUMN: "P001", PIPE_AGE_COLUMN: "10 年", "健康风险值": 0.3}, - {ID_COLUMN: "P002", PIPE_AGE_COLUMN: "12 年", "健康风险值": 0.4}, + {ID_COLUMN: "P001", PIPE_AGE_COLUMN: "10 年", "当前健康状态": 0.7, "健康等级": "IV级", "预计剩余寿命(年)": ">63"}, + {ID_COLUMN: "P002", PIPE_AGE_COLUMN: "12 年", "当前健康状态": 0.6, "健康等级": "III级", "预计剩余寿命(年)": ">61"}, ] with TemporaryDirectory() as temp_dir: output_path = Path(temp_dir) / "prediction.xlsx" - write_prediction_workbook(output_path, curves, summary_rows, summary_sheet_rows) + write_prediction_workbook(output_path, predictions, summary_rows, summary_sheet_rows) workbook = pd.ExcelFile(output_path) self.assertEqual(workbook.sheet_names, ["结果摘要", "样本数据"]) summary_data = pd.read_excel(output_path, sheet_name="结果摘要") - self.assertEqual(summary_data.columns.tolist(), [ID_COLUMN, PIPE_AGE_COLUMN, "健康风险值"]) + self.assertEqual( + summary_data.columns.tolist(), + [ID_COLUMN, PIPE_AGE_COLUMN, "当前健康状态", "健康等级", "预计剩余寿命(年)"], + ) sample_data = pd.read_excel(output_path, sheet_name="样本数据") self.assertEqual(sample_data.columns.tolist(), ["管龄(年)", "P001", "P002"]) @@ -171,13 +213,15 @@ class PredictionHelpersTest(unittest.TestCase): self.assertEqual(sample_worksheet["B2"].number_format, "0.0%") def test_prediction_workbook_writes_pipe_ids_as_excel_text(self) -> None: - curves = [DummyCurve([1], [0.9])] + predictions = [dummy_prediction("00123", [1], [0.9], 10, 0.9)] summary_rows = [{"pipe_id": "00123"}] - summary_sheet_rows = [{ID_COLUMN: "00123", PIPE_AGE_COLUMN: "10 年", "健康风险值": 0.1}] + summary_sheet_rows = [ + {ID_COLUMN: "00123", PIPE_AGE_COLUMN: "10 年", "当前健康状态": 0.9, "健康等级": "V级", "预计剩余寿命(年)": ">63"} + ] with TemporaryDirectory() as temp_dir: output_path = Path(temp_dir) / "prediction.xlsx" - write_prediction_workbook(output_path, curves, summary_rows, summary_sheet_rows) + write_prediction_workbook(output_path, predictions, summary_rows, summary_sheet_rows) workbook = load_workbook(output_path) summary_worksheet = workbook["结果摘要"] @@ -200,18 +244,17 @@ class PredictionHelpersTest(unittest.TestCase): patch("app.prediction.plt.figtext") as figtext, patch("app.prediction.plt.title") as title, ): - summary_rows, _ = render_survival_chart(df, [DummyCurve([1, 10, 12], [0.9, 0.7, 0.4])], output_path) + summary_rows, _ = render_survival_chart([dummy_prediction()], output_path) - self.assertAlmostEqual(summary_rows[0]["health_probability"], 0.4) - self.assertAlmostEqual(summary_rows[0]["health_risk"], 0.6) + self.assertAlmostEqual(summary_rows[0]["health_state"], 0.4) self.assertEqual(summary_rows[0]["grade_label"], "II级") xlabel.assert_called_once() self.assertEqual(xlabel.call_args.args[0], "管龄(年)") ylabel.assert_called_once() - self.assertEqual(ylabel.call_args.args[0], "健康风险") + self.assertEqual(ylabel.call_args.args[0], "管道健康状态") title.assert_called_once() - self.assertEqual(title.call_args.args[0], "管道的剩余寿命分析图") + self.assertEqual(title.call_args.args[0], "供水管道健康状态曲线") figtext.assert_called_once() self.assertIn(f"前{CHART_DISPLAY_LIMIT}条管道的示例数据", figtext.call_args.args[2]) @@ -223,12 +266,15 @@ class PredictionHelpersTest(unittest.TestCase): PIPE_AGE_COLUMN: [12] * sample_count, } ) - curves = [DummyCurve([1, 10, 12], [0.9, 0.7, 0.4]) for _ in range(sample_count)] + predictions = [ + dummy_prediction(f"P{i:03d}", [1, 10, 12], [0.9, 0.7, 0.4]) + for i in range(sample_count) + ] with TemporaryDirectory() as temp_dir: output_path = Path(temp_dir) / "chart.png" with patch("app.prediction.plt.step") as step, patch("app.prediction.plt.legend"): - summary_rows, summary_sheet_rows = render_survival_chart(df, curves, output_path) + summary_rows, summary_sheet_rows = render_survival_chart(predictions, output_path) self.assertEqual(step.call_count, CHART_DISPLAY_LIMIT) self.assertEqual(len(summary_rows), sample_count)