Update prediction results workflow
This commit is contained in:
+143
-116
@@ -61,6 +61,16 @@ PIPE_AGE_COLUMN = "管龄(年)"
|
|||||||
LEGACY_PIPE_AGE_COLUMN = "管龄"
|
LEGACY_PIPE_AGE_COLUMN = "管龄"
|
||||||
STATUS_COLUMN = "状态"
|
STATUS_COLUMN = "状态"
|
||||||
EVENT_AGE_COLUMN = "事件/观察管龄(年)"
|
EVENT_AGE_COLUMN = "事件/观察管龄(年)"
|
||||||
|
COLUMN_ALIASES = {
|
||||||
|
"管径(mm)": "管径",
|
||||||
|
"管径(mm)": "管径",
|
||||||
|
"流速(m/s)": "流速",
|
||||||
|
"压力(MPa)": "压力",
|
||||||
|
"压力(MPa)": "压力",
|
||||||
|
"温度(℃)": "温度",
|
||||||
|
"年均降雨量(mm)": "降雨量",
|
||||||
|
"降雨量(mm)": "降雨量",
|
||||||
|
}
|
||||||
INPUT_COLUMNS = [
|
INPUT_COLUMNS = [
|
||||||
ID_COLUMN,
|
ID_COLUMN,
|
||||||
PIPE_AGE_COLUMN,
|
PIPE_AGE_COLUMN,
|
||||||
@@ -68,6 +78,7 @@ INPUT_COLUMNS = [
|
|||||||
EVENT_AGE_COLUMN,
|
EVENT_AGE_COLUMN,
|
||||||
*FEATURES,
|
*FEATURES,
|
||||||
]
|
]
|
||||||
|
CHART_DISPLAY_LIMIT = 10
|
||||||
MATERIAL_COLUMN = "管材"
|
MATERIAL_COLUMN = "管材"
|
||||||
MATERIAL_CODE_OPTIONS = [
|
MATERIAL_CODE_OPTIONS = [
|
||||||
(1, "镀锌"),
|
(1, "镀锌"),
|
||||||
@@ -103,6 +114,7 @@ MATERIAL_ALIAS_TO_CODE.update(
|
|||||||
)
|
)
|
||||||
SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"}
|
SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"}
|
||||||
CHINESE_FONT_PROP = None
|
CHINESE_FONT_PROP = None
|
||||||
|
DEFECT_GRADE_VALUES = {"无": 0.0, "轻度": 1.0, "中度": 3.0, "严重": 5.0}
|
||||||
|
|
||||||
|
|
||||||
class PredictionError(Exception):
|
class PredictionError(Exception):
|
||||||
@@ -119,12 +131,35 @@ class PredictionArtifacts:
|
|||||||
excel_path: Path
|
excel_path: Path
|
||||||
image_path: Path
|
image_path: Path
|
||||||
image_filename: str
|
image_filename: str
|
||||||
importance_filename: str | None
|
|
||||||
sample_count: int
|
sample_count: int
|
||||||
summary_rows: list[dict[str, Any]]
|
summary_rows: list[dict[str, Any]]
|
||||||
analysis_text: str
|
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():
|
def configure_matplotlib_fonts():
|
||||||
for font_path in CHINESE_FONT_FILES:
|
for font_path in CHINESE_FONT_FILES:
|
||||||
path = Path(font_path)
|
path = Path(font_path)
|
||||||
@@ -159,6 +194,10 @@ def load_model(model_path: str):
|
|||||||
if not os.path.exists(model_path):
|
if not os.path.exists(model_path):
|
||||||
raise FileNotFoundError(f"未找到模型文件: {model_path}")
|
raise FileNotFoundError(f"未找到模型文件: {model_path}")
|
||||||
model = joblib.load(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)
|
feature_names = getattr(model, "feature_names_in_", None)
|
||||||
if feature_names is not None and list(feature_names) != FEATURES:
|
if feature_names is not None and list(feature_names) != FEATURES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -195,17 +234,25 @@ def read_input_file(path: Path, suffix: str) -> pd.DataFrame:
|
|||||||
if suffix == ".csv":
|
if suffix == ".csv":
|
||||||
df = pd.read_csv(path, dtype={ID_COLUMN: "string"})
|
df = pd.read_csv(path, dtype={ID_COLUMN: "string"})
|
||||||
else:
|
else:
|
||||||
df = pd.read_excel(path, dtype={ID_COLUMN: "string"})
|
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:
|
except Exception as exc:
|
||||||
logging.exception("文件解析失败: %s", exc)
|
logging.exception("文件解析失败: %s", exc)
|
||||||
raise PredictionError("文件解析失败,请检查编码或表格格式。")
|
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:
|
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:
|
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})
|
aliases[LEGACY_PIPE_AGE_COLUMN] = PIPE_AGE_COLUMN
|
||||||
return df
|
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:
|
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)
|
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:
|
def validate_input_frame(df: pd.DataFrame) -> None:
|
||||||
if df.empty:
|
if df.empty:
|
||||||
raise PredictionError("上传文件没有可预测的数据。")
|
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:
|
def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArtifacts:
|
||||||
original_filename = uploaded.filename or ""
|
original_filename = uploaded.filename or ""
|
||||||
if not original_filename:
|
if not original_filename:
|
||||||
@@ -409,15 +436,6 @@ def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArti
|
|||||||
image_path = IMAGE_DIR / image_filename
|
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(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
|
safe_stem = Path(saved_filename).stem
|
||||||
excel_path = user_dir / f"{safe_stem}_pre.xlsx"
|
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, curves, summary_rows, summary_sheet_rows)
|
||||||
@@ -427,7 +445,6 @@ def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArti
|
|||||||
excel_path=excel_path,
|
excel_path=excel_path,
|
||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
image_filename=image_filename,
|
image_filename=image_filename,
|
||||||
importance_filename=importance_filename,
|
|
||||||
sample_count=len(summary_rows),
|
sample_count=len(summary_rows),
|
||||||
summary_rows=summary_rows,
|
summary_rows=summary_rows,
|
||||||
analysis_text=make_analysis_text(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))
|
plt.figure(figsize=(10, 5.6))
|
||||||
summary_rows: list[dict[str, Any]] = []
|
summary_rows: list[dict[str, Any]] = []
|
||||||
summary_sheet_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):
|
for i, curve in enumerate(curves):
|
||||||
times = [float(x) for x in list(curve.x)]
|
times = [float(x) for x in list(curve.x)]
|
||||||
probs = [float(y) for y in list(curve.y)]
|
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_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 "-"
|
raw_pipe_age = df.iloc[i][PIPE_AGE_COLUMN] if PIPE_AGE_COLUMN in df.columns else None
|
||||||
health_probability = interpolate_probability(times, probs, 10)
|
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)
|
remaining_life = estimate_remaining_life(times, probs)
|
||||||
grade_label, grade_desc, grade_class = grade_info(health_probability)
|
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_id": pipe_id,
|
||||||
"pipe_age": pipe_age,
|
"pipe_age": pipe_age,
|
||||||
"health_probability": health_probability,
|
"health_probability": health_probability,
|
||||||
|
"health_risk": health_risk,
|
||||||
"remaining_life": remaining_life,
|
"remaining_life": remaining_life,
|
||||||
"grade_label": grade_label,
|
"grade_label": grade_label,
|
||||||
"grade_desc": grade_desc,
|
"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,
|
ID_COLUMN: pipe_id,
|
||||||
PIPE_AGE_COLUMN: pipe_age,
|
PIPE_AGE_COLUMN: pipe_age,
|
||||||
"健康等级": grade_label,
|
"健康风险值": health_risk,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
plt.step(times, probs, where="post", linewidth=2, label=pipe_id)
|
if i < CHART_DISPLAY_LIMIT:
|
||||||
|
plt.step(times, probs, where="post", linewidth=2, label=pipe_id)
|
||||||
|
|
||||||
font_kwargs = chinese_font_kwargs()
|
font_kwargs = chinese_font_kwargs()
|
||||||
plt.xlabel("管龄(年)", **font_kwargs)
|
plt.xlabel("管龄(年)", **font_kwargs)
|
||||||
plt.ylabel("健康风险", **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)
|
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)
|
plt.legend(loc="best", fontsize=8, prop=CHINESE_FONT_PROP)
|
||||||
ax = plt.gca()
|
ax = plt.gca()
|
||||||
if CHINESE_FONT_PROP:
|
if CHINESE_FONT_PROP:
|
||||||
for label in [*ax.get_xticklabels(), *ax.get_yticklabels()]:
|
for label in [*ax.get_xticklabels(), *ax.get_yticklabels()]:
|
||||||
label.set_fontproperties(CHINESE_FONT_PROP)
|
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.savefig(image_path, dpi=160, bbox_inches="tight")
|
||||||
plt.close()
|
plt.close()
|
||||||
return summary_rows, summary_sheet_rows
|
return summary_rows, summary_sheet_rows
|
||||||
@@ -511,7 +535,7 @@ def write_prediction_workbook(
|
|||||||
]
|
]
|
||||||
|
|
||||||
with pd.ExcelWriter(excel_path, engine="openpyxl") as writer:
|
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)
|
sample_df = pd.DataFrame(sample_data_rows, columns=sample_columns)
|
||||||
if ID_COLUMN in summary_df.columns:
|
if ID_COLUMN in summary_df.columns:
|
||||||
summary_df[ID_COLUMN] = summary_df[ID_COLUMN].astype("string")
|
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_df.to_excel(writer, sheet_name="样本数据", index=False)
|
||||||
|
|
||||||
sample_worksheet = writer.book["样本数据"]
|
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.freeze_panes = "A2"
|
||||||
sample_worksheet.auto_filter.ref = sample_worksheet.dimensions
|
sample_worksheet.auto_filter.ref = sample_worksheet.dimensions
|
||||||
sample_worksheet.column_dimensions["A"].width = 12
|
sample_worksheet.column_dimensions["A"].width = 12
|
||||||
|
|||||||
@@ -130,16 +130,10 @@ def password_reset_users():
|
|||||||
|
|
||||||
def prediction_result_payload(artifacts, record: UploadRecord) -> dict:
|
def prediction_result_payload(artifacts, record: UploadRecord) -> dict:
|
||||||
image_url = url_for("static", filename=f"images/{artifacts.image_filename}")
|
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 {
|
return {
|
||||||
"original_filename": artifacts.original_filename,
|
"original_filename": artifacts.original_filename,
|
||||||
"generated_at": format_app_datetime(record.upload_time),
|
"generated_at": format_app_datetime(record.upload_time),
|
||||||
"image_url": image_url,
|
"image_url": image_url,
|
||||||
"importance_url": importance_url,
|
|
||||||
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"),
|
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"),
|
||||||
"result_url": url_for("main.result_page"),
|
"result_url": url_for("main.result_page"),
|
||||||
"sample_count": int(artifacts.sample_count),
|
"sample_count": int(artifacts.sample_count),
|
||||||
@@ -439,7 +433,6 @@ def predict():
|
|||||||
{
|
{
|
||||||
"message": "预测成功",
|
"message": "预测成功",
|
||||||
"image_url": last_result["image_url"],
|
"image_url": last_result["image_url"],
|
||||||
"importance_url": last_result["importance_url"],
|
|
||||||
"excel_url": last_result["excel_url"],
|
"excel_url": last_result["excel_url"],
|
||||||
"result_url": last_result["result_url"],
|
"result_url": last_result["result_url"],
|
||||||
"sample_count": last_result["sample_count"],
|
"sample_count": last_result["sample_count"],
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -14,5 +14,5 @@ openpyxl==3.1.5
|
|||||||
XlsxWriter==3.2.9
|
XlsxWriter==3.2.9
|
||||||
xlrd==2.0.2
|
xlrd==2.0.2
|
||||||
|
|
||||||
scikit-learn==1.8.0
|
scikit-learn==1.9.0
|
||||||
scikit-survival==0.27.0
|
scikit-survival==0.28.0
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -15,8 +15,6 @@ const inlineResult = document.getElementById('inlineResult');
|
|||||||
const resultPlaceholder = document.getElementById('resultPlaceholder');
|
const resultPlaceholder = document.getElementById('resultPlaceholder');
|
||||||
const resultContent = document.getElementById('resultContent');
|
const resultContent = document.getElementById('resultContent');
|
||||||
const resultImage = document.getElementById('resultImage');
|
const resultImage = document.getElementById('resultImage');
|
||||||
const resultImportanceWrap = document.getElementById('resultImportanceWrap');
|
|
||||||
const resultImportanceImage = document.getElementById('resultImportanceImage');
|
|
||||||
const excelBtn = document.getElementById('excelBtn');
|
const excelBtn = document.getElementById('excelBtn');
|
||||||
const resultPageBtn = document.getElementById('resultPageBtn');
|
const resultPageBtn = document.getElementById('resultPageBtn');
|
||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
@@ -69,13 +67,6 @@ function updateSelectedFile(file) {
|
|||||||
|
|
||||||
function renderResult(data) {
|
function renderResult(data) {
|
||||||
resultImage.src = data.image_url;
|
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;
|
excelBtn.href = data.excel_url;
|
||||||
resultPageBtn.href = data.result_url;
|
resultPageBtn.href = data.result_url;
|
||||||
resultPlaceholder.classList.add('hidden');
|
resultPlaceholder.classList.add('hidden');
|
||||||
|
|||||||
+7
-11
@@ -7,7 +7,7 @@
|
|||||||
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管道健康状态与剩余寿命评估</h1>
|
<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>
|
</div>
|
||||||
<a href="{{ url_for('main.download_template') }}" class="ui-btn ui-btn-secondary text-primary">
|
<a href="{{ url_for('main.download_template') }}" class="ui-btn ui-btn-secondary text-primary">
|
||||||
<span class="material-symbols-outlined text-lg">download</span>
|
<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">
|
<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>
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,15 +81,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
<div class="grid gap-4">
|
<div class="flex h-full min-h-0 flex-col rounded-md border border-line bg-slate-50 p-3">
|
||||||
<div class="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="mb-2 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-auto w-full rounded bg-white object-contain">
|
<img id="resultImage" src="" alt="预测图" class="h-full w-full 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
供水管道健康状态与<br>
|
供水管道健康状态与<br>
|
||||||
剩余寿命评估系统<br>
|
剩余寿命评估系统<br>
|
||||||
</h1>
|
</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>
|
||||||
<div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div>
|
<div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+13
-53
@@ -23,25 +23,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
<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">
|
<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">
|
<div class="mb-4 shrink-0">
|
||||||
<h2 class="text-xl font-extrabold">管道剩余寿命动态评估</h2>
|
<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>
|
||||||
<div class="rounded-md border border-line bg-slate-50 p-3">
|
<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="h-auto w-full rounded bg-white object-contain">
|
<img src="{{ result.image_url }}" alt="管道的剩余寿命分析图" class="max-h-full 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>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="rounded-lg border border-line bg-white p-5 shadow-panel">
|
||||||
<h2 class="text-lg font-extrabold">报告摘要</h2>
|
<h2 class="text-lg font-extrabold">报告摘要</h2>
|
||||||
<dl class="mt-4 space-y-3 text-sm">
|
<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.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>
|
<div class="flex justify-between gap-4"><dt class="text-textSub">输出文件</dt><dd class="font-bold">电子表格</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<a href="{{ result.excel_url }}" class="ui-btn ui-btn-secondary mt-5 w-full text-primary">
|
<a href="{{ result.excel_url }}" class="ui-btn ui-btn-secondary mt-5 w-full text-primary">
|
||||||
@@ -51,52 +47,16 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="flex flex-1 flex-col rounded-lg border border-line bg-white p-5 shadow-panel">
|
<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>
|
<h2 class="text-lg font-extrabold">健康等级说明</h2>
|
||||||
<div class="mt-4 overflow-hidden rounded-md border border-line">
|
<div class="mt-4 grid gap-2 text-sm">
|
||||||
<table class="w-full 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>
|
||||||
<thead class="bg-slate-50 text-xs font-bold text-textSub">
|
<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>
|
||||||
<tr>
|
<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>
|
||||||
<th class="px-3 py-3 text-left">编号</th>
|
<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>
|
||||||
<th class="px-3 py-3 text-left">管龄</th>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</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="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>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border border-line bg-white p-10 text-center shadow-panel">
|
<section class="rounded-lg border border-line bg-white p-10 text-center shadow-panel">
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
from app.prediction import (
|
from app.prediction import (
|
||||||
|
CHART_DISPLAY_LIMIT,
|
||||||
FEATURES,
|
FEATURES,
|
||||||
ID_COLUMN,
|
ID_COLUMN,
|
||||||
INPUT_COLUMNS,
|
INPUT_COLUMNS,
|
||||||
@@ -70,7 +72,8 @@ class PredictionHelpersTest(unittest.TestCase):
|
|||||||
worksheet = workbook["Template"]
|
worksheet = workbook["Template"]
|
||||||
headers = [cell.value for cell in next(worksheet.iter_rows(min_row=1, max_row=1))]
|
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:
|
def test_read_input_file_preserves_text_pipe_ids(self) -> None:
|
||||||
with TemporaryDirectory() as temp_dir:
|
with TemporaryDirectory() as temp_dir:
|
||||||
@@ -81,6 +84,36 @@ class PredictionHelpersTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(df[ID_COLUMN].tolist(), ["00123"])
|
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:
|
def test_prepare_model_features_maps_material_aliases_to_codes(self) -> None:
|
||||||
rows = []
|
rows = []
|
||||||
for value in ["镀锌", "2-钢塑", 13]:
|
for value in ["镀锌", "2-钢塑", 13]:
|
||||||
@@ -110,8 +143,8 @@ class PredictionHelpersTest(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
summary_rows = [{"pipe_id": "P001"}, {"pipe_id": "P002"}]
|
summary_rows = [{"pipe_id": "P001"}, {"pipe_id": "P002"}]
|
||||||
summary_sheet_rows = [
|
summary_sheet_rows = [
|
||||||
{ID_COLUMN: "P001", PIPE_AGE_COLUMN: "10 年", "健康概率": 0.7, "预计剩余寿命": 2.0, "健康等级": "IV级"},
|
{ID_COLUMN: "P001", PIPE_AGE_COLUMN: "10 年", "健康风险值": 0.3},
|
||||||
{ID_COLUMN: "P002", PIPE_AGE_COLUMN: "12 年", "健康概率": 0.6, "预计剩余寿命": 1.5, "健康等级": "III级"},
|
{ID_COLUMN: "P002", PIPE_AGE_COLUMN: "12 年", "健康风险值": 0.4},
|
||||||
]
|
]
|
||||||
|
|
||||||
with TemporaryDirectory() as temp_dir:
|
with TemporaryDirectory() as temp_dir:
|
||||||
@@ -122,7 +155,7 @@ class PredictionHelpersTest(unittest.TestCase):
|
|||||||
self.assertEqual(workbook.sheet_names, ["结果摘要", "样本数据"])
|
self.assertEqual(workbook.sheet_names, ["结果摘要", "样本数据"])
|
||||||
|
|
||||||
summary_data = pd.read_excel(output_path, sheet_name="结果摘要")
|
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="样本数据")
|
sample_data = pd.read_excel(output_path, sheet_name="样本数据")
|
||||||
self.assertEqual(sample_data.columns.tolist(), ["管龄(年)", "P001", "P002"])
|
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:
|
def test_prediction_workbook_writes_pipe_ids_as_excel_text(self) -> None:
|
||||||
curves = [DummyCurve([1], [0.9])]
|
curves = [DummyCurve([1], [0.9])]
|
||||||
summary_rows = [{"pipe_id": "00123"}]
|
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:
|
with TemporaryDirectory() as temp_dir:
|
||||||
output_path = Path(temp_dir) / "prediction.xlsx"
|
output_path = Path(temp_dir) / "prediction.xlsx"
|
||||||
@@ -161,13 +194,45 @@ class PredictionHelpersTest(unittest.TestCase):
|
|||||||
|
|
||||||
with TemporaryDirectory() as temp_dir:
|
with TemporaryDirectory() as temp_dir:
|
||||||
output_path = Path(temp_dir) / "chart.png"
|
output_path = Path(temp_dir) / "chart.png"
|
||||||
with patch("app.prediction.plt.xlabel") as xlabel, patch("app.prediction.plt.ylabel") as ylabel:
|
with (
|
||||||
render_survival_chart(df, [DummyCurve([1, 2], [0.9, 0.7])], output_path)
|
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()
|
xlabel.assert_called_once()
|
||||||
self.assertEqual(xlabel.call_args.args[0], "管龄(年)")
|
self.assertEqual(xlabel.call_args.args[0], "管龄(年)")
|
||||||
ylabel.assert_called_once()
|
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], "管道的剩余寿命分析图")
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user