From 0a12eb7f3b384b4ebfdf1c4a729a3617c032dae1 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 6 Jul 2026 18:33:52 +0800 Subject: [PATCH] feat: update prediction workbook output --- app/prediction.py | 137 +++++++++++++++++++++++++++----- app/routes.py | 3 +- example.xlsx | Bin 11499 -> 13081 bytes static/js/dashboard.js | 41 ---------- templates/base.html | 9 --- templates/result.html | 6 +- tests/test_auth_registration.py | 13 +++ tests/test_prediction.py | 88 ++++++++++++++++++-- 8 files changed, 219 insertions(+), 78 deletions(-) diff --git a/app/prediction.py b/app/prediction.py index 86483c6..e55edaf 100644 --- a/app/prediction.py +++ b/app/prediction.py @@ -58,6 +58,39 @@ FEATURES = [ ID_COLUMN = "管道编号" PIPE_AGE_COLUMN = "管龄" +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_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 + } +) SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"} CHINESE_FONT_PROP = None @@ -150,13 +183,21 @@ def secure_upload_name(original_filename: str, run_id: str) -> tuple[str, str]: def read_input_file(path: Path, suffix: str) -> pd.DataFrame: try: if suffix == ".csv": - return pd.read_csv(path) - return pd.read_excel(path) + return pd.read_csv(path, dtype={ID_COLUMN: "string"}) + return pd.read_excel(path, dtype={ID_COLUMN: "string"}) except Exception as exc: logging.exception("文件解析失败: %s", exc) raise PredictionError("文件解析失败,请检查编码或表格格式。") +def normalize_id_value(value: Any, fallback: str) -> str: + if pd.isna(value): + return fallback + if isinstance(value, str): + return value.strip() + return str(value) + + def validate_input_frame(df: pd.DataFrame) -> None: if df.empty: raise PredictionError("上传文件没有可预测的数据。") @@ -166,6 +207,28 @@ def validate_input_frame(df: pd.DataFrame) -> None: raise PredictionError(f"缺少必要字段: {', '.join(missing)}") +def normalize_material_code(value: Any) -> int: + if pd.isna(value): + raise PredictionError("管材不能为空。") + if isinstance(value, str): + key = value.strip().casefold() + else: + numeric_value = float(value) + if not numeric_value.is_integer(): + raise PredictionError(f"管材编码无效: {value}") + key = str(int(numeric_value)) + code = MATERIAL_ALIAS_TO_CODE.get(key) + if code is None: + raise PredictionError(f"管材编码无效: {value}") + return code + + +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 + + def grade_info(probability: float) -> tuple[str, str, str]: if probability <= 0.2: return ("I级", "管道安全风险十分严重,需立刻进行抢修或更新改造", "bg-dangerSoft text-dangerText") @@ -318,7 +381,7 @@ def run_prediction(uploaded: FileStorage, user_id: int, model) -> PredictionArti try: df = read_input_file(original_path, suffix) validate_input_frame(df) - x_test = df[FEATURES].copy() + x_test = prepare_model_features(df) try: curves = model.predict_survival_function(x_test) except Exception as exc: @@ -365,7 +428,7 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l 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 = str(df.iloc[i][ID_COLUMN]) if pd.notna(df.iloc[i][ID_COLUMN]) else 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 "-" health_probability = interpolate_probability(times, probs, 10) remaining_life = estimate_remaining_life(times, probs) @@ -386,15 +449,13 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l { "管道编号": pipe_id, "管龄": pipe_age, - "健康概率": health_probability, - "预计剩余寿命": remaining_life, "健康等级": grade_label, } ) plt.step(times, probs, where="post", linewidth=2, label=pipe_id) font_kwargs = chinese_font_kwargs() - plt.xlabel("预测时间轴(年)", **font_kwargs) + plt.xlabel("管龄(年)", **font_kwargs) plt.ylabel("生存概率", **font_kwargs) plt.title("预测分析图", **font_kwargs) plt.grid(alpha=0.18) @@ -416,22 +477,58 @@ def write_prediction_workbook( summary_rows: list[dict[str, Any]], summary_sheet_rows: list[dict[str, Any]], ) -> None: - sample_data_rows: list[dict[str, Any]] = [] + 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)] pipe_id = summary_rows[i]["pipe_id"] - for time, probability in zip(times, probs): - sample_data_rows.append( - { - "管道编号": pipe_id, - "样本序号": i + 1, - "时间(年)": time, - "生存概率": probability, - "风险概率": 1 - probability, - } - ) + chart_times.update(times) + chart_series.append((pipe_id, dict(zip(times, probs)))) + + sorted_times = sorted(chart_times) + sample_columns = ["管龄(年)", *[pipe_id for pipe_id, _ in chart_series]] + sample_data_rows = [ + [time, *[series.get(time) for _, series in chart_series]] + for time in sorted_times + ] with pd.ExcelWriter(excel_path, engine="openpyxl") as writer: - pd.DataFrame(summary_sheet_rows).to_excel(writer, sheet_name="结果摘要", index=False) - pd.DataFrame(sample_data_rows).to_excel(writer, sheet_name="样本数据", index=False) + 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") + + summary_df.to_excel(writer, sheet_name="结果摘要", index=False) + sample_df.to_excel(writer, sheet_name="样本数据", index=False) + + sample_worksheet = writer.book["样本数据"] + sample_worksheet.freeze_panes = "A2" + sample_worksheet.auto_filter.ref = sample_worksheet.dimensions + sample_worksheet.column_dimensions["A"].width = 12 + for column_cells in sample_worksheet.iter_cols(min_col=2, max_col=sample_worksheet.max_column): + header_cell = column_cells[0] + sample_worksheet.column_dimensions[header_cell.column_letter].width = max(12, len(str(header_cell.value)) + 2) + for cell in column_cells[1:]: + cell.number_format = "0.0%" + + for sheet_name in ("结果摘要", "样本数据"): + worksheet = writer.book[sheet_name] + header_cells = next(worksheet.iter_rows(min_row=1, max_row=1), []) + id_column_index = None + for cell in header_cells: + if cell.value == ID_COLUMN: + id_column_index = cell.column + break + if id_column_index is None: + continue + for cell in worksheet.iter_cols( + min_col=id_column_index, + max_col=id_column_index, + min_row=2, + max_row=worksheet.max_row, + ): + for item in cell: + if item.value is not None: + item.value = str(item.value) + item.number_format = "@" diff --git a/app/routes.py b/app/routes.py index add9fde..3b4be7d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -30,6 +30,7 @@ from .time_utils import format_datetime_for_timezone bp = Blueprint("main", __name__) REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf" +TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx" REGISTRATION_SETTING_KEY = "allow_registration" RECORDS_PER_PAGE = 10 @@ -375,7 +376,7 @@ def download_template(): template_path = BASE_DIR / "example.xlsx" if not template_path.exists(): abort(404) - return send_file(template_path, as_attachment=True, download_name="example.xlsx") + return send_file(template_path, as_attachment=True, download_name=TEMPLATE_EXCEL_NAME) @bp.route("/reference_pdf") diff --git a/example.xlsx b/example.xlsx index da9b083b971e5809b3bacf1e3801fd6bd2ebcee1..60e345c35b961bf52de722317f93433150f6d278 100644 GIT binary patch delta 6221 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 delta 4679 zcmZ8lXE+?(wjMngqm2>_M$2HLN7QJegh+;rAc%Q!YSclZBwA#&=!P&v`G}e* z(PoH5?>&0C&UfxP=bpR&>_6|b_FjAKwchov47)W`cBCE|IT%0*xCQ_Kpa9a5CqzFI z03e>Cik*)XovQENE&+_(!W{6Fm}6xtJ7o01pi?|~7?>>Z;(Tg5&vCVg z!Q81XW@hOV#9n4nM#xm7l`v@zta+)^%6fm?FFCGa((DArBw$-7^-Wz2_u`pUOY`*s z9Jj=8m$=VG(Px-c6VxEAKbxvgw2(HQFO-gU#i$C6qD?vsZwrTK3s$BYmO{G<*kGf* z$lb=@-xY6=0AuF^>#J-R)C1jD)fpAjoJ%i}!^+UP{syX2!W@LiP>mTZKck;m3h{7M z{{+n_QQa!B;`Fkfucz(%?A^Css}rSXobF3PU@46hP0FLsm3ajC@fO}yW>BYmbf+yb z%K+Uafv-+JxXkxRq@N89fBn7hCL)8IcX$l&q-outHqpEoaDpLy&^1bTHnu7HIn$d$ zpzb%pH8CH1^xO^G7C280@(5)E@e}N@?_cpE>ApfY6xOx*9$a?1MjeC><$kwi6BQUY ze?8OV(`t*q_f_R82A7v)fPcjV<9>s(W|B;r3=PGS3kZ?{0OVI;qQ61~xQP3^dpSRI zcXt*GaCP}$M0A^#U=i4IDdf5_AjIQ*lj9h6_TGCIX&gel$j-=owGm z{dT8L9OFcl{qyH`ixcw7GxHfcIq|N~mcFlcHi44}eG@id z1xnEHf~)all>dI8wtc6kmuMYW@pR%sii6T|^f5IwFbCm%IQ4jW-KR||s41;nt#>ze zzR~cyloa16%qmvMV!k7(Vr4BN4rq{e9&7v5mFU-}F*f<+8#4)6QHc0M6c~*dA`u7c zxN4u5R+m&!2~#n0K0Il97S=vVLain@m?9@Y@1>4%Lh-HTtI2&yX84^gATPS8Az~c+ zW{>2-vQnOW&KE|gJn9VHo%ELiEi0nMwm#(YEd{S|yOtBPATZzy`xEuXiXMIe9^LHq zZ;KQ*Z1r1lCJ{#$lQrS*RkqR3rP&b^K2r&<2mDxj%6AFUyUN&wrZ!q~e!Rru2g8-} za*oD0F4*i#$9uBz6hy1e6l0F>2b+?sst(#DBLGc-&mes=Y4_Q~1#o9Y;QH>%!V45OtD z{gM0OTAMZfKn%y+^lOG1p^y#og*A;{df_80Otlhy65JAfxXk12=lDa!wQEkBmQ9v^ zHmIyawq~;K2Y<0E$QfWAvt)Ks8~Ba7omQVoe>BMhz*yY?*Zf5-O-kdcT%2@44gd@@ zVMu_y=&OX)VYmqGIj3qrB;lCRDsX!QvA6TZ4E!Y8Q<@2q$&A;2yC-`%Q<3S!K(m?e zY{!rzl2a1e2owI8^Aj_n;GLn(_inO($0ZJ%H=(&=Wp}xAdb0Tw=f(1&iI-J4XOc&$ zUS$A%ZhIm$?;JXBZrYl(D1BOllJ%TcQrZ<{c^C#AmoEsf;AxOjbJ_C&EBwY|%k7+54nZiOpc+tZ9T-Z%m#QKbg{ z&J_+vYTCEVdn(n8^TT$&#PNF2kW8UP)X(v+abhATS+#!8d9t6?fp;v2?nCSkCd8_? zW;bHAqYA=MCzXB{N|@=du}e_YO@X;1GKer(aeAr%;Dc z!uRIlU`i5oYqKA&WsYWJuAw8i5@L`(QDb_{oiOH0%ykSdBSz{IA5P0U5#F!iPCstw zP0P9wWaI0Yt#EX6n%CLiLnFq*5)PW-a=LJhQ$!4AHAo7M zLPaPdG|4*ed5)R8$`biC{P^rmR;| z_$>JD>9HAZ}}!&1X@z9qL9}4Fx`m1#SvMpVZ!=L%-~Z2Hefdlbj;V$svZ^r9oJGqz?bzSp!n7Z!!uzEzy1rJF)HbzPH``@M$ zB{Ub;%y2y3pYne{qF5#vvHI5Y5#>ii&bMRUcZZppq>y!bd?NL2ius1eld@2m(z7Y`sD*Q2lyEeP?lo zwAf1&d_!4C+C=BoW3T)ip6(?G2+!uvZdi&n*h7>2T^Bi- ?T7R%biuDU-b3={%E z59oHNGEi+d{Zfo*CQc(h8Og4wZBy@)s8`Frv|+B{95M^1fEtQ|BMe=3_<~jj=$+<9 z8e#%z=Q`D8zaF34M_o6WYTi~}$VT+j6@h-RlG5ZodfHTr4adwCd;%faZx_1;kq9a@ z2mpQJ2k83QjZ5lNS6Y87DZNNC1?}jg$7s~mOXp>xpJR+|e}uzdM>|SKF>D4SHHzp$ zMhaD{!rpp897K@MR^NBdZ}R~A&B?qAOq9}l8ce3+h7=>GTDQNCPmZe&@88_ILDziT zVG&LfsD6=|dLGzpJoPi$=kfU2oF+VH_3{14h5gTX?$Piq7ZV#=$N2A_cR0bNFkAWL z1n|n)l8Ey+2E(-ihG82*x})|-iwuI)M$eCAN3?$BiD;Mj-@F1U%KihuUo~ceSI@@} zz~o8_q9YR9Xxb$h5L;oF36q z6bmO_%kb;*blYMNJ-brB$`!p>j3q1fHE=A4h7r+(1wf#Jn~1Qws& zi_L-X{4zpWP@BQ1d`UOecEKt~!25&JYO$z+1gf?44lBZg@`Y#zbiM9o;U^hU*se;| z5mVn%8VEneT|S6DKax*)13Q+R2LY?*XcV7#yhhP$u0>SxH`@qG*Nd{Z%VR1;py zZM(Cx^P|gb*+o7ceOig%GF#J#Y9YJ3pdr*#O&stn{V~73zPqH& zE#@}6_WQg!U@BD%J&^ff)uq)vBgh{uDe*5tWEap#U0K(czGFne1!m%#*=_) zDZr78tzAe)ruND?&IoiUsu`W@Wbbu%!34%1`+QvT#|s)X{AFYcfIm%!5p=82M$`9% zxZl>pYBc040;TiOVNV>mo~xzK3-@~wDbhZr*^R_M8QqVvH$ztWiWJM&GuplISfS2s zRo%Wuq9YfMgw6Fm#%8(*^3LQMnr95EJ%y;f@PfTSFjDk3FbL~Qrj$BMpZ~Er_%+IE z^lA;oW!jDwu2Vh1=4hyd8PZU(%Z%w<{&f#tnyGZ`=5^vlE8<>VTGiUE#x4bluIW9D zmCyYIR0B))%8yU|W*OaIf78>HgUd}>L)~M_;~E8M*pk?0lai#_;~pCn`9|lNJ-QXV z6L7FQuujC9Ro30FErA0KmKXMJ>#kix=7*G4Iy9rdDQE(HyIKXOEte9-zsg$y%wkO$fZ0B5c&FNlx(3vQc1Ca{j0;^PE{*W;Sm4pOgR9~_15OFcSz|OOD;~1D zue-C-qj{;gyxSNJ`>j56B##)sq5poFR;6fJV_Y6+o94aLVhZdl4e`KE2k!l{@8N|k z-6<9(1h?pwHkxK5C@l(|ES~C($Q=5 zdRH|_y(aVqlmwkTt=l2N9{HVOD{mgJPni9fx1nmQ4I&qfwTB+#Sh8dUhTM#Qy`23N zFncrRNu+80=rI|0R89gO?V3_XpuHdtG<};ULE&+5+vOA3DJ|}<1p0;#H=8l;x zSvG@ID-#!Uza*YCvhhYfo#8fj@z%X*67sLeaPkFsLIFG47sBp%i60bH)HnTHcp&ZS z<3AZz(b3XeX=Ld_lyw@p-*Ni&D9FoiD4yeyQ=KTN$YcX+H>Q6kf@O)GyGpn1lY?C_ zUPZ<3NXtKy`scBM+Y5Pym+0j3b6k&6kk{h`gXAO|h7_55b1A^(Hb%jDp$?TzHf5w6 z_(Cvfxl%wQlY7{vL1T(E_S@-c1zXNPl>?QCBn8cX<|7z&aRZu)D@ktYW$WTCj%k%- z;r#z9{fZC%cg8XfU)AaIXKz4*-DuUx>eVdm!ea1cEe+5tAXILMp|MAxQ9W z{hO6Z0RZrS!QXOXSR{qG{+oOLFXD*niivJ<{j-__01{Wzga5Yh2ZGtTvheR;b9Dsc o|1|&@gkn6Ult~Q*FqKkDq?|V}n^F)E9e@six^n%c@ISEs1HQSBt^fc4 diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 8feffc5..12db5f4 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -19,7 +19,6 @@ const resultImportanceWrap = document.getElementById('resultImportanceWrap'); const resultImportanceImage = document.getElementById('resultImportanceImage'); const excelBtn = document.getElementById('excelBtn'); const resultPageBtn = document.getElementById('resultPageBtn'); -const homeStateKey = 'pipelineLifetime.homeState'; let selectedFile = null; let alertTimer = null; let alertHideTimer = null; @@ -66,24 +65,6 @@ function updateSelectedFile(file) { selectedFile = file; selectedFileName.textContent = '已选择文件:' + file.name; selectedFileName.classList.remove('hidden'); - saveHomeState({ selectedFilename: file.name }); -} - -function readHomeState() { - try { - return JSON.parse(sessionStorage.getItem(homeStateKey)) || {}; - } catch (err) { - return {}; - } -} - -function saveHomeState(nextState) { - try { - const currentState = readHomeState(); - sessionStorage.setItem(homeStateKey, JSON.stringify({ ...currentState, ...nextState })); - } catch (err) { - // Ignore storage failures; prediction still works without client-side restore. - } } function renderResult(data) { @@ -102,17 +83,6 @@ function renderResult(data) { resultContent.classList.add('flex'); } -function restoreHomeState() { - const savedState = readHomeState(); - if (savedState.selectedFilename) { - selectedFileName.textContent = '上次选择文件:' + savedState.selectedFilename + '(需重新选择)'; - selectedFileName.classList.remove('hidden'); - } - if (savedState.result) { - renderResult(savedState.result); - } -} - fileInput.addEventListener('change', () => { const file = fileInput.files[0]; if (!file) return; @@ -176,15 +146,6 @@ form.addEventListener('submit', async (e) => { return; } renderResult(data); - saveHomeState({ - selectedFilename: data.original_filename || selectedFile.name, - result: { - image_url: data.image_url, - importance_url: data.importance_url || '', - excel_url: data.excel_url, - result_url: data.result_url - } - }); showAlert('预测完成,已生成图表与电子表格报告。', 'success'); inlineResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (err) { @@ -197,5 +158,3 @@ form.addEventListener('submit', async (e) => { submitIcon = restoredIcon; } }); - -restoreHomeState(); diff --git a/templates/base.html b/templates/base.html index 3247b48..058804e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -142,15 +142,6 @@ showAppNotification(message, category === 'error' ? 'error' : 'info'); } - document.querySelectorAll('[data-clear-home-state]').forEach((form) => { - form.addEventListener('submit', () => { - try { - sessionStorage.removeItem('pipelineLifetime.homeState'); - } catch (err) { - // Ignore storage failures during logout. - } - }); - }); })(); {% block scripts %}{% endblock %} diff --git a/templates/result.html b/templates/result.html index c9a1f71..08ca52a 100644 --- a/templates/result.html +++ b/templates/result.html @@ -51,12 +51,13 @@
-

样本预览

+

结果展示

- + + @@ -64,6 +65,7 @@ {% for item in result.summary_rows %} + {% endfor %} diff --git a/tests/test_auth_registration.py b/tests/test_auth_registration.py index 9bf55f5..9d516d5 100644 --- a/tests/test_auth_registration.py +++ b/tests/test_auth_registration.py @@ -130,6 +130,19 @@ class RegistrationRoutesTest(unittest.TestCase): self.assertIn('name="captcha"', html) self.assertIn(captcha, html) + def test_template_download_uses_chinese_filename(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=True) + + response = app.test_client().get("/download_template") + + self.assertEqual(response.status_code, 200) + self.assertIn("attachment", response.headers["Content-Disposition"]) + self.assertIn( + "filename*=UTF-8''%E7%AE%A1%E9%81%93%E9%A2%84%E6%B5%8B%E6%95%B0%E6%8D%AE%E6%A8%A1%E6%9D%BF.xlsx", + response.headers["Content-Disposition"], + ) + def test_register_post_does_not_create_user_when_registration_is_closed(self) -> None: with TemporaryDirectory() as temp_dir: app = self.create_test_app(temp_dir, allow_registration=False) diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 8b51300..242cef5 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -3,16 +3,22 @@ from __future__ import annotations import unittest from pathlib import Path from tempfile import TemporaryDirectory +from unittest.mock import patch import pandas as pd +from openpyxl import load_workbook from app.prediction import ( FEATURES, ID_COLUMN, + PIPE_AGE_COLUMN, PredictionError, estimate_remaining_life, grade_info, interpolate_probability, + prepare_model_features, + read_input_file, + render_survival_chart, secure_upload_name, validate_input_frame, write_prediction_workbook, @@ -53,6 +59,37 @@ class PredictionHelpersTest(unittest.TestCase): self.assertIn("缺少必要字段", ctx.exception.message) + def test_read_input_file_preserves_text_pipe_ids(self) -> None: + with TemporaryDirectory() as temp_dir: + output_path = Path(temp_dir) / "input.xlsx" + pd.DataFrame({ID_COLUMN: ["00123"], FEATURES[0]: [1]}).to_excel(output_path, index=False) + + df = read_input_file(output_path, ".xlsx") + + self.assertEqual(df[ID_COLUMN].tolist(), ["00123"]) + + def test_prepare_model_features_maps_material_aliases_to_codes(self) -> None: + rows = [] + for value in ["镀锌", "2-钢塑", 13]: + row = {feature: 1 for feature in FEATURES} + row["管材"] = value + rows.append(row) + df = pd.DataFrame(rows) + + x_test = prepare_model_features(df) + + self.assertEqual(x_test["管材"].tolist(), [1, 2, 13]) + + def test_prepare_model_features_rejects_invalid_material_alias(self) -> None: + row = {feature: 1 for feature in FEATURES} + row["管材"] = "未知管材" + df = pd.DataFrame([row]) + + with self.assertRaises(PredictionError) as ctx: + prepare_model_features(df) + + self.assertIn("管材编码无效", ctx.exception.message) + def test_prediction_workbook_keeps_sample_data_in_one_sheet(self) -> None: curves = [ DummyCurve([1, 2], [0.9, 0.7]), @@ -60,8 +97,8 @@ class PredictionHelpersTest(unittest.TestCase): ] summary_rows = [{"pipe_id": "P001"}, {"pipe_id": "P002"}] summary_sheet_rows = [ - {"管道编号": "P001", "健康概率": 0.7}, - {"管道编号": "P002", "健康概率": 0.6}, + {"管道编号": "P001", "管龄": "10 年", "健康概率": 0.7, "预计剩余寿命": 2.0, "健康等级": "IV级"}, + {"管道编号": "P002", "管龄": "12 年", "健康概率": 0.6, "预计剩余寿命": 1.5, "健康等级": "III级"}, ] with TemporaryDirectory() as temp_dir: @@ -71,10 +108,51 @@ class PredictionHelpersTest(unittest.TestCase): 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, "健康等级"]) + sample_data = pd.read_excel(output_path, sheet_name="样本数据") - self.assertEqual(len(sample_data), 4) - self.assertEqual(sample_data["管道编号"].tolist(), ["P001", "P001", "P002", "P002"]) - self.assertIn("风险概率", sample_data.columns) + self.assertEqual(sample_data.columns.tolist(), ["管龄(年)", "P001", "P002"]) + self.assertEqual(len(sample_data), 2) + self.assertEqual(sample_data["管龄(年)"].tolist(), [1, 2]) + self.assertEqual(sample_data["P001"].tolist(), [0.9, 0.7]) + self.assertEqual(sample_data["P002"].tolist(), [0.8, 0.6]) + + openpyxl_workbook = load_workbook(output_path) + sample_worksheet = openpyxl_workbook["样本数据"] + self.assertEqual(sample_worksheet.freeze_panes, "A2") + self.assertEqual(sample_worksheet.auto_filter.ref, "A1:C3") + 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])] + summary_rows = [{"pipe_id": "00123"}] + summary_sheet_rows = [{ID_COLUMN: "00123", PIPE_AGE_COLUMN: "10 年", "健康等级": "V级"}] + + with TemporaryDirectory() as temp_dir: + output_path = Path(temp_dir) / "prediction.xlsx" + write_prediction_workbook(output_path, curves, summary_rows, summary_sheet_rows) + + workbook = load_workbook(output_path) + summary_worksheet = workbook["结果摘要"] + self.assertEqual(summary_worksheet["A2"].value, "00123") + self.assertEqual(summary_worksheet["A2"].data_type, "s") + self.assertEqual(summary_worksheet["A2"].number_format, "@") + + sample_worksheet = workbook["样本数据"] + self.assertEqual(sample_worksheet["B1"].value, "00123") + self.assertEqual(sample_worksheet["B1"].data_type, "s") + + def test_survival_chart_uses_pipe_age_x_axis_label(self) -> None: + df = pd.DataFrame({ID_COLUMN: ["P001"], PIPE_AGE_COLUMN: [12]}) + + with TemporaryDirectory() as temp_dir: + output_path = Path(temp_dir) / "chart.png" + with patch("app.prediction.plt.xlabel") as xlabel: + render_survival_chart(df, [DummyCurve([1, 2], [0.9, 0.7])], output_path) + + xlabel.assert_called_once() + self.assertEqual(xlabel.call_args.args[0], "管龄(年)") if __name__ == "__main__":
管道编号编号管龄 等级
{{ item.pipe_id }}{{ item.pipe_age }} {{ item.grade_label }}