Update prediction results workflow

This commit is contained in:
2026-07-20 13:47:39 +08:00
parent 8d347cfbc2
commit a20339d35e
10 changed files with 239 additions and 207 deletions
+141 -114
View File
@@ -61,6 +61,16 @@ PIPE_AGE_COLUMN = "管龄(年)"
LEGACY_PIPE_AGE_COLUMN = "管龄"
STATUS_COLUMN = "状态"
EVENT_AGE_COLUMN = "事件/观察管龄(年)"
COLUMN_ALIASES = {
"管径(mm": "管径",
"管径(mm)": "管径",
"流速(m/s": "流速",
"压力(MPa)": "压力",
"压力(MPa": "压力",
"温度(℃)": "温度",
"年均降雨量(mm": "降雨量",
"降雨量(mm": "降雨量",
}
INPUT_COLUMNS = [
ID_COLUMN,
PIPE_AGE_COLUMN,
@@ -68,6 +78,7 @@ INPUT_COLUMNS = [
EVENT_AGE_COLUMN,
*FEATURES,
]
CHART_DISPLAY_LIMIT = 10
MATERIAL_COLUMN = "管材"
MATERIAL_CODE_OPTIONS = [
(1, "镀锌"),
@@ -103,6 +114,7 @@ MATERIAL_ALIAS_TO_CODE.update(
)
SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"}
CHINESE_FONT_PROP = None
DEFECT_GRADE_VALUES = {"": 0.0, "轻度": 1.0, "中度": 3.0, "严重": 5.0}
class PredictionError(Exception):
@@ -119,12 +131,35 @@ class PredictionArtifacts:
excel_path: Path
image_path: Path
image_filename: str
importance_filename: str | None
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))
def configure_matplotlib_fonts():
for font_path in CHINESE_FONT_FILES:
path = Path(font_path)
@@ -159,6 +194,10 @@ 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(
@@ -195,17 +234,25 @@ def read_input_file(path: Path, suffix: str) -> pd.DataFrame:
if suffix == ".csv":
df = pd.read_csv(path, dtype={ID_COLUMN: "string"})
else:
try:
df = pd.read_excel(path, sheet_name="Template", dtype={ID_COLUMN: "string"})
except ValueError:
df = pd.read_excel(path, dtype={ID_COLUMN: "string"})
df["_excel_row"] = df.index + 2
except Exception as exc:
logging.exception("文件解析失败: %s", exc)
raise PredictionError("文件解析失败,请检查编码或表格格式。")
return normalize_input_columns(df)
df = normalize_input_columns(df)
if suffix in {".xls", ".xlsx"}:
df = fill_defects_from_detail_sheet(path, df)
return df
def normalize_input_columns(df: pd.DataFrame) -> pd.DataFrame:
aliases = dict(COLUMN_ALIASES)
if PIPE_AGE_COLUMN not in df.columns and LEGACY_PIPE_AGE_COLUMN in df.columns:
return df.rename(columns={LEGACY_PIPE_AGE_COLUMN: PIPE_AGE_COLUMN})
return df
aliases[LEGACY_PIPE_AGE_COLUMN] = PIPE_AGE_COLUMN
return df.rename(columns={name: aliases[name] for name in df.columns if name in aliases})
def normalize_id_value(value: Any, fallback: str) -> str:
@@ -216,6 +263,79 @@ def normalize_id_value(value: Any, fallback: str) -> str:
return str(value)
def defect_grade_value(value: Any) -> float | None:
if pd.isna(value):
return None
text = str(value).strip()
if text == "":
return None
if text in DEFECT_GRADE_VALUES:
return DEFECT_GRADE_VALUES[text]
try:
return float(text)
except ValueError:
return None
def weighted_defect_score(values: list[Any], weights: list[float]) -> float | None:
scores = [defect_grade_value(value) for value in values]
if any(score is None for score in scores):
return None
return round(sum(score * weight for score, weight in zip(scores, weights)), 3)
def numeric_or_none(value: Any) -> float | None:
try:
if pd.isna(value):
return None
return float(value)
except (TypeError, ValueError):
return None
def fill_defects_from_detail_sheet(path: Path, df: pd.DataFrame) -> pd.DataFrame:
if "结构缺陷" not in df.columns or "功能缺陷" not in df.columns or "_excel_row" not in df.columns:
return df
structure = pd.to_numeric(df["结构缺陷"], errors="coerce")
function = pd.to_numeric(df["功能缺陷"], errors="coerce")
needs_fill = structure.isna() | function.isna()
df["结构缺陷"] = structure
df["功能缺陷"] = function
if not needs_fill.any():
return df
try:
from openpyxl import load_workbook
workbook = load_workbook(path, data_only=False, read_only=True)
defect_sheet = workbook["缺陷计算"]
except Exception as exc:
logging.info("未能读取缺陷计算工作表,跳过缺陷自动计算: %s", exc)
return df
for index in df.index[needs_fill]:
excel_row = int(df.at[index, "_excel_row"])
if pd.isna(df.at[index, "结构缺陷"]):
value = numeric_or_none(defect_sheet.cell(excel_row, 5).value)
if value is None:
value = weighted_defect_score(
[defect_sheet.cell(excel_row, column).value for column in (2, 3, 4)],
[0.5, 0.4, 0.1],
)
df.at[index, "结构缺陷"] = value
if pd.isna(df.at[index, "功能缺陷"]):
value = numeric_or_none(defect_sheet.cell(excel_row, 10).value)
if value is None:
value = weighted_defect_score(
[defect_sheet.cell(excel_row, column).value for column in (6, 7, 8, 9)],
[0.5, 0.2, 0.2, 0.1],
)
df.at[index, "功能缺陷"] = value
return df
def validate_input_frame(df: pd.DataFrame) -> None:
if df.empty:
raise PredictionError("上传文件没有可预测的数据。")
@@ -288,99 +408,6 @@ def make_analysis_text(summary_rows: list[dict[str, Any]]) -> str:
)
def compute_feature_importance(model, x_test: pd.DataFrame) -> np.ndarray | None:
try:
importances = model.feature_importances_
except Exception:
importances = None
if importances is not None and len(importances) == len(FEATURES):
vals = np.asarray(importances, dtype=float)
else:
try:
baseline = np.asarray(model.predict(x_test), dtype=float)
except Exception as exc:
logging.exception("特征重要性基线预测失败: %s", exc)
return None
rng = np.random.default_rng(42)
vals = np.zeros(len(FEATURES), dtype=float)
for j, feat in enumerate(FEATURES):
diffs = []
for _ in range(5):
x_perm = x_test.copy()
x_perm[feat] = rng.permutation(x_perm[feat].to_numpy())
try:
perm_pred = np.asarray(model.predict(x_perm), dtype=float)
except Exception:
perm_pred = baseline
diffs.append(float(np.mean(np.abs(perm_pred - baseline))))
vals[j] = float(np.mean(diffs)) if diffs else 0.0
vals = np.clip(vals, a_min=0.0, a_max=None)
total = float(vals.sum())
if total > 0:
vals = vals / total
return vals
def render_importance_chart(values: np.ndarray, save_path: Path) -> None:
from matplotlib.colors import LinearSegmentedColormap
keep = values >= 5e-4
if not bool(np.any(keep)):
keep = np.ones_like(values, dtype=bool)
kept_feats = [FEATURES[i] for i in range(len(FEATURES)) if keep[i]]
kept_vals = values[keep]
order = np.argsort(kept_vals)
sorted_feats = [kept_feats[k] for k in order]
sorted_vals = kept_vals[order]
n = len(sorted_vals)
fig, ax = plt.subplots(figsize=(9, max(3.0, 0.62 * n + 1.6)))
cmap = LinearSegmentedColormap.from_list("brand", ["#7fb2e6", "#005EB8", "#0c4188"])
colors = cmap(np.linspace(0.15, 1.0, n)) if n else None
y_pos = np.arange(n)
bars = ax.barh(y_pos, sorted_vals, color=colors, height=0.66, edgecolor="white", linewidth=0.8, zorder=3)
font_kwargs = chinese_font_kwargs()
ax.set_xlabel("相对重要性", fontsize=11, color="#475569", **font_kwargs)
ax.set_title("模型输入因素重要性排序", fontsize=15, fontweight="bold", color="#0f172a", pad=14, **font_kwargs)
ax.set_yticks(y_pos)
ax.set_yticklabels(sorted_feats, **font_kwargs)
ax.grid(axis="x", color="#e2e8f0", linewidth=1, zorder=0)
ax.set_axisbelow(True)
for spine in ("top", "right", "left"):
ax.spines[spine].set_visible(False)
ax.spines["bottom"].set_color("#cbd5e1")
ax.tick_params(axis="y", length=0, labelsize=11)
ax.tick_params(axis="x", colors="#94a3b8", labelsize=9)
if CHINESE_FONT_PROP:
for label in [*ax.get_yticklabels(), *ax.get_xticklabels()]:
label.set_fontproperties(CHINESE_FONT_PROP)
max_val = float(sorted_vals.max()) if n else 0.0
for bar, value in zip(bars, sorted_vals):
ax.text(
bar.get_width() + max_val * 0.012,
bar.get_y() + bar.get_height() / 2,
f"{value:.1%}",
va="center",
ha="left",
fontsize=10,
fontweight="bold",
color="#1e293b",
**font_kwargs,
)
if max_val > 0:
ax.set_xlim(0, max_val * 1.18)
fig.tight_layout()
fig.savefig(save_path, dpi=160, bbox_inches="tight")
plt.close(fig)
def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArtifacts:
original_filename = uploaded.filename or ""
if not original_filename:
@@ -409,15 +436,6 @@ def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArti
image_path = IMAGE_DIR / image_filename
summary_rows, summary_sheet_rows = render_survival_chart(df, curves, image_path)
importance_filename = None
try:
importance_values = compute_feature_importance(model, x_test)
if importance_values is not None:
importance_filename = f"importance_{user_id}_{run_id}.png"
render_importance_chart(importance_values, IMAGE_DIR / importance_filename)
except Exception as exc:
logging.exception("生成特征重要性图失败: %s", exc)
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)
@@ -427,7 +445,6 @@ def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArti
excel_path=excel_path,
image_path=image_path,
image_filename=image_filename,
importance_filename=importance_filename,
sample_count=len(summary_rows),
summary_rows=summary_rows,
analysis_text=make_analysis_text(summary_rows),
@@ -441,13 +458,17 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l
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}")
pipe_age = f"{df.iloc[i][PIPE_AGE_COLUMN]}" if PIPE_AGE_COLUMN in df.columns and pd.notna(df.iloc[i][PIPE_AGE_COLUMN]) else "-"
health_probability = interpolate_probability(times, probs, 10)
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)
@@ -456,6 +477,7 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l
"pipe_id": pipe_id,
"pipe_age": pipe_age,
"health_probability": health_probability,
"health_risk": health_risk,
"remaining_life": remaining_life,
"grade_label": grade_label,
"grade_desc": grade_desc,
@@ -466,23 +488,25 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l
{
ID_COLUMN: pipe_id,
PIPE_AGE_COLUMN: pipe_age,
"健康等级": grade_label,
"健康风险值": health_risk,
}
)
if i < CHART_DISPLAY_LIMIT:
plt.step(times, probs, 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.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 len(summary_rows) <= 12:
if min(len(summary_rows), CHART_DISPLAY_LIMIT) <= 12:
plt.legend(loc="best", fontsize=8, prop=CHINESE_FONT_PROP)
ax = plt.gca()
if CHINESE_FONT_PROP:
for label in [*ax.get_xticklabels(), *ax.get_yticklabels()]:
label.set_fontproperties(CHINESE_FONT_PROP)
plt.tight_layout()
plt.tight_layout(rect=(0, 0.07, 1, 1))
plt.savefig(image_path, dpi=160, bbox_inches="tight")
plt.close()
return summary_rows, summary_sheet_rows
@@ -511,7 +535,7 @@ 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")
@@ -520,6 +544,9 @@ def write_prediction_workbook(
sample_df.to_excel(writer, sheet_name="样本数据", index=False)
sample_worksheet = writer.book["样本数据"]
summary_worksheet = writer.book["结果摘要"]
for cell in summary_worksheet["C"][1:]:
cell.number_format = "0.0%"
sample_worksheet.freeze_panes = "A2"
sample_worksheet.auto_filter.ref = sample_worksheet.dimensions
sample_worksheet.column_dimensions["A"].width = 12
-7
View File
@@ -130,16 +130,10 @@ def password_reset_users():
def prediction_result_payload(artifacts, record: UploadRecord) -> dict:
image_url = url_for("static", filename=f"images/{artifacts.image_filename}")
importance_url = (
url_for("static", filename=f"images/{artifacts.importance_filename}")
if artifacts.importance_filename
else None
)
return {
"original_filename": artifacts.original_filename,
"generated_at": format_app_datetime(record.upload_time),
"image_url": image_url,
"importance_url": importance_url,
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"),
"result_url": url_for("main.result_page"),
"sample_count": int(artifacts.sample_count),
@@ -439,7 +433,6 @@ def predict():
{
"message": "预测成功",
"image_url": last_result["image_url"],
"importance_url": last_result["importance_url"],
"excel_url": last_result["excel_url"],
"result_url": last_result["result_url"],
"sample_count": last_result["sample_count"],
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -14,5 +14,5 @@ openpyxl==3.1.5
XlsxWriter==3.2.9
xlrd==2.0.2
scikit-learn==1.8.0
scikit-survival==0.27.0
scikit-learn==1.9.0
scikit-survival==0.28.0
+1 -1
View File
File diff suppressed because one or more lines are too long
-9
View File
@@ -15,8 +15,6 @@ const inlineResult = document.getElementById('inlineResult');
const resultPlaceholder = document.getElementById('resultPlaceholder');
const resultContent = document.getElementById('resultContent');
const resultImage = document.getElementById('resultImage');
const resultImportanceWrap = document.getElementById('resultImportanceWrap');
const resultImportanceImage = document.getElementById('resultImportanceImage');
const excelBtn = document.getElementById('excelBtn');
const resultPageBtn = document.getElementById('resultPageBtn');
let selectedFile = null;
@@ -69,13 +67,6 @@ function updateSelectedFile(file) {
function renderResult(data) {
resultImage.src = data.image_url;
if (data.importance_url) {
resultImportanceImage.src = data.importance_url;
resultImportanceWrap.classList.remove('hidden');
} else {
resultImportanceWrap.classList.add('hidden');
resultImportanceImage.removeAttribute('src');
}
excelBtn.href = data.excel_url;
resultPageBtn.href = data.result_url;
resultPlaceholder.classList.add('hidden');
+7 -11
View File
@@ -7,7 +7,7 @@
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管道健康状态与剩余寿命评估</h1>
<p class="mt-2 max-w-3xl text-sm leading-6 text-textSub">上传标准数据文件,系统将生成生存概率曲线、健康等级摘要、剩余寿命判断和电子表格预测报告。</p>
<p class="mt-2 max-w-3xl text-sm leading-6 text-textSub">上传标准数据文件,系统将生成管道的剩余寿命分析图、健康等级摘要、剩余寿命判断和电子表格预测报告。</p>
</div>
<a href="{{ url_for('main.download_template') }}" class="ui-btn ui-btn-secondary text-primary">
<span class="material-symbols-outlined text-lg">download</span>
@@ -59,7 +59,7 @@
<div class="mt-5 flex min-h-0 flex-1 flex-col items-center justify-center rounded-md border border-dashed border-outline bg-slate-50 px-5 text-center">
<span class="material-symbols-outlined text-4xl text-primary">monitoring</span>
<div class="mt-3 text-sm font-bold text-textMain">暂无预测结果</div>
<div class="mt-1 text-xs leading-5 text-textSub">完成一次分析后,将生成生存概率阶梯图、摘要和电子表格报告。</div>
<div class="mt-1 text-xs leading-5 text-textSub">完成一次分析后,将生成管道的剩余寿命分析图、摘要和电子表格报告。</div>
</div>
</div>
@@ -81,15 +81,11 @@
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
<div class="grid gap-4">
<div class="rounded-md border border-line bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold text-textSub">生存概率阶梯图</div>
<img id="resultImage" src="" alt="预测图" class="h-auto w-full rounded bg-white object-contain">
</div>
<div id="resultImportanceWrap" class="hidden rounded-md border border-line bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold text-textSub">输入因素重要性</div>
<img id="resultImportanceImage" src="" alt="重要性排序图" class="h-auto w-full rounded bg-white object-contain">
<div class="min-h-0 flex-1 overflow-hidden">
<div class="flex h-full min-h-0 flex-col rounded-md border border-line bg-slate-50 p-3">
<div class="mb-2 shrink-0 text-xs font-bold text-textSub">管道的剩余寿命分析图</div>
<div class="min-h-0 flex-1 overflow-hidden rounded bg-white">
<img id="resultImage" src="" alt="预测图" class="h-full w-full object-contain">
</div>
</div>
</div>
+1 -1
View File
@@ -123,7 +123,7 @@
供水管道健康状态与<br>
剩余寿命评估系统<br>
</h1>
<p class="mt-6 text-white/75 text-[15px] max-w-[440px] leading-7">上传管网数据,自动生成健康状态评估、剩余寿命预测与输入因素重要性分析</p>
<p class="mt-6 text-white/75 text-[15px] max-w-[440px] leading-7">上传管网数据,自动生成健康状态评估、剩余寿命预测与电子表格报告</p>
</div>
<div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div>
</section>
+8 -48
View File
@@ -23,25 +23,21 @@
</div>
<div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<section class="rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6">
<div class="mb-4">
<section class="flex h-full flex-col rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6">
<div class="mb-4 shrink-0">
<h2 class="text-xl font-extrabold">管道剩余寿命动态评估</h2>
<p class="mt-1 text-sm text-textSub">生存概率随时间变化的阶梯曲线。</p>
<p class="mt-1 text-sm text-textSub">管道剩余寿命随时间变化的分析曲线。</p>
</div>
<div class="rounded-md border border-line bg-slate-50 p-3">
<img src="{{ result.image_url }}" alt="生存概率阶梯图" class="h-auto w-full rounded bg-white object-contain">
</div>
<div class="mt-4 rounded-md border border-blue-100 bg-blueSoft p-4 text-sm leading-6 text-textSub">
<span class="font-bold text-textMain">分析说明:</span>{{ result.analysis_text }}
<div class="flex min-h-[360px] flex-1 items-center justify-center rounded-md border border-line bg-slate-50 p-3">
<img src="{{ result.image_url }}" alt="管道的剩余寿命分析图" class="max-h-full w-full rounded bg-white object-contain">
</div>
</section>
<aside class="flex flex-col gap-6">
<aside class="flex h-full flex-col gap-6">
<section class="rounded-lg border border-line bg-white p-5 shadow-panel">
<h2 class="text-lg font-extrabold">报告摘要</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-textSub">样本数量</dt><dd class="font-bold">{{ result.sample_count }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-textSub">显示样本</dt><dd class="font-bold">{{ result.summary_rows|length }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-textSub">输出文件</dt><dd class="font-bold">电子表格</dd></div>
</dl>
<a href="{{ result.excel_url }}" class="ui-btn ui-btn-secondary mt-5 w-full text-primary">
@@ -51,51 +47,15 @@
</section>
<section class="flex flex-1 flex-col rounded-lg border border-line bg-white p-5 shadow-panel">
<h2 class="text-lg font-extrabold">结果展示</h2>
<div class="mt-4 overflow-hidden rounded-md border border-line">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-xs font-bold text-textSub">
<tr>
<th class="px-3 py-3 text-left">编号</th>
<th class="px-3 py-3 text-left">管龄</th>
<th class="px-3 py-3 text-left">等级</th>
</tr>
</thead>
<tbody>
{% for item in result.summary_rows %}
<tr class="border-t border-line">
<td class="px-3 py-3 font-semibold">{{ item.pipe_id }}</td>
<td class="px-3 py-3 text-textSub">{{ item.pipe_age }}</td>
<td class="px-3 py-3"><span class="inline-flex rounded-full px-2.5 py-1 text-xs font-bold {{ item.grade_class }}">{{ item.grade_label }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</aside>
{% if result.importance_url %}
<section class="rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6">
<div class="mb-4">
<h2 class="text-xl font-extrabold">模型输入因素重要性排序</h2>
<p class="mt-1 text-sm text-textSub">各输入因素对预测结果的相对影响程度。</p>
</div>
<div class="rounded-md border border-line bg-slate-50 p-3">
<img src="{{ result.importance_url }}" alt="模型输入因素重要性排序图" class="h-auto w-full rounded bg-white object-contain">
</div>
</section>
{% endif %}
<aside class="flex flex-col rounded-lg border border-line bg-white p-5 shadow-panel lg:col-start-2">
<h2 class="text-lg font-extrabold">健康等级说明</h2>
<div class="mt-4 grid flex-1 gap-2 text-sm">
<div class="mt-4 grid gap-2 text-sm">
<div class="rounded-md border border-red-100 bg-dangerSoft p-3"><div class="font-extrabold text-dangerText">I级 · (0, 0.2]</div><p class="mt-1 text-xs leading-5">风险十分严重,需立刻抢修或更新改造。</p></div>
<div class="rounded-md border border-orange-100 bg-orange-50 p-3"><div class="font-extrabold text-orange-600">II级 · (0.2, 0.4]</div><p class="mt-1 text-xs leading-5">风险较为严重,需尽快检修并加频巡检。</p></div>
<div class="rounded-md border border-amber-100 bg-amber-50 p-3"><div class="font-extrabold text-amber-600">III级 · (0.4, 0.6]</div><p class="mt-1 text-xs leading-5">风险较低,安排定期巡检。</p></div>
<div class="rounded-md border border-blue-100 bg-blue-50 p-3"><div class="font-extrabold text-blue-600">IV级 · (0.6, 0.8]</div><p class="mt-1 text-xs leading-5">风险较小,维持常规巡视。</p></div>
<div class="rounded-md border border-blue-100 bg-blueSoft p-3"><div class="font-extrabold text-primary">V级 · (0.8, 1]</div><p class="mt-1 text-xs leading-5">管道安全,维持常规巡视。</p></div>
</div>
</section>
</aside>
</div>
{% else %}
+72 -7
View File
@@ -7,8 +7,10 @@ from unittest.mock import patch
import pandas as pd
from openpyxl import load_workbook
from openpyxl import Workbook
from app.prediction import (
CHART_DISPLAY_LIMIT,
FEATURES,
ID_COLUMN,
INPUT_COLUMNS,
@@ -70,7 +72,8 @@ class PredictionHelpersTest(unittest.TestCase):
worksheet = workbook["Template"]
headers = [cell.value for cell in next(worksheet.iter_rows(min_row=1, max_row=1))]
self.assertEqual(headers, INPUT_COLUMNS)
self.assertEqual(len(headers), len(INPUT_COLUMNS))
self.assertEqual(set(headers), set(INPUT_COLUMNS))
def test_read_input_file_preserves_text_pipe_ids(self) -> None:
with TemporaryDirectory() as temp_dir:
@@ -81,6 +84,36 @@ class PredictionHelpersTest(unittest.TestCase):
self.assertEqual(df[ID_COLUMN].tolist(), ["00123"])
def test_read_input_file_calculates_defects_from_template_detail_sheet(self) -> None:
with TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "input.xlsx"
workbook = Workbook()
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"])
detail = workbook.create_sheet("缺陷计算")
detail.append([
ID_COLUMN,
"泄漏\n权重0.5",
"腐蚀\n权重0.4",
"管瘤\n权重0.1",
"结构性缺陷值",
"气囊\n权重0.5",
"杂质\n权重0.2",
"异物\n权重0.2",
"不明连接\n权重0.1",
"功能性缺陷值",
])
detail.append(["001", "严重", "中度", "轻度", None, "轻度", "", "中度", "严重", None])
workbook.save(output_path)
df = read_input_file(output_path, ".xlsx")
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:
rows = []
for value in ["镀锌", "2-钢塑", 13]:
@@ -110,8 +143,8 @@ class PredictionHelpersTest(unittest.TestCase):
]
summary_rows = [{"pipe_id": "P001"}, {"pipe_id": "P002"}]
summary_sheet_rows = [
{ID_COLUMN: "P001", PIPE_AGE_COLUMN: "10 年", "健康概率": 0.7, "预计剩余寿命": 2.0, "健康等级": "IV级"},
{ID_COLUMN: "P002", PIPE_AGE_COLUMN: "12 年", "健康概率": 0.6, "预计剩余寿命": 1.5, "健康等级": "III级"},
{ID_COLUMN: "P001", PIPE_AGE_COLUMN: "10 年", "健康风险值": 0.3},
{ID_COLUMN: "P002", PIPE_AGE_COLUMN: "12 年", "健康风险值": 0.4},
]
with TemporaryDirectory() as temp_dir:
@@ -122,7 +155,7 @@ class PredictionHelpersTest(unittest.TestCase):
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"])
@@ -140,7 +173,7 @@ class PredictionHelpersTest(unittest.TestCase):
def test_prediction_workbook_writes_pipe_ids_as_excel_text(self) -> None:
curves = [DummyCurve([1], [0.9])]
summary_rows = [{"pipe_id": "00123"}]
summary_sheet_rows = [{ID_COLUMN: "00123", PIPE_AGE_COLUMN: "10 年", "健康等级": "V级"}]
summary_sheet_rows = [{ID_COLUMN: "00123", PIPE_AGE_COLUMN: "10 年", "健康风险值": 0.1}]
with TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "prediction.xlsx"
@@ -161,13 +194,45 @@ class PredictionHelpersTest(unittest.TestCase):
with TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "chart.png"
with patch("app.prediction.plt.xlabel") as xlabel, patch("app.prediction.plt.ylabel") as ylabel:
render_survival_chart(df, [DummyCurve([1, 2], [0.9, 0.7])], output_path)
with (
patch("app.prediction.plt.xlabel") as xlabel,
patch("app.prediction.plt.ylabel") as ylabel,
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)
self.assertAlmostEqual(summary_rows[0]["health_probability"], 0.4)
self.assertAlmostEqual(summary_rows[0]["health_risk"], 0.6)
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], "健康风险")
title.assert_called_once()
self.assertEqual(title.call_args.args[0], "管道的剩余寿命分析图")
figtext.assert_called_once()
self.assertIn(f"{CHART_DISPLAY_LIMIT}条管道的示例数据", figtext.call_args.args[2])
def test_survival_chart_only_plots_first_ten_pipes(self) -> None:
sample_count = CHART_DISPLAY_LIMIT + 2
df = pd.DataFrame(
{
ID_COLUMN: [f"P{i:03d}" for i in range(sample_count)],
PIPE_AGE_COLUMN: [12] * sample_count,
}
)
curves = [DummyCurve([1, 10, 12], [0.9, 0.7, 0.4]) for _ 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)
self.assertEqual(step.call_count, CHART_DISPLAY_LIMIT)
self.assertEqual(len(summary_rows), sample_count)
self.assertEqual(len(summary_sheet_rows), sample_count)
if __name__ == "__main__":