fcdp 20261015__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
_shared.py ADDED
@@ -0,0 +1,3418 @@
1
+ """
2
+ _shared.py -- 共享数据容器, 常量, 配置和通用辅助函数。
3
+
4
+ 项目核心文件之一, 不依赖 Tkinter (界面文案常量只是字符串, 不 import tkinter)。
5
+ 除本职内容外另含三个分区: 「安装形态判别与配置路径解析」「版本检查」「自更新」
6
+ (拉起独立更新器进程, 见 AGENTS.md); 三者均为边界例外, 同类逻辑不要再新增到本模块。
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import site
12
+ import subprocess
13
+ import sysconfig
14
+ from pathlib import Path
15
+ import re
16
+ from importlib.metadata import distribution
17
+ _PROJECT_ROOT = Path(__file__).resolve().parent # 包目录 (代码所在目录), 不依赖 CWD
18
+
19
+ import pandas as pd
20
+ import numpy as np
21
+ from datetime import datetime
22
+ from dataclasses import dataclass, field, fields, MISSING
23
+ from typing import Any, Callable, Literal
24
+ import json
25
+ import traceback
26
+ from scipy.stats import linregress
27
+
28
+
29
+ # =========================
30
+ # 公开符号
31
+ # =========================
32
+
33
+ __all__ = [
34
+ # 模块常量
35
+ '_PROJECT_ROOT',
36
+ # 常量类
37
+ 'UiText', 'Columns', 'PhysicalConstants', 'PHYS',
38
+ 'HUPD_DIRECTION_TUPLE',
39
+ # Config
40
+ 'Config', 'build_config', 'validate_config',
41
+ # 安装形态判别与配置路径解析
42
+ 'DIST_NAME', 'detect_install_mode', 'user_config_dir', 'resolve_config_path',
43
+ 'write_config_file', 'resource_path', 'dist_info_file',
44
+ # 版本检查 (原 version_check.py)
45
+ 'PYPI_JSON_URL', 'GITLAB_RAW_URL', 'RELEASES_PAGE_URL',
46
+ 'STATUS_NEWER', 'STATUS_UP_TO_DATE', 'STATUS_FAILED',
47
+ 'VersionCheckResult', 'compare_versions', 'check_for_update', 'changelog_path',
48
+ 'CHANGELOG_FILE_NAME', 'BUNDLED_DOC_DIR',
49
+ # 自更新 (检查更新 -> 用户确认 -> 更新器执行 pip -> 下次启动读结果)
50
+ 'UPDATE_RESULT_FILE_NAME', 'PIP_INSTALL_FLAGS',
51
+ 'is_updatable_install', 'build_install_command',
52
+ 'update_result_path', 'update_result_paths',
53
+ 'write_update_result', 'read_update_result',
54
+ 'build_updater_payload', 'start_self_update',
55
+ # 运行期服务
56
+ 'RuntimeServices',
57
+ 'LogMessage',
58
+ # 数据容器
59
+ 'IvMeanResult', 'IvContext', 'IvRunParams',
60
+ 'create_iv_run_params',
61
+ 'H2CrossoverData',
62
+ 'InteractivePeakSelector', 'CoEcsaResult', 'HupdEcsaResult', 'CoEcsaSession', 'HupdEcsaSession',
63
+ 'LinearFitResult',
64
+ 'OxygenResult', 'OxygenCurveRecord', 'OxygenFitRecord', 'OxygenResistanceResult',
65
+ 'FigureBundle', 'ExportFigure', 'TafelFit',
66
+ # Pipeline 输入/输出
67
+ 'IvPipelineInput', 'IvPipelineOutput',
68
+ 'EcsaPipelineInput', 'CoEcsaPipelineOutput', 'HupdEcsaPipelineOutput',
69
+ 'OxygenPipelineInput', 'OxygenPipelineOutput',
70
+ 'EisPipelineInput', 'EisPipelineOutput',
71
+ # EIS 结果
72
+ 'EisFitSegment', 'EisFileResult',
73
+ # 文件读取结果
74
+ 'FcdFileResult', 'EcsaDataResult',
75
+ # 通用预读结果
76
+ 'FilePreRead',
77
+ # 工具函数
78
+ 'concat_plot_data_indices',
79
+ 'safe_linear_fit', 'calculate_hupd_ecsa_averages',
80
+ 'calculate_oxygen_resistance_result', 'calculate_oxygen_corrected_resistance',
81
+ # DTA 检测
82
+ 'detect_dta_subtype', 'DTA_SUBTYPE_O2', 'DTA_SUBTYPE_EIS',
83
+ # O2 文件模型
84
+ 'OxygenFileData', 'parse_oxygen_file_conditions',
85
+ # 下划线辅助 (被其他模块显式导入)
86
+ '_extract_config_fields', '_auto_output_path', '_get_modified_config_fields',
87
+ # 统一命名辅助 (被 gui/cli 显式导入)
88
+ 'extract_folder_name', 'build_output_filename', 'panel_key_to_mode',
89
+ ]
90
+
91
+
92
+ # =========================
93
+ # 界面文本常量
94
+ # =========================
95
+
96
+ class UiText:
97
+ '''
98
+ 用途: 集中保存界面标签文本常量。
99
+
100
+ 调用: 由 GUI (gui.py) 的各面板与 ConfigEditorDialog 读取。
101
+ '''
102
+ RAW_FILE = '原始文件: '
103
+ EXPORT_FILE = '导出文件: '
104
+ NO_FILE_SELECTED = '未选择文件'
105
+ DATA_PATH = '数据路径'
106
+ SAVE_PATH = '保存路径'
107
+ RESTART = '重启'
108
+ START_PROCESS = '开始处理'
109
+ OPEN_RESULT = '打开结果'
110
+
111
+ H2_SLOPE = 'H2斜率:'
112
+ H2_INTERCEPT = 'H2截距:'
113
+ USE_LEFT_VALUE = '使用左值'
114
+
115
+ EXPORT = '导出'
116
+ ECSA_CO_BASELINE = '扣基线ECSA (m2/g_Pt): '
117
+ ECSA_CO_LINEAR = '扣直线ECSA (m2/g_Pt): '
118
+ ECSA_LEFT_POINT = '左端点'
119
+ ECSA_RIGHT_POINT = '右端点'
120
+ ECSA_BASELINE_ABOVE = '基线以上'
121
+ ECSA_BASELINE_BELOW = '基线以下'
122
+
123
+ EIS_HF_FIT = 'HF拟合 (mΩ):'
124
+ EIS_HF_INTERP = 'HF插值 (mΩ):'
125
+ EIS_HF_START = 'HF左端点'
126
+ EIS_HF_END = 'HF右端点'
127
+ EIS_LF_START = 'LF左端点'
128
+ EIS_LF_END = 'LF右端点'
129
+ CONFIG = '配置'
130
+
131
+ # 主窗口与日志面板
132
+ WINDOW_TITLE = '燃料电池数据处理平台'
133
+ LOG_COL_LABEL = '项目'
134
+ LOG_COL_TEXT = '结果'
135
+ LOG_INITIAL = '应用程序日志'
136
+ LOG_CLEARED = '--- 日志已清空 ---'
137
+
138
+ # 配置编辑窗口
139
+ CONFIG_EDITOR_TITLE = 'FCDP 配置编辑'
140
+ CONFIG_FILE_PREFIX = '配置文件: '
141
+ CONFIG_APPLY_ONLY = '直接应用'
142
+ CONFIG_SAVE_APPLY = '保存并应用'
143
+ CONFIG_RESET_ALL = '全部恢复默认'
144
+ NO_EXPORT_DIR = '(未设置导出目录)'
145
+
146
+ # 主窗菜单栏
147
+ MENU_FILE = '文件'
148
+ MENU_TOOLS = '工具'
149
+ MENU_HELP = '帮助'
150
+ MENU_DATA_PREVIEW = '数据预览'
151
+ MENU_ABOUT = '关于程序'
152
+ MENU_EXIT = '退出'
153
+ MENU_VERSION = '版本信息'
154
+
155
+ # 检查更新 (菜单项文字随检查状态变化; 状态由 gui.py 会话内变量维护)
156
+ CHECK_UPDATE = '检查更新'
157
+ CHECK_MENU_NEWER = '检查更新 (已有新版本 {latest})'
158
+ CHECK_MENU_UP_TO_DATE = '检查更新 (已是最新 {current})'
159
+ CHECK_MENU_FAILED = '检查更新 (检测失败)'
160
+ CHECK_MENU_CHECKING = '检查更新 (正在检查...)'
161
+ CHECK_TITLE = '检查更新'
162
+ CHECK_DIALOG_NEWER_TITLE = '发现新版本'
163
+ CHECK_DIALOG_NEWER_BODY = ('发现新版本 {latest}\n当前版本: {current}\n\n'
164
+ '建议更新到新版本; 也可先看更新说明了解变更。')
165
+ CHECK_DIALOG_UP_TO_DATE_TITLE = '已是最新版本'
166
+ CHECK_DIALOG_UP_TO_DATE_BODY = '当前版本 {current} 已是最新。'
167
+ CHECK_DIALOG_FAILED_TITLE = '未能检查更新'
168
+ CHECK_DIALOG_FAILED_BODY = ('本次未能连接更新服务器, 不影响程序使用。\n\n'
169
+ '可稍后重试; 也可先看更新说明了解已知变更。')
170
+ CHECK_BTN_OPEN_PAGE = '打开下载页'
171
+ CHECK_BTN_VIEW_CHANGELOG = '查看更新说明'
172
+ CHECK_BTN_UPDATE_NOW = '立即更新'
173
+ CHECK_BTN_LATER = '稍后'
174
+ CHECK_BTN_OK = '确定'
175
+ # 程序内自更新 (仅 pip 安装形态可点; 判据见 _shared.is_updatable_install)
176
+ # 不变式: 确认框是**退出前最后一个界面动作** (确认 -> 拉起更新器 -> 立即退出),
177
+ # 确认之后不得再弹任何模态框; 更新器只等 60 s, 界面多留一刻,
178
+ # "磁盘已换, 内存还是旧版"的不一致窗口就长一刻。
179
+ CHECK_UPDATE_CONFIRM = ('即将把程序更新到 {latest}\n'
180
+ '当前版本: {current}\n'
181
+ '更新位置: {target}\n\n'
182
+ '更新由独立进程在程序退出后执行, 需要联网, 更新期间请勿断网;\n'
183
+ '完成后请手动重新打开程序, 届时会提示更新结果。\n'
184
+ '该位置若没有写权限 (如全局 site-packages 属管理员所有), 更新会失败。\n'
185
+ '是否继续?')
186
+ CHECK_UPDATE_START_FAILED = ('未能启动更新进程。\n\n'
187
+ '可稍后重试; 也可在命令行执行 pip install --upgrade fcdp 手动更新。')
188
+ CHECK_UPDATE_FAILED_HINT = ('当前版本仍可正常使用, 可依次尝试:\n'
189
+ '1) 以管理员身份重新打开程序后再执行一次更新 (该位置需要写权限);\n'
190
+ '2) 在命令行执行 pip install --upgrade --no-deps '
191
+ '--disable-pip-version-check --no-input --target "{target}" fcdp\n'
192
+ '3) 或执行 pip install --user fcdp 装到用户目录 '
193
+ '(它优先于系统目录被加载, 之后仍可在程序内一键更新)。')
194
+ CHECK_UPDATE_NOT_AVAILABLE = ('本机这份程序不是 pip 安装形态 (源码副本或开发仓库), '
195
+ '无法在程序内自动更新。\n\n'
196
+ '本程序唯一的分发与升级方式是 pip, 请先执行:\n'
197
+ ' pip install fcdp\n'
198
+ '装好后从 pip 的那份启动, 即可在程序内一键更新;\n'
199
+ '也可以用 pip install -U fcdp 手动升级。\n\n'
200
+ '源码副本可用, 但不提供维护: 需要新版请重新 clone / 拉取源码。')
201
+ CHECK_CHANGELOG_MISSING = ('本机没有找到 CHANGELOG 文件\n'
202
+ '(它随 wheel 分发, 正常安装应当存在;\n'
203
+ '只有在安装目录被手工清理过时才会缺失)。\n\n'
204
+ '是否打开下载页查看各版本说明?')
205
+ CHECK_OPEN_PAGE_FAILED = '无法打开浏览器, 请手动访问:\n{url}'
206
+
207
+ # 关于程序对话框 (内容尽量少: 能打开此窗口即说明程序已正常运行)
208
+ ABOUT_FEATURES = '功能: 燃料电池测试数据处理 (IV / H2 / MA / CO-ECSA / HUPD-ECSA / O2 / EIS)'
209
+ ABOUT_LICENSE = '许可: MIT'
210
+ ABOUT_HOMEPAGE = '程序主页: {url}'
211
+ ABOUT_BTN_README = '程序介绍'
212
+ ABOUT_BTN_LICENSE = '许可协议'
213
+ ABOUT_BTN_OK = '确定'
214
+ ABOUT_DOC_MISSING = '(本安装未提供该文件, 对应按钮已灰显)'
215
+ ABOUT_DOC_OPEN_FAILED = '无法打开 {path}\n{error}'
216
+ # 程序主页 (源码托管); 发布到其他平台时改这一处即可
217
+ HOMEPAGE_URL = 'https://git.ustc.edu.cn/maoyan/fcdp'
218
+
219
+ # 数据预览窗口
220
+ PREVIEW_TITLE = 'FCD 数据预览'
221
+ PREVIEW_HINT_INITIAL = '左键勾选列加入 Y1; 右键列名可设置 X / Y1 / Y2 / 不绘制'
222
+ PREVIEW_HINT_NEED_Y = '当前没有有效的 Y 列, 请至少勾选一个 Y 列'
223
+ PREVIEW_DTA_UNSUPPORTED = 'DTA 文件暂不支持预览, 请选择 .fcd 文件'
224
+ PREVIEW_READ_FAILED = '读取文件失败, 无法预览'
225
+ PREVIEW_CHOOSE_TITLE = '选择要预览的 FCD 文件'
226
+ PREVIEW_OPEN_OTHER = '打开其他文件...'
227
+ PREVIEW_PROCESS_FILE = '处理文件: '
228
+ PREVIEW_PROCESS_FILE_NONE = '(尚未选择, 本窗仅预览)'
229
+ PREVIEW_TIME_SYNC = '对应 Time (Sec):'
230
+ PREVIEW_RANGE_TITLE = '数据点索引'
231
+ PREVIEW_RANGE_TO = '至'
232
+ PREVIEW_REDRAW = '重绘'
233
+ PREVIEW_SET_X = '设为 X'
234
+ PREVIEW_SET_Y1 = '设为 Y1'
235
+ PREVIEW_SET_Y2 = '设为 Y2'
236
+ PREVIEW_SET_NONE = '不绘制'
237
+ PREVIEW_NOT_Y = '不可作 Y'
238
+ PREVIEW_AXIS_X_CLEARED = 'X 列变更, 同名 Y 列已从绘制中移除'
239
+ PREVIEW_RANGE_INVALID = '数据点索引无效, 已回填合法范围'
240
+ PREVIEW_RANGE_ROUNDED = '数据点索引已按四舍五入取整'
241
+ PREVIEW_DUPLICATE_COLUMNS = '文件存在重名列, pandas 已自动改名 (如 A.1), 请以列表框显示为准'
242
+ PREVIEW_CANCELLED = '已取消选择预览文件'
243
+ PREVIEW_IS_X_COLUMN = '该列当前是 X 轴, 如需改轴次请右键'
244
+ PREVIEW_CANNOT_BE_Y = '{name} 不能作为 Y 列'
245
+ PREVIEW_CJK_HINT = '提示: 列名 {names} 含中文字形, 图中无法显示'
246
+ PREVIEW_UNAVAILABLE = '预览不可用'
247
+
248
+ # 弹窗标题
249
+ ASK_TITLE = '询问'
250
+ CFG_ERROR = '配置错误'
251
+ CFG_WARNING = '配置警告'
252
+ SUCCESS_SUFFIX = '成功'
253
+ CONFIRM_RESET = '确认重置'
254
+ APPLY_ERROR = '应用错误'
255
+ SAVE_FAIL = '保存失败'
256
+
257
+
258
+ # =========================
259
+ # 列名常量
260
+ # =========================
261
+
262
+ class Columns:
263
+ '''
264
+ 用途: 集中保存原始列, 派生列和导出列名称。
265
+
266
+ 调用: 由 computations / plotting / excel_export 的解析, 计算, 绘图与导出流程读取。
267
+ '''
268
+ T = 'Time (Sec)'
269
+
270
+ IA = 'I (A)'
271
+ '''电流 (A)'''
272
+
273
+ I = 'I (mA/cm²)'
274
+ '''电流密度 (mA/cm²)'''
275
+
276
+ E = 'E_Stack (V)'
277
+
278
+ HFR = 'HFR (mOhm)'
279
+
280
+ Status = 'Ctrl_Status'
281
+ '''3: 程序运行中'''
282
+
283
+ Mode = 'Ctrl_Mode'
284
+ '''1: 控制电压; 0: 控制电流'''
285
+
286
+ Value = 'Ctrl_Value'
287
+ '''控制电压/电流的值'''
288
+
289
+ MultigasCa = 'Multigas_Cathode'
290
+
291
+ TempCa = 'Ctrl_Temp_Cathode (C)'
292
+ '''阴极加湿罐设定温度'''
293
+
294
+ TempCell = 'Cell Temp (C)'
295
+
296
+ Real_TempCell = 'Temp (C)'
297
+
298
+ Real_TempCa = 'Temp_Cathode (C)'
299
+
300
+ FlowAn = 'Ctrl_Flow_Anode (l/min)'
301
+
302
+ FlowCa = 'Ctrl_Flow_Cathode (l/min)'
303
+
304
+ Freq = 'Z_Freq (Hz)'
305
+
306
+ Z_real = 'Z_Real (Ohm)'
307
+
308
+ Z_imag = 'Z_Imag (Ohm)'
309
+
310
+ Z_Mag = 'Z_Mag (Ohm)'
311
+
312
+ _RH = 'RH (%)'
313
+
314
+ _ECor = 'E HFR correction (V)'
315
+
316
+ @classmethod
317
+ def raw_columns(cls) -> list[str]:
318
+ '''
319
+ 用途: 返回所有原始来源列名。
320
+
321
+ 调用: 由 FCD/DTA 解析流程与测试调用。
322
+ 返回: 返回列名列表。
323
+ '''
324
+ return [value for key, value in vars(cls).items() if not key.startswith('_') and isinstance(value, str)]
325
+
326
+ @classmethod
327
+ def final_columns(cls) -> list[str]:
328
+ '''
329
+ 用途: 返回最终 IV 导出列名。
330
+
331
+ 调用: 由 IV 均值表构建流程调用。
332
+ 返回: 返回列名列表。
333
+ '''
334
+ return [cls.T, cls.I, cls.E, cls.HFR, cls.Value, cls._RH, cls._ECor]
335
+
336
+ @classmethod
337
+ def detail_columns(cls) -> list[str]:
338
+ '''
339
+ 用途: 返回 IV 详情表列名。
340
+
341
+ 调用: 由 IV 均值表构建流程调用。
342
+ 返回: 返回列名列表。
343
+ '''
344
+ return [cls.Status, cls.Mode, cls.Value, cls.MultigasCa, cls.TempCell, cls.Real_TempCell, cls.TempCa, cls.Real_TempCa]
345
+
346
+
347
+ # =========================
348
+ # 物理常量
349
+ # =========================
350
+
351
+ @dataclass(frozen=True)
352
+ class PhysicalConstants:
353
+ '''
354
+ 用途: 集中保存物理常量, 避免魔法数字散落在计算代码中。
355
+
356
+ 调用: 由 computations.py 的计算函数和 excel_export.py 使用。
357
+ '''
358
+ # ---- 电化学 ----
359
+ FARADAY_CONSTANT: float = 96485.0 # C/mol, 法拉第常数
360
+ GAS_CONSTANT: float = 8.314 # J/(mol·K), 通用气体常数
361
+
362
+ # ---- 温度 ----
363
+ CELL_TEMPERATURE_K: float = 353.15 # 80°C in K
364
+ CELSIUS_TO_KELVIN_OFFSET: float = 273.15 # 准确的 0°C→K 偏移量 (≈273, 精确 273.15)
365
+
366
+ # ---- ECSA ----
367
+ # 标准电荷密度值 (C/cm²):
368
+ CO_STRIPPING_CHARGE_DENSITY: float = 0.00042 # C/cm², CO 脱附电荷密度 (420 μC/cm²)
369
+ HUPD_CHARGE_DENSITY: float = 0.00021 # C/cm², HUPD 脱附电荷密度 (210 μC/cm²)
370
+ HUPD_VOLTAGE_WINDOW_MAX_V: float = 0.4 # V, HUPD 基线点电位窗口上限
371
+ # 计算时 pt_usage 单位为 mg/cm², 输出单位为 m²/g_Pt。
372
+ # cm²→m² 除以 10000,mg→g 乘以 1000, 合并转换因子 = 10。
373
+ CM2_MG_TO_M2_G: float = 10.0 # cm²/mg → m²/g_Pt 单位转换因子 (10000/1000)
374
+
375
+ # ---- CO 自动选点阈值 ----
376
+ CO_LEFT_MIN_VOLTAGE: float = 0.4 # V, 左侧端点/峰搜索最小电位 (排除 H 区)
377
+ CO_RIGHT_WARN_VOLTAGE: float = 0.8 # V, 右端点超过此电位时发出警告
378
+
379
+ # ---- 湿度计算 ----
380
+ RH_EMPIRICAL_CONSTANT: float = 5148 # 经验常数, 用于相对湿度计算
381
+
382
+ # ---- O2 传输阻力 ----
383
+ WATER_VAPOR_PRESSURE_KPA: float = 33.3850272990271 # kPa, 精确值, 80°C时水蒸气饱和压力
384
+ AMBIENT_PRESSURE_KPA: float = 100.0 # kPa, 环境压力 (背景)
385
+ O2_D_ON_NAFION: float = 0.186874772957424 # cm²/s, O2 在 Nafion 中扩散系数
386
+ O2_D_OW_WATER: float = 0.238499175944215 # cm²/s, O2 在水中扩散系数
387
+ O2_CORRECTION_FACTOR_1: float = 1.01 # 修正因子 1
388
+ O2_CORRECTION_FACTOR_2: float = 0.08 # 修正因子 2 (边界层厚度相关)
389
+
390
+ # ---- 单位转换 ----
391
+ MA_TO_A: float = 1e-3 # mA -> A
392
+ MOHM_TO_OHM: float = 1e-3 # mΩ -> Ω
393
+ LPM_TO_MLS: float = 1000.0 / 60.0 # L/min -> mL/s
394
+ BAR_TO_KPA: float = 10.0 # bar -> kPa
395
+
396
+ # ---- O2 阻力计算 ----
397
+ WATER_VAPOR_PRESSURE_BAR: float = 0.3338 # bar, 80°C 水蒸气分压
398
+ R_TOTAL_UNIT_DIVISOR: float = 100.0 # R_total 公式单位归一化因子
399
+
400
+ # ---- H2/IV 经验阈值 ----
401
+ IV_CURRENT_TOLERANCE_MA_CM2: float = 10.0 # mA/cm², IV 有效电流密度偏差容限
402
+ IV_HFR_UPPER_LIMIT_MOHM_CM2: float = 250.0 # mΩ·cm², IV 有效 HFR 上限 (面积比电阻)
403
+ MA_HFR_MIN_MOHM_CM2: float = 25.0 # mΩ·cm², MA 有效 HFR 下限 (面积比电阻)
404
+ MA_HFR_MAX_MOHM_CM2: float = 100.0 # mΩ·cm², MA 有效 HFR 上限 (面积比电阻)
405
+
406
+ # ---- 活化/Conditioning 检测 ----
407
+ CONDITIONING_MIN_DURATION_S: float = 1800.0 # s, 活化段最小持续时间 (30min)
408
+ CONDITIONING_GAP_WARN_S: float = 3600.0 # s, 活化段间非零电流累计时长阈值 (1h)
409
+
410
+ # ---- 近零值全局阈值 ----
411
+ SMALL_VALUE: float = 1e-12 # 通用极小值阈值, 用于避免除零, 浮点零比较等
412
+
413
+
414
+ PHYS = PhysicalConstants()
415
+ '''便捷访问实例'''
416
+
417
+
418
+ HUPD_DIRECTION_TUPLE: tuple[str, str] = ('baseline above', 'baseline below')
419
+ '''HUPD 基线方向选项元组。索引 0 为"基线以上", 索引 1 为"基线以下"。
420
+
421
+ 注意: 字符串值直接绑定 GUI 单选按钮 value, 且用于 calculate_hupd_ecsa_result
422
+ 的 direction 参数匹配, 因此保持英文。'''
423
+
424
+ # ---- DTA 子类型常量 ----
425
+ DTA_SUBTYPE_O2: Literal['o2'] = 'o2'
426
+ '''DTA 文件子类型: O2 氧传输阻力 (LSV 曲线)'''
427
+ DTA_SUBTYPE_EIS: Literal['eis'] = 'eis'
428
+ '''DTA 文件子类型: EIS 阻抗谱 (用于 H+ 质子传导阻力计算)'''
429
+
430
+
431
+ # =========================
432
+ # 安装形态判别与配置路径解析
433
+ # =========================
434
+
435
+ # 发行版名, 与 pyproject.toml 的 [project].name 一致。用于 importlib.metadata 查询安装元数据。
436
+ DIST_NAME: str = 'fcdp'
437
+
438
+
439
+ def _site_dirs() -> list[Path]:
440
+ '''
441
+ 用途: 收集当前解释器的全部 site-packages 目录 (必须包含 user site)。
442
+
443
+ 调用: 由 `detect_install_mode` 调用。
444
+
445
+ 返回: 去重后的绝对路径列表; 解释器未提供该项时不入列表。
446
+
447
+ 注意: 只保留目录名形如 site-packages / dist-packages 的路径。理由: Windows 上
448
+ site.getsitepackages() 还会返回解释器根目录 (如 C:\\Program Files\\Python312),
449
+ 若把它当作 site 目录, 则位于该根目录之下的普通源码目录会被误判为 pip 安装。
450
+ 另外本函数对任何单个取目录失败都静默跳过 (判别不应因环境差异抛异常)。
451
+ '''
452
+ raw: list[str] = []
453
+ try:
454
+ paths = sysconfig.get_paths()
455
+ for key in ('purelib', 'platlib'):
456
+ value = paths.get(key)
457
+ if isinstance(value, str) and value:
458
+ raw.append(value)
459
+ except Exception:
460
+ pass
461
+ try:
462
+ user_site = site.getusersitepackages()
463
+ if isinstance(user_site, str) and user_site:
464
+ raw.append(user_site)
465
+ except Exception:
466
+ pass
467
+ try:
468
+ for item in site.getsitepackages():
469
+ if isinstance(item, str) and item:
470
+ raw.append(item)
471
+ except Exception:
472
+ pass
473
+
474
+ dirs: list[Path] = []
475
+ for item in raw:
476
+ resolved = Path(item).resolve()
477
+ name = resolved.name.lower()
478
+ if not (name.endswith('site-packages') or name.endswith('dist-packages')):
479
+ continue # 排除解释器根目录等非 site 目录
480
+ if resolved not in dirs:
481
+ dirs.append(resolved)
482
+ return dirs
483
+
484
+
485
+ def _dist_located_at(dist, module_dir: Path) -> bool:
486
+ '''
487
+ 用途: 判断发行版元数据声明的安装位置是否就是本模块所在目录。
488
+
489
+ 参数:
490
+ dist: `importlib.metadata.Distribution` 实例。
491
+ module_dir: 当前运行的核心模块所在目录。
492
+
493
+ 调用: 由 `detect_install_mode` 调用。
494
+
495
+ 返回: 安装位置与模块目录一致时返回 True; 无法判定或元数据不可读时返回 False。
496
+
497
+ 注意: 复制源码目录里若夹带旧 dist-info, 元数据可能指向 site-packages 而代码在别处,
498
+ 此时必须返回 False, 否则会把复制版误判为 pip 安装。
499
+ '''
500
+ try:
501
+ located = Path(dist.locate_file('')).resolve()
502
+ except Exception:
503
+ return False
504
+ return located == module_dir
505
+
506
+
507
+ def detect_install_mode(module_file: Path | str) -> str:
508
+ '''
509
+ 用途: 判别程序来源, 决定配置归属。只看本机安装事实, 不 import 目标模块, 不读配置文件。
510
+
511
+ 参数:
512
+ module_file: 任一核心模块的 __file__ (调用方传 `_shared.__file__`)。
513
+
514
+ 调用: 由 `resolve_config_path` 与 `is_updatable_install` 调用; 诊断日志亦可直接调用。
515
+
516
+ 返回: 'local' (本目录副本 / 开发仓库) | 'pip-install' (装入 site-packages)
517
+ | 'editable' (开发可编辑安装)。
518
+
519
+ 注意: 判别流程 (两道闸门, 任一不成立即为 local):
520
+ ① 代码文件必须位于某个 site-packages 之下, 位于 site 即"装进去的",
521
+ 即使元数据缺失 (安装残缺 / 手工拷贝) 也按 pip 安装对待, 否则配置会写进安装目录,
522
+ 升级按 RECORD 删除即丢用户设置;
523
+ ② 元数据声明的安装位置必须与模块目录一致, 复制源码目录里夹带的残留 dist-info
524
+ 不会让它被误判为 pip 安装; 含 direct_url.json 的 dir_info 记为 editable。
525
+ 本函数**保证不抛异常** (判别属旁路逻辑, 任何环境差异都不得阻断启动);
526
+ 冻结应用 (PyInstaller 等) 无可靠 __file__, 需另走 sys.frozen 分支; 当前不打包 exe, 故不实现。
527
+ '''
528
+ try:
529
+ here = Path(module_file).resolve()
530
+ module_dir = here.parent
531
+ site_dirs = _site_dirs()
532
+ except Exception:
533
+ return 'local'
534
+
535
+ if not any(here.is_relative_to(d) for d in site_dirs):
536
+ return 'local' # 不在任何 site-packages 之下 -> 本目录副本
537
+
538
+ try:
539
+ dist = distribution(DIST_NAME)
540
+ except Exception:
541
+ return 'pip-install' # 在 site 但元数据缺失 -> 仍按 pip 安装对待
542
+ if not _dist_located_at(dist, module_dir):
543
+ return 'local' # 元数据指向别处 (残留 dist-info) -> 本目录副本
544
+ try:
545
+ direct = dist.read_text('direct_url.json') # PEP 610; 标准索引安装无此文件
546
+ except Exception:
547
+ direct = None
548
+ return 'editable' if direct and 'dir_info' in direct else 'pip-install'
549
+
550
+
551
+ def user_config_dir() -> Path:
552
+ '''
553
+ 用途: 返回用户数据目录中的配置目录 (pip 安装形态的配置归属, 也是更新结果文件的首选目录)。
554
+
555
+ 调用: 由 `resolve_config_path` (pip 安装形态) 与 `_local_appdata_root` 调用。
556
+
557
+ 返回: Windows 为 %LOCALAPPDATA%\\FCDP; 其他平台为 $XDG_CONFIG_HOME/fcdp (缺省 ~/.config/fcdp)。
558
+
559
+ 注意: 取 LOCALAPPDATA 而非 APPDATA, 因为配置中的 data_path_default/result_path/device_map
560
+ 与本机实验环境绑定, 属机器状态, 不应随域漫游同步。仅 Windows 为官方支持平台,
561
+ 其他平台分支保留但不承担验证义务。环境变量异常或家目录取不到时回退为包目录。
562
+ '''
563
+ try:
564
+ local = os.environ.get('LOCALAPPDATA')
565
+ if local:
566
+ return Path(local) / 'FCDP'
567
+ xdg = os.environ.get('XDG_CONFIG_HOME')
568
+ return (Path(xdg) if xdg else Path.home() / '.config') / 'fcdp'
569
+ except Exception:
570
+ return _PROJECT_ROOT
571
+
572
+
573
+ def resolve_config_path(explicit: Path | str | None = None) -> tuple[Path, bool]:
574
+ '''
575
+ 用途: 解析本次运行使用的配置文件路径, 并给出该文件是否已存在。
576
+
577
+ 参数:
578
+ explicit: 显式指定的路径 (CLI --config / run_pipeline config_path); None 表示未指定。
579
+
580
+ 调用: 由 `build_config` (json_path 为 None 时) 与 GUI 配置编辑器调用。
581
+
582
+ 返回: `(配置文件路径, 是否已存在)`。后两档路径可能尚不存在, 由调用方决定是否首建。
583
+
584
+ 注意: 本函数只做判定, 不创建文件, 不写盘, 不写日志 (启动时只读不写)。
585
+ 归属规则: 显式指定 > `FCDP_CONFIG` > (pip 安装 ? 用户数据目录 : **本目录**)。
586
+ 即: 只有 pip 装进 site-packages 的那份读机器级配置; 其余一切形态 (源码副本 /
587
+ 开发仓库 / 手工拷贝的副本) 都读写**本目录**的 config.json: 本目录没放时只用内置
588
+ 默认值 (不回退用户目录, 避免同机多份副本互相串配置), 并在**首次保存时生成在本目录**,
589
+ 使整目录可携带。
590
+ 启动器不设 `FCDP_CONFIG`, 也不介入本判定; 因此"双击 launch.py"与"直接双击 gui.py"
591
+ 读到的是同一份配置。
592
+ 本函数保证不抛异常: 任何判定环节出错都回退为"本目录副本"语义。
593
+ '''
594
+ try:
595
+ if explicit is not None:
596
+ path = Path(explicit)
597
+ return (path.resolve(), path.is_file())
598
+ env_path = os.environ.get('FCDP_CONFIG')
599
+ if env_path:
600
+ path = Path(env_path)
601
+ return (path.resolve(), path.is_file())
602
+ local_path = _PROJECT_ROOT / 'config.json'
603
+ if detect_install_mode(__file__) == 'pip-install':
604
+ user_path = user_config_dir() / 'config.json'
605
+ return (user_path, user_path.is_file())
606
+ return (local_path, local_path.is_file())
607
+ except Exception:
608
+ fallback = _PROJECT_ROOT / 'config.json'
609
+ return (fallback, fallback.is_file())
610
+
611
+
612
+ def _resolve_default_config_path() -> Path:
613
+ '''
614
+ 用途: 求值本进程的默认配置文件路径, 供 `Config._json_path` 默认值使用。
615
+
616
+ 调用: 由 `Config` 字段 `default_factory` 调用。
617
+
618
+ 返回: `resolve_config_path()` 的路径部分 (不关心文件是否存在)。
619
+
620
+ 注意: **每次构造 `Config` 都会重新求值一次** (default_factory 的语义如此, 本函数不加缓存):
621
+ 这是刻意保留的行为, 调用方在运行期改了 `FCDP_CONFIG` / `LOCALAPPDATA` 后再新建
622
+ `Config`, 理应拿到新的默认路径。想固定路径请显式传 `json_path` (CLI `--config`)。
623
+ 对程序正常运行而言这个区别不可见: 求值出来的是同一份路径, 且没有任何调用方依赖"只求值一次"。
624
+ 真实构造次数不止启动那一次: 记录配置变更的 `_get_modified_config_fields` 每次都会新建一个
625
+ 默认 `Config` (即每次跑管线都会重新求值一次), `build_config` 也无条件触发一次 default_factory。
626
+ 本函数在模块导入期由 `Config._json_path` 的 default_factory 首次调用, **绝不允许抛异常**
627
+ (否则整个模块 import 失败, 程序无法启动), 出错时回退为包目录下的 config.json。
628
+ '''
629
+ try:
630
+ return resolve_config_path()[0]
631
+ except Exception:
632
+ return _PROJECT_ROOT / 'config.json'
633
+
634
+
635
+ # 随 wheel 分发的两份用户文档 (README.md / CHANGELOG.md) 的落点目录名。
636
+ # pyproject.toml 的 [tool.setuptools.data-files] 以它为键, 装出来是 <Python 前缀>/fcdp/
637
+ # (wheel 规范里 .data/data/ 只能装到"前缀", 装不进 site-packages 根)。
638
+ BUNDLED_DOC_DIR = 'fcdp'
639
+
640
+
641
+ def resource_path(file_name: str) -> Path | None:
642
+ '''
643
+ 用途: 查找随程序提供的文件 (README / LICENSE / CHANGELOG 等)。
644
+
645
+ 参数:
646
+ file_name: 文件名 (如 'README.md')。
647
+
648
+ 调用: 由 GUI「关于程序」的"程序介绍 / 许可协议"按钮与「检查更新」的"查看更新说明"调用。
649
+
650
+ 返回: 命中时返回绝对路径; 所有候选目录都没有时返回 None。
651
+
652
+ 注意: 候选目录按"越贴近模块目录越优先"排列, 只查固定几层, 不递归 (递归会命中无关目录里的同名文件):
653
+ ① 模块目录 (源码副本 / 开发仓库 / editable 安装);
654
+ ② 模块目录的上一级 (兜底"模块放在子目录, 文档留在其上级"的布局);
655
+ ③ 模块目录/fcdp 与 <Python 前缀>/fcdp 与 <用户基础目录>/fcdp, 即 pip 安装形态的落点
656
+ (普通 venv / 系统安装落在前缀下, 自更新用的 `pip install --target` 落在代码目录下,
657
+ `pip install --user` 落在用户基础目录下), 见 [tool.setuptools.data-files]。
658
+ '''
659
+ try:
660
+ root = _PROJECT_ROOT
661
+ candidates = [
662
+ root,
663
+ root.parent,
664
+ root / BUNDLED_DOC_DIR,
665
+ Path(sys.prefix) / BUNDLED_DOC_DIR,
666
+ ]
667
+ if site.USER_BASE:
668
+ candidates.append(Path(site.USER_BASE) / BUNDLED_DOC_DIR)
669
+ seen: set[str] = set()
670
+ for directory in candidates:
671
+ key = str(directory)
672
+ if key in seen:
673
+ continue
674
+ seen.add(key)
675
+ candidate = directory / file_name
676
+ if candidate.is_file():
677
+ return candidate
678
+ except Exception:
679
+ return None
680
+ return None
681
+
682
+
683
+ def dist_info_file(file_name: str) -> Path | None:
684
+ '''
685
+ 用途: 在已安装发行版的元数据目录 (dist-info) 里查找文件 (pip 安装形态下 LICENSE 就在这里)。
686
+
687
+ 参数:
688
+ file_name: 文件名 (如 'LICENSE')。
689
+
690
+ 调用: 由 GUI「关于程序」的"许可协议"按钮调用 (先试它, 失败才回落到 `resource_path`:
691
+ pip 安装形态的 LICENSE 只存在于 dist-info 目录里)。
692
+
693
+ 返回: 命中时返回绝对路径; 未安装 / 元数据里没有该文件时返回 None。
694
+
695
+ 注意: 必须直接拼 dist-info 目录, **不能用 `distribution().locate_file()`**: 后者的基准
696
+ 是 site-packages 而不是 dist-info (实测 `locate_file('LICENSE')` 得到
697
+ `site-packages\\LICENSE`, 那不是真实文件), 用它会让 pip 用户的许可按钮被错误置灰。
698
+ 另: 新版 setuptools (PEP 639) 会把许可文件放到 `dist-info/licenses/`, 因此两处都查。
699
+ '''
700
+ try:
701
+ dist = distribution(DIST_NAME)
702
+ raw_path = str(getattr(dist, '_path', '') or '')
703
+ # 空 _path 必须在这里挡掉: str(Path('')) 等于 '.', 直接往下拼会去探测**当前工作目录**,
704
+ # 可能把 CWD 下同名的 LICENSE 当成发行版元数据里的文件返回 (打开无关内容)
705
+ if not raw_path:
706
+ return None
707
+ meta_dir = Path(raw_path)
708
+ for candidate in (meta_dir / file_name, meta_dir / 'licenses' / file_name):
709
+ if candidate.is_file():
710
+ return candidate
711
+ return None
712
+ except Exception:
713
+ return None
714
+
715
+
716
+ def write_config_file(config_path: Path | str, payload: dict) -> None:
717
+ '''
718
+ 用途: 把配置字典全量写入 JSON 文件, 必要时创建父目录 (首次保存即首建用户配置)。
719
+
720
+ 参数:
721
+ config_path: 目标文件路径。
722
+ payload: 待写入的配置字典 (通常为 `_get_config_payload()` 的结果)。
723
+
724
+ 调用: 由 GUI 配置编辑器保存流程调用。
725
+
726
+ 返回: 无返回值; 文件 I/O 失败时抛出 OSError (由调用方提示用户)。
727
+
728
+ 注意: 全量覆盖写入, 不做增量合并; 不写入下划线开头的内部字段 (由 payload 构造方保证)。
729
+ '''
730
+ path = Path(config_path)
731
+ if path.parent and not path.parent.exists():
732
+ path.parent.mkdir(parents=True, exist_ok=True)
733
+ with open(path, 'w', encoding='utf-8') as fp:
734
+ json.dump(payload, fp, indent=4, ensure_ascii=False)
735
+
736
+
737
+ # =========================
738
+ # 版本检查 (纯逻辑, 无 Tk 依赖; 不写任何文件)
739
+ # =========================
740
+
741
+ # 说明: 版本检查归在本模块内以减少顶层模块数, 不另设独立模块。
742
+ # 只有 urllib 是函数内惰性导入, 根模块导入期不拉进网络栈。
743
+
744
+ # =========================
745
+ # 常量
746
+ # =========================
747
+
748
+ # 数据源 (主源 PyPI 的 JSON; 版本号单一来源为 _shared.__version__ 的 8 位 YYYYMMDD)
749
+ PYPI_JSON_URL = 'https://pypi.org/pypi/fcdp/json'
750
+ GITLAB_RAW_URL = ('https://git.ustc.edu.cn/api/v4/projects/maoyan%2Ffcdp'
751
+ '/repository/files/_shared.py/raw?ref=master')
752
+ RELEASES_PAGE_URL = 'https://pypi.org/project/fcdp/'
753
+ CHANGELOG_FILE_NAME = 'CHANGELOG.md'
754
+
755
+ # 请求标识: 便于对方在日志中识别来源 (README/文档均为公开地址)
756
+ VERSION_CHECK_UA = 'FCDP-version-check'
757
+
758
+ # 单次请求超时 (秒); 检查失败必须静默且不拖慢启动, 故取值偏短
759
+ VERSION_CHECK_TIMEOUT_S = 4.0
760
+
761
+ # 状态词表 (勿再扩; 与 UI 三态一一对应)
762
+ STATUS_NEWER = 'newer' # 发现新版本
763
+ STATUS_UP_TO_DATE = 'up-to-date' # 已是最新
764
+ STATUS_FAILED = 'failed' # 检查失败 (超时 / 网络不可达 / 响应非法)
765
+
766
+ # 版本号解析: 单一来源为 _shared.__version__, 硬约定为 8 位日期式 YYYYMMDD
767
+ # (pyproject.toml 的动态版本与 .gitlab-ci.yml 的 tag 校验都按这个格式)。**只认 8 位**:
768
+ # 带点或其他长度的版本号一律落到"无法比较", 由上层如实报检查失败, 不得当成"没有新版本"。
769
+ _VERSION_RE = re.compile(r'^\d{8}$')
770
+
771
+ # 6 个核心模块的文件名 (自更新时用于清理"新版已删除"的残留; 与 pyproject 的 py-modules 对应)
772
+ CORE_MODULE_NAMES: tuple[str, ...] = ('_shared.py', 'computations.py', 'plotting.py',
773
+ 'excel_export.py', 'gui.py', 'cli.py')
774
+
775
+
776
+ def _version_tuple(raw: str) -> tuple[int, ...] | None:
777
+ '''
778
+ 用途: 把版本号字符串解析为可比较的整数元组。
779
+
780
+ 参数:
781
+ raw: 版本号字符串 (项目硬约定为 8 位日期式, 如 '20260814')。
782
+
783
+ 调用: 由 `compare_versions` 调用。
784
+
785
+ 返回: 单元素元组 (如 (20260814,)); 非 8 位纯数字形式 (空串, 字母, 连字符, 带点形式, 脏数据)
786
+ 时返回 None。
787
+
788
+ 注意: 只认 8 位数字, 不兼容 '2026.8.14' 这类形式。日期式的字典序与数值序一致, 因此解析为
789
+ 整数比较即可; 而长度不同的元组比较会让带点版本被误判为更旧 (见 `_VERSION_RE` 的说明)。
790
+ '''
791
+ text = (raw or '').strip()
792
+ if not _VERSION_RE.match(text):
793
+ return None
794
+ return (int(text),)
795
+
796
+
797
+ def compare_versions(candidate: str, current: str) -> int | None:
798
+ '''
799
+ 用途: 比较两个版本号。
800
+
801
+ 参数:
802
+ candidate: 待比较的版本 (如远端最新版本)。
803
+ current: 当前版本。
804
+
805
+ 调用: 由 `check_for_update` 调用; 测试直接调用。
806
+
807
+ 返回: candidate > current 返回 1, 相等返回 0, 更旧返回 -1; 任一侧不是 8 位日期式时返回 None。
808
+
809
+ 注意: 返回 None 表示"这一侧版本号不可用", 调用方按"该数据源没给出可用结果"处理 (换下一源,
810
+ 全部源都不可用时如实报检查失败), **不得**当成"没有新版本"。
811
+ '''
812
+ left = _version_tuple(candidate)
813
+ right = _version_tuple(current)
814
+ if left is None or right is None:
815
+ return None
816
+ if left > right:
817
+ return 1
818
+ if left < right:
819
+ return -1
820
+ return 0
821
+
822
+
823
+ # =========================
824
+ # 结果对象
825
+ # =========================
826
+
827
+ @dataclass(frozen=True)
828
+ class VersionCheckResult:
829
+ '''
830
+ 用途: 承载一次版本检查的结果, 供 UI 与启动器消费。
831
+
832
+ 字段:
833
+ status: STATUS_* 之一 (newer / up-to-date / failed)。
834
+ latest: 远端最新版本号; 检查失败时为 None。
835
+ current: 当前版本号 (由调用方传入)。
836
+ source: 命中数据源的标识 (来源 URL); 失败时为 None。
837
+ detail: 失败原因摘要 (如 'OSError: ...'), 由检查失败的弹窗展示; 成功时为 None。
838
+ '''
839
+ status: str
840
+ latest: str | None = None
841
+ current: str = ''
842
+ source: str | None = None
843
+ detail: str | None = None
844
+
845
+ @property
846
+ def has_new_version(self) -> bool:
847
+ '''是否发现新版本。'''
848
+ return self.status == STATUS_NEWER
849
+
850
+
851
+ # =========================
852
+ # 抓取
853
+ # =========================
854
+
855
+ def fetch_text(url: str, timeout: float = VERSION_CHECK_TIMEOUT_S) -> str:
856
+ '''
857
+ 用途: 发起一次 GET 请求并返回解码后的文本 (可被注入替换以便离线测试)。
858
+
859
+ 参数:
860
+ url: 目标 URL。
861
+ timeout: 超时秒数, 默认 VERSION_CHECK_TIMEOUT_S。
862
+
863
+ 调用: 默认作为 `check_for_update` 的 fetcher 参数;
864
+
865
+ 返回: 响应正文 (UTF-8 尽力解码)。
866
+
867
+ 注意: 失败时抛出异常 (URLError / OSError / HTTPError 等), 由调用方统一捕获并转为
868
+ 结果对象的 'failed' 状态; 本函数不自行吞异常, 以免掩盖真实原因。
869
+ '''
870
+ import urllib.request # 函数内导入: 根模块导入期不拉进网络栈
871
+ request = urllib.request.Request(url, headers={'User-Agent': VERSION_CHECK_UA})
872
+ with urllib.request.urlopen(request, timeout=timeout) as response:
873
+ payload = response.read()
874
+ return payload.decode('utf-8', 'replace')
875
+
876
+
877
+ def _parse_pypi_version(text: str) -> str:
878
+ '''
879
+ 用途: 从 PyPI JSON API 响应中取出最新已发布版本号。
880
+
881
+ 参数:
882
+ text: `https://pypi.org/pypi/<name>/json` 的正文。
883
+
884
+ 调用: 由 `build_check_urls` 注册进数据源列表, 再由 `check_for_update` 逐源调用。
885
+
886
+ 返回: 版本号字符串 (如 '20260814')。
887
+
888
+ 注意: 结构不符合预期 (无 info.version / 值不是字符串 / JSON 语法错) 时抛出 ValueError,
889
+ 由上层转为 'failed', 绝不猜测版本号。
890
+ '''
891
+ data = json.loads(text)
892
+ version = data.get('info', {}).get('version') if isinstance(data, dict) else None
893
+ if not isinstance(version, str) or not version.strip():
894
+ raise ValueError('PyPI 响应中缺少 info.version')
895
+ return version.strip()
896
+
897
+
898
+ def _parse_gitlab_source_version(text: str) -> str:
899
+ '''
900
+ 用途: 从 GitLab 原始文件 (_shared.py) 正文中取出 `__version__` 常量值 (备份数据源)。
901
+
902
+ 参数:
903
+ text: `_shared.py` 的完整正文。
904
+
905
+ 调用: 由 `build_check_urls` 注册进数据源列表, 再由 `check_for_update` 逐源调用。
906
+
907
+ 返回: 版本号字符串。
908
+
909
+ 注意: 必须锚定行首的赋值语句。实测 _shared.py 的注释里也出现 `__version__` 字样,
910
+ 若用宽松匹配会先命中注释行导致解析出错误内容。
911
+ '''
912
+ pattern = re.compile(r"^__version__\s*(?::\s*str\s*)?=\s*['\"](\d{8})['\"]", re.M)
913
+ match = pattern.search(text)
914
+ if match is None:
915
+ raise ValueError('GitLab 源码中未找到行首 __version__ 赋值')
916
+ return match.group(1)
917
+
918
+
919
+ def build_check_urls() -> list[tuple[str, Callable[[str], str]]]:
920
+ '''
921
+ 用途: 返回"数据源 → 解析器"的有序列表 (主源在前, 备源在后)。
922
+
923
+ 调用: 由 `check_for_update` 调用; 测试可直接调用以核对数据源数量与顺序。
924
+
925
+ 返回: `[(url, parser), ...]`; parser 接收响应正文, 返回版本号字符串。
926
+
927
+ 注意: 顺序即优先级。PyPI 是权威"最新已发布版本"面; GitLab 仅作内网备份。
928
+ '''
929
+ return [
930
+ (PYPI_JSON_URL, _parse_pypi_version),
931
+ (GITLAB_RAW_URL, _parse_gitlab_source_version),
932
+ ]
933
+
934
+
935
+ # =========================
936
+ # 主入口
937
+ # =========================
938
+
939
+ def check_for_update(current_version: str,
940
+ fetcher: Callable[[str], str] | None = None) -> VersionCheckResult:
941
+ '''
942
+ 用途: 依次尝试各数据源, 取到最新版本后与当前版本比较, 返回结果对象。
943
+
944
+ 参数:
945
+ current_version: 当前程序版本 (调用方传 `_shared.__version__`)。
946
+ fetcher: 抓取函数 (url) -> 正文; None 时使用 `fetch_text`。测试注入桩即可完全离线。
947
+
948
+ 调用: 由 GUI 自动/手动检查通道调用; 测试直接调用并注入桩。
949
+
950
+ 返回: `VersionCheckResult`。**任何异常都转为 status='failed' 的结果**, 不向调用方抛出。
951
+
952
+ 注意: 本函数不做去重, 不写文件, 不打日志, 这些都由调用方负责。
953
+ 数据源顺序固定为 `build_check_urls()`, 不开放注入 (测试用 fetcher 桩就能覆盖两条源)。
954
+ '''
955
+ active_fetcher = fetcher if fetcher is not None else fetch_text
956
+ last_detail: str | None = None
957
+
958
+ for url, parser in build_check_urls():
959
+ try:
960
+ text = active_fetcher(url)
961
+ latest = parser(text)
962
+ # 版本比较也放进 try: compare_versions 对非字符串入参会抛 (如 AttributeError),
963
+ # 它同样属于"这个源没给出可用结果", 不应破坏本函数"不向调用方抛异常"的约定
964
+ verdict = compare_versions(latest, current_version)
965
+ except Exception as ex: # 网络 / HTTP / JSON / 解析 一律视为该源失败
966
+ last_detail = f'{type(ex).__name__}: {ex}'
967
+ continue
968
+
969
+ if verdict is None:
970
+ last_detail = f'版本号无法比较 (远端 {latest!r} / 当前 {current_version!r})'
971
+ continue
972
+
973
+ return VersionCheckResult(
974
+ status=STATUS_NEWER if verdict > 0 else STATUS_UP_TO_DATE,
975
+ latest=latest,
976
+ current=current_version,
977
+ source=url,
978
+ )
979
+
980
+ return VersionCheckResult(
981
+ status=STATUS_FAILED,
982
+ current=current_version,
983
+ detail=last_detail or '所有数据源均不可用',
984
+ )
985
+
986
+
987
+ # =========================
988
+ # 辅助
989
+ # =========================
990
+
991
+ def changelog_path(base_dir: Path | str) -> Path:
992
+ '''
993
+ 用途: 返回随包 CHANGELOG 的路径。
994
+
995
+ 参数:
996
+ base_dir: 程序目录 (调用方传 `_shared._PROJECT_ROOT`)。
997
+
998
+ 调用: 由 GUI「检查更新」对话框的"查看更新说明"按钮调用。
999
+
1000
+ 返回: CHANGELOG 的绝对路径。
1001
+
1002
+ 注意: 仅拼路径, 不判断是否存在 (由调用方决定文件缺失时的提示)。
1003
+ 本函数只覆盖"文档与模块同目录 / 其上一级"这一档形态 (源码副本 / 开发仓库);
1004
+ pip 安装形态的文档由 [tool.setuptools.data-files] 装到前缀下的 `fcdp/` 目录,
1005
+ 必须用 `resource_path(CHANGELOG_FILE_NAME)` 查 (它已包含本函数这一档并按优先级串联),
1006
+ 直接拿本函数的返回值会把 pip 用户误判成"文档缺失"。
1007
+ '''
1008
+ return Path(base_dir) / CHANGELOG_FILE_NAME
1009
+
1010
+
1011
+ # =========================
1012
+ # 配置常量
1013
+ # =========================
1014
+
1015
+ # 版本号单一来源 (YYYYMMDD 日期式)。pyproject.toml 经 tool.setuptools.dynamic
1016
+ # attr = "_shared.__version__" 派生; GUI/CLI 亦直接引用此值, 避免多处手抄漂移。
1017
+ __version__: str = '20261015'
1018
+
1019
+ @dataclass(frozen=True)
1020
+ class Config:
1021
+ '''
1022
+ 用途: 应用级只读配置中心。通过 build_config() 从 config.json 合并默认值构建,
1023
+ 运行期不可变 (frozen=True)。所有可配置项集中于此, GUI ConfigEditor 以此驱动。
1024
+
1025
+ metadata 字段说明:
1026
+ comment: 中文说明, 在 GUI ConfigEditor 中显示为行标签。
1027
+ section: 配置分组标签, ConfigEditor 按此分组展示。
1028
+ GUI 当前支持: '全局', 'IV', 'ECSA', 'O2', 'EIS'。
1029
+ 不在上述分组中的字段归入"其他参数"。
1030
+ validation:可选的校验规则标识, 供 ConfigEditor 输入验证用。
1031
+ 当前支持: 'positive_number' (正数), 'non_empty_list' (非空列表)。
1032
+
1033
+ 注意:
1034
+ - 以下划线开头的字段 (如 _json_path) 不会写入 config.json,
1035
+ 且不被 GUI ConfigEditor 展示。它们仅供程序内部使用。
1036
+ - 列表字段 (如 o2_x_list) 写入 JSON 时为 list, 读取后自动转为 tuple
1037
+ 以保持 frozen dataclass 的不可变性。详见 build_config()。
1038
+ - 新增配置项时, 只需在此 dataclass 中添加字段和 metadata,
1039
+ 其余 (config.json 读写, GUI 编辑) 自动适配。
1040
+ '''
1041
+
1042
+ # ---- 元数据 ----
1043
+ # 配置文件路径由 resolve_config_path() 解析: 显式指定 > FCDP_CONFIG >
1044
+ # (pip 安装 ? 用户数据目录 : 本目录)。本目录副本没有 config.json 时用内置默认值, 首次保存生成在本目录。
1045
+ # 每次构造 Config 都重新求值 (default_factory 语义); 下划线开头 -> 不写入 config.json, 也不被配置编辑器展示。
1046
+ _json_path: Path = field(default_factory=_resolve_default_config_path)
1047
+
1048
+ # ---- 全局 ----
1049
+ data_path_default: str = field(
1050
+ default='', metadata={'comment': '默认数据文件所在父文件夹路径', 'section': '全局'},
1051
+ )
1052
+ result_path: str = field(
1053
+ default='', metadata={'comment': '默认导出文件存放路径', 'section': '全局'},
1054
+ )
1055
+ draw_results: bool = field(
1056
+ default=True, metadata={'comment': '是否绘制结果图', 'section': '全局'},
1057
+ )
1058
+ # 公共参数 (跨管线共享)
1059
+ cell_area: float = field(
1060
+ default=5.0, metadata={'comment': '电池面积 (cm²)', 'section': '全局', 'validation': 'positive_number'},
1061
+ )
1062
+ cell_area_auto: bool = field(
1063
+ default=True, metadata={'comment': '自动从 .fcd 文件读取电池面积 (否则使用 cell_area)', 'section': '全局'},
1064
+ )
1065
+ # 跨管线共享: Pt 载量 (mg/cm²), ECSA/O2 loading 分析用
1066
+ pt_loading: float = field(
1067
+ default=0.1, metadata={
1068
+ 'comment': '默认 Pt 载量 (mg/cm²)', 'section': '全局', 'validation': 'positive_number',
1069
+ },
1070
+ )
1071
+ fig_dpi: int = field(
1072
+ default=100, metadata={'comment': '导出图片的 DPI(清晰度)', 'section': '全局'},
1073
+ )
1074
+ processing_mode: str = field(
1075
+ default='auto', metadata={'comment': '处理模式: auto(自动检测) / iv / hupd / co / o2, 强制指定处理方式', 'section': '全局'},
1076
+ )
1077
+ auto_process: bool = field(
1078
+ default=False, metadata={'comment': '选择文件后是否立刻开始处理 (否则需点开始处理按钮)', 'section': '全局'},
1079
+ )
1080
+
1081
+ # 版本检查开关: 只控制"启动时自动检查", 不影响帮助菜单的手动「检查更新」(手动始终可用)
1082
+ auto_check_update: bool = field(
1083
+ default=True, metadata={
1084
+ 'comment': '启动时自动检查新版本 (关闭后仍可用帮助菜单手动检查)', 'section': '全局',
1085
+ },
1086
+ )
1087
+
1088
+ device_map: dict = field(
1089
+ default_factory=dict, metadata={
1090
+ 'comment': '设备 SN→名称映射表 格式: SN1=名称1\\nSN2=名称2',
1091
+ 'section': '全局',
1092
+ },
1093
+ )
1094
+
1095
+ # ---- EIS ----
1096
+ eis_min_r_squared: float = field(
1097
+ default=0.999, metadata={'comment': 'EIS 线性拟合最低 R² 阈值 (默认 0.999)', 'section': 'EIS'},
1098
+ )
1099
+
1100
+ # ---- IV ----
1101
+ iv_mean_num: int = field(
1102
+ default=6, metadata={
1103
+ 'comment': 'IV 取点数: 每段控制变化后取最后 N 个点平均',
1104
+ 'section': 'IV', 'validation': 'positive_number',
1105
+ },
1106
+ )
1107
+ h2_mean_num: int = field(
1108
+ default=6, metadata={
1109
+ 'comment': 'H₂ crossover 取点数: 每个电压平台取最后 N 个点平均',
1110
+ 'section': 'IV', 'validation': 'positive_number',
1111
+ },
1112
+ )
1113
+ ma_mean_num: int = field(
1114
+ default=6, metadata={
1115
+ 'comment': 'MA 取点数: 每段控制变化后取最后 N 个点平均',
1116
+ 'section': 'IV', 'validation': 'positive_number',
1117
+ },
1118
+ )
1119
+ iv_e_list: tuple = field(
1120
+ default=(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0.5, 0.2, 0.1, 0),
1121
+ metadata={
1122
+ 'comment': 'IV 极化曲线电流设定值列表 (A)', 'section': 'IV', 'validation': 'non_empty_list',
1123
+ },
1124
+ )
1125
+ iv_e_match_min_num: int = field(
1126
+ default=5, metadata={
1127
+ 'comment': 'IV 匹配所需的最小连续电流点个数', 'section': 'IV', 'validation': 'positive_number',
1128
+ },
1129
+ )
1130
+ iv_lookup_e_value_list: tuple = field(
1131
+ default=(0.5, 0.6, 0.7, 0.8),
1132
+ metadata={'comment': 'J@E 查找的电压点列表 (V)', 'section': 'IV', 'validation': 'non_empty_list'},
1133
+ )
1134
+ iv_lookup_j_value_list: tuple = field(
1135
+ default=(2000,),
1136
+ metadata={'comment': 'E@J 查找的电流密度点列表 (mA/cm²)', 'section': 'IV', 'validation': 'non_empty_list'},
1137
+ )
1138
+ h2_volt_list: tuple = field(
1139
+ default=(0.3, 0.4, 0.5, 0.6),
1140
+ metadata={'comment': 'H₂ crossover 电压平台列表 (V)', 'section': 'IV', 'validation': 'non_empty_list'},
1141
+ )
1142
+ h2_anode_flow: float = field(
1143
+ default=0.5, metadata={'comment': 'H₂ crossover 阳极气体流速 (SLM)', 'section': 'IV'},
1144
+ )
1145
+ h2_cathode_flow: float = field(
1146
+ default=0.05, metadata={'comment': 'H₂ crossover 阴极气体流速 (SLM)', 'section': 'IV'},
1147
+ )
1148
+ h2_to_entry: bool = field(
1149
+ default=False, metadata={'comment': '是否将最后一段 H₂ crossover 结果填入输入框', 'section': 'IV'},
1150
+ )
1151
+ h2_e_tol: float = field(
1152
+ default=0.01, metadata={
1153
+ 'comment': 'H₂ crossover 电压平台匹配容差 (V)', 'section': 'IV', 'validation': 'positive_number',
1154
+ },
1155
+ )
1156
+ ma_volt_list: tuple = field(
1157
+ default=(0.775, 0.8, 0.825, 0.85, 0.875, 0.9),
1158
+ metadata={'comment': 'MA 电压点列表 (V)', 'section': 'IV', 'validation': 'non_empty_list'},
1159
+ )
1160
+ ma_match_min_num: int = field(
1161
+ default=3, metadata={
1162
+ 'comment': 'MA 匹配所需的最小连续电压点个数', 'section': 'IV', 'validation': 'positive_number',
1163
+ },
1164
+ )
1165
+ ma_erange_tol: float = field(
1166
+ default=0.0003, metadata={
1167
+ 'comment': 'MA 电位窗口的 E_range 容差 (V)', 'section': 'IV', 'validation': 'positive_number',
1168
+ },
1169
+ )
1170
+ del_condition_rh: int = field(
1171
+ default=140, metadata={
1172
+ 'comment': '活化截断的 RH 阈值 (%)', 'section': 'IV', 'validation': 'positive_number',
1173
+ },
1174
+ )
1175
+ is_del_conditioning: bool = field(
1176
+ default=True, metadata={'comment': '是否自动删除活化数据段', 'section': 'IV'},
1177
+ )
1178
+ process_imp: bool = field(
1179
+ default=True, metadata={'comment': '是否处理并删除阻抗谱数据行', 'section': 'IV'},
1180
+ )
1181
+ impdata_to_txt: bool = field(
1182
+ default=False, metadata={'comment': '是否额外将阻抗谱导出为 TXT 文件', 'section': 'IV'},
1183
+ )
1184
+ output_zmag: bool = field(
1185
+ default=False, metadata={'comment': '阻抗谱导出是否包含 Z_Mag 列', 'section': 'IV'},
1186
+ )
1187
+ detail_to_excel: bool = field(
1188
+ default=False, metadata={'comment': '是否将 detail_df 输出到 Excel', 'section': 'IV'},
1189
+ )
1190
+
1191
+ # ---- ECSA ----
1192
+ ecsa_scan_rate: float = field(
1193
+ default=0.1, metadata={
1194
+ 'comment': 'ECSA 默认电压扫速 (V/s)', 'section': 'ECSA', 'validation': 'positive_number',
1195
+ },
1196
+ )
1197
+ ecsa_co_cross_threshold: float = field(
1198
+ default=0.0025, metadata={
1199
+ 'comment': 'CO 选点零跨容差 (A): 平滑 diff < 此值即认为已回基线', 'section': 'ECSA', 'validation': 'positive_number',
1200
+ },
1201
+ )
1202
+
1203
+ # ---- O2 ----
1204
+ o2_start_v: float = field(
1205
+ default=0.15, metadata={'comment': '极限电流搜索的起始电位 (V)', 'section': 'O2'},
1206
+ )
1207
+ o2_end_v: float = field(
1208
+ default=0.3, metadata={'comment': '极限电流搜索的结束电位 (V)', 'section': 'O2'},
1209
+ )
1210
+ o2_flow_rate: float = field(
1211
+ default=5.0, metadata={
1212
+ 'comment': '阴极气体流速 (SLM)', 'section': 'O2', 'validation': 'positive_number',
1213
+ },
1214
+ )
1215
+ o2_x_list: tuple = field(
1216
+ default=(0.01, 0.015, 0.02, 0.025),
1217
+ metadata={'comment': '氧气浓度列表 (摩尔分数, 非百分比)', 'section': 'O2', 'validation': 'non_empty_list'},
1218
+ )
1219
+ o2_pressure_list: tuple = field(
1220
+ default=(1.0, 1.5, 2.0, 3.0),
1221
+ metadata={'comment': '压力列表 (bar)', 'section': 'O2', 'validation': 'non_empty_list'},
1222
+ )
1223
+ o2_split_sign: str = field(
1224
+ default='CURVE\tTABLE\t', metadata={'comment': 'Gamry 数据文件中的曲线定位标记', 'section': 'O2'},
1225
+ )
1226
+ o2_filename_pattern: str = field(
1227
+ default=r'(\d+(?:\.\d+)?)\s*', metadata={'comment': '从文件名提取压力/浓度的正则匹配模式', 'section': 'O2'},
1228
+ )
1229
+ o2_cell_temp: int = field(
1230
+ default=80, metadata={
1231
+ 'comment': 'O2 测试电池温度 (°C)', 'section': 'O2', 'validation': 'positive_number',
1232
+ },
1233
+ )
1234
+ o2_l_flow_channel: float = field(
1235
+ default=6.5, metadata={
1236
+ 'comment': '流道长度 (cm)', 'section': 'O2', 'validation': 'positive_number',
1237
+ },
1238
+ )
1239
+ o2_n_flow_channel: int = field(
1240
+ default=7, metadata={
1241
+ 'comment': '流道数量', 'section': 'O2', 'validation': 'positive_number',
1242
+ },
1243
+ )
1244
+
1245
+
1246
+ def build_config(json_path: Path | None = None) -> Config:
1247
+ '''
1248
+ 用途: 读取 JSON 配置文件, 与默认值合并, 构建不可变 Config 实例。
1249
+
1250
+ 参数:
1251
+ json_path: 配置文件路径, None 时按 `resolve_config_path()` 解析
1252
+ (显式指定 > FCDP_CONFIG 环境变量 > 本目录副本的本目录配置 / pip 安装的用户数据目录)。
1253
+
1254
+ 调用: 由 GUI/CLI 启动流程与程序化调用方调用。
1255
+
1256
+ 返回: 返回 frozen Config 实例。
1257
+
1258
+ 注意: 显式传入的路径不存在时**不抛异常**: 回退为全部默认值并打印 stderr 警告。这是既定的程序化契约;
1259
+ 需要确定性失败的入口 (如 CLI `--config`) 自行前置校验。
1260
+ 本函数只读不写: 解析出的路径文件不存在时不会自动创建, 首建发生在保存配置时 (本目录副本形态生成在本目录, pip 安装形态生成在用户数据目录)。
1261
+ '''
1262
+ defaults = {}
1263
+ for f in fields(Config):
1264
+ if f.default is not MISSING:
1265
+ defaults[f.name] = f.default
1266
+ elif f.default_factory is not MISSING:
1267
+ defaults[f.name] = f.default_factory()
1268
+
1269
+ # 读取 JSON 配置覆盖 (仅覆盖非下划线字段)
1270
+ if json_path is None:
1271
+ config_path = Path(defaults['_json_path']) # default_factory 保证存在且为 Path
1272
+ else:
1273
+ config_path = Path(json_path)
1274
+ if not config_path.is_file():
1275
+ # 契约仍是"不存在即默认值", 但不能静默: 否则"路径写错"与"配置没生效"无法区分
1276
+ print(f'[WARNING] 指定的配置文件不存在 ({config_path}), 使用全部默认值', file=sys.stderr)
1277
+ if config_path.is_file():
1278
+ try:
1279
+ with open(config_path, 'r', encoding='utf-8') as fp:
1280
+ overrides = json.load(fp)
1281
+ field_map = {f.name: f for f in fields(Config) if not f.name.startswith('_')}
1282
+ for key, value in overrides.items():
1283
+ if key not in field_map:
1284
+ continue
1285
+ # JSON 中的 list 转 tuple, 保持 frozen 兼容
1286
+ if isinstance(value, list):
1287
+ defaults[key] = tuple(value)
1288
+ continue
1289
+ # 数值字段类型强制: config.json 中误写为字符串时尝试转 int/float
1290
+ target_type = field_map[key].type
1291
+ if isinstance(value, str) and target_type in (int, float):
1292
+ try:
1293
+ defaults[key] = int(value) if target_type is int else float(value)
1294
+ continue
1295
+ except (ValueError, TypeError):
1296
+ pass # 转换失败保留原值, 由 validate_config 告警
1297
+ defaults[key] = value
1298
+ except Exception as e:
1299
+ print(f'[WARNING] 配置文件读取失败 ({e}), 使用全部默认值', file=sys.stderr)
1300
+
1301
+ return Config(**defaults)
1302
+
1303
+
1304
+ # =========================
1305
+ # 配置字段提取
1306
+ # =========================
1307
+
1308
+ def _extract_config_fields(config_cls):
1309
+ '''
1310
+ 用途: 从 Config dataclass 提取字段名称, 类型, 默认值, 中文注释和元数据。
1311
+
1312
+ 调用: 由 `ConfigEditorDialog` 构造 UI 行时调用。
1313
+ 返回: 返回 `[(name, type_tag, default_value, comment, metadata), ...]` 列表。
1314
+ type_tag 为 'bool' | 'int' | 'float' | 'list' | 'str'。
1315
+ metadata 为字段 metadata 字典。
1316
+ '''
1317
+ result = []
1318
+ for f in fields(config_cls):
1319
+ if f.name.startswith('_'):
1320
+ continue
1321
+ # 确定默认值
1322
+ if f.default is not MISSING:
1323
+ default = f.default
1324
+ elif f.default_factory is not MISSING:
1325
+ default = f.default_factory()
1326
+ else:
1327
+ default = None
1328
+ # 确定类型标签
1329
+ if isinstance(default, bool):
1330
+ tag = 'bool'
1331
+ elif isinstance(default, tuple):
1332
+ tag = 'list' # UI 仍显示为可编辑列表
1333
+ elif isinstance(default, dict):
1334
+ tag = 'str' # dict 以纯文本形式展示和编辑
1335
+ elif isinstance(default, int):
1336
+ tag = 'int'
1337
+ elif isinstance(default, float):
1338
+ tag = 'float'
1339
+ else:
1340
+ tag = 'str'
1341
+ comment = f.metadata.get('comment', '')
1342
+ result.append((f.name, tag, default, comment, f.metadata))
1343
+ return result
1344
+
1345
+
1346
+ def validate_config(config: Config) -> list[str]:
1347
+ '''
1348
+ 用途: 校验关键配置项 (路径存在性, 正数, 非空列表,O2 电压范围,ECSA 模式等)。
1349
+
1350
+ 调用: 由 `Application.start` 和 `ConfigEditorDialog.save` 调用。
1351
+ 返回: 返回警告字符串列表, 空列表表示无警告。
1352
+ '''
1353
+ warnings = []
1354
+
1355
+ def add_warning(name, current_value, suggestion, impact):
1356
+ warnings.append(f'{name}: 当前值={current_value}; 建议={suggestion}; 影响={impact}')
1357
+
1358
+ def is_positive_number(name):
1359
+ value = getattr(config, name)
1360
+ if not isinstance(value, (int, float)) or value <= 0:
1361
+ add_warning(name, value, '使用正数', '相关计算公式或参数读取可能失败')
1362
+
1363
+ def is_non_empty_list(name):
1364
+ value = getattr(config, name)
1365
+ if not isinstance(value, (list, tuple)) or len(value) == 0:
1366
+ add_warning(name, value, '使用非空列表/元组', '序列匹配, 插值或 O2 分组可能失败')
1367
+
1368
+ for path_name in ('data_path_default', 'result_path'):
1369
+ path_value = getattr(config, path_name)
1370
+ if not path_value:
1371
+ add_warning(path_name, '未设置', '设置目录路径', '默认打开目录或结果保存目录可能不可用')
1372
+ continue
1373
+ p = Path(path_value)
1374
+ if not p.exists():
1375
+ add_warning(path_name, path_value, '填写存在的目录路径', '默认打开目录或结果保存目录可能不可用')
1376
+ elif not p.is_dir():
1377
+ add_warning(path_name, path_value, '填写目录路径而非文件路径', '该路径应为父文件夹路径')
1378
+ if path_name == 'result_path' and p.exists() and p.is_dir():
1379
+ # 检查结果路径是否可写
1380
+ try:
1381
+ write_test = p / '.fcdp_write_test'
1382
+ write_test.touch(exist_ok=True)
1383
+ write_test.unlink(missing_ok=True)
1384
+ except (OSError, PermissionError):
1385
+ add_warning(path_name, path_value, '填写有写权限的目录路径', 'Excel 导出可能失败')
1386
+
1387
+ # 按字段 metadata 中的 validation 标记校验
1388
+ for f in fields(config.__class__):
1389
+ name = f.name
1390
+ if name.startswith('_'):
1391
+ continue
1392
+ vtype = f.metadata.get('validation')
1393
+ if vtype == 'positive_number':
1394
+ is_positive_number(name)
1395
+ elif vtype == 'non_empty_list':
1396
+ is_non_empty_list(name)
1397
+
1398
+ # 数值字段类型告警: build_config 强制转换失败(如字符串不可转数字)时在此暴露
1399
+ for f in fields(config.__class__):
1400
+ name = f.name
1401
+ if name.startswith('_'):
1402
+ continue
1403
+ if f.type in (int, float):
1404
+ value = getattr(config, name)
1405
+ if not isinstance(value, (int, float)):
1406
+ add_warning(name, value, f'使用数字 ({f.type.__name__})', '相关计算公式或参数读取可能失败')
1407
+
1408
+ o2_start_v = getattr(config, 'o2_start_v')
1409
+ o2_end_v = getattr(config, 'o2_end_v')
1410
+ if not isinstance(o2_start_v, (int, float)) or not isinstance(o2_end_v, (int, float)):
1411
+ add_warning('o2_start_v/o2_end_v', f'{o2_start_v}, {o2_end_v}', '使用数字电压范围', 'O2 极限电流搜索可能失败')
1412
+ elif o2_start_v >= o2_end_v:
1413
+ add_warning('o2_start_v/o2_end_v', f'{o2_start_v}, {o2_end_v}', '保证 o2_start_v < o2_end_v', 'O2 极限电流搜索范围会被反转或无效')
1414
+
1415
+ if getattr(config, 'processing_mode', 'auto').lower() not in ('auto', 'iv', 'hupd', 'co', 'o2'):
1416
+ add_warning('processing_mode', config.processing_mode, "使用 'auto','iv','hupd','co' 或 'o2'", '处理模式选择可能失败')
1417
+
1418
+ o2_pressure_list = getattr(config, 'o2_pressure_list')
1419
+ o2_x_list = getattr(config, 'o2_x_list')
1420
+ if isinstance(o2_pressure_list, (list, tuple)) and isinstance(o2_x_list, (list, tuple)) and len(o2_pressure_list) * len(o2_x_list) == 0:
1421
+ add_warning('o2_pressure_list/o2_x_list', f'{o2_pressure_list}, {o2_x_list}', '两个列表都保持非空', 'O2 文件名解析失败时无法按配置兜底分组')
1422
+
1423
+ return warnings
1424
+
1425
+
1426
+ def _get_modified_config_fields(config: Config) -> list[tuple[str, object, object]]:
1427
+ """
1428
+ 用途: 对比当前 config 与默认值, 返回被修改的字段名及其 (默认值, 当前值) 列表。
1429
+
1430
+ 调用: 由各管道引擎在运行前调用, 用于日志输出。
1431
+ 返回: [(field_name, default_value, current_value), ...]
1432
+ """
1433
+ default = Config()
1434
+ changed = []
1435
+ for f in fields(Config):
1436
+ if f.name.startswith('_'):
1437
+ continue
1438
+ default_val = getattr(default, f.name, None)
1439
+ current_val = getattr(config, f.name, None)
1440
+ if type(default_val) != type(current_val):
1441
+ changed.append((f.name, default_val, current_val))
1442
+ elif isinstance(default_val, (tuple, list)):
1443
+ if list(default_val) != list(current_val):
1444
+ changed.append((f.name, default_val, current_val))
1445
+ elif default_val != current_val:
1446
+ changed.append((f.name, default_val, current_val))
1447
+ return changed
1448
+
1449
+
1450
+ # =========================
1451
+ # 结构化导出报告 (Export Report)
1452
+ # =========================
1453
+
1454
+ # 逐项状态四态 (勿再扩)
1455
+ # 各 Output 的 outcome 字段值与 @property total 均取自此四态, 第三方消费时无需再翻源码:
1456
+ # ok = 该步骤成功且结果可靠
1457
+ # skip = 该步骤未执行 (如未启用绘图/导出, 或无对应数据)
1458
+ # warn = 该步骤完成但结果可能偏离 (如识别值异常, 拟合 R² 偏低)
1459
+ # fail = 该步骤失败, 对应结果不可用
1460
+ # 整体 total 取各字段最高严重度 (见 _TOTAL_ORDER)。
1461
+ ITEM_OK = 'ok'
1462
+ ITEM_SKIP = 'skip'
1463
+ ITEM_WARN = 'warn'
1464
+ ITEM_FAIL = 'fail'
1465
+
1466
+ # 阶段词表 (5 条管线统一, 保证跨管线可比)
1467
+ PHASE_PARSE = '解析'
1468
+ PHASE_PREPROCESS = '预处理'
1469
+ PHASE_CALC = '计算'
1470
+ PHASE_PLOT = '绘图'
1471
+ PHASE_EXCEL = 'Excel导出'
1472
+
1473
+ _TOTAL_ORDER = {ITEM_FAIL: 3, ITEM_WARN: 2, ITEM_OK: 1, ITEM_SKIP: 0}
1474
+
1475
+
1476
+ def _max_status(records: 'list[str]') -> str:
1477
+ '''
1478
+ 用途: 从一组状态中取最高严重度 (fail>warn>ok>skip)。
1479
+
1480
+ 调用: 由各 Output 的 @property total 现算整体状态时调用。
1481
+ 返回: 返回 ITEM_* 之一。
1482
+ '''
1483
+ worst = ITEM_OK
1484
+ worst_sev = -1
1485
+ for status in records:
1486
+ # 未注册状态按最高严重度 (fail) 处理, 避免未知/拼错的状态被静默降级为 ok
1487
+ if status not in _TOTAL_ORDER:
1488
+ return ITEM_FAIL
1489
+ sev = _TOTAL_ORDER[status]
1490
+ if sev > worst_sev:
1491
+ worst, worst_sev = status, sev
1492
+ return worst
1493
+
1494
+
1495
+ @dataclass
1496
+ class IvOutcome:
1497
+ '''IV/H2/MA 管线结果状态。'''
1498
+ conditioning: str = ITEM_SKIP # 活化移除 (预处理, ok=已剔除 / skip=未剔除)
1499
+ figure: str = ITEM_SKIP # IV 图 (绘图)
1500
+ excel: str = ITEM_SKIP # IV工作簿 (Excel导出)
1501
+ cell_area: str = ITEM_SKIP # 电池面积 (识别到的值与 config 不一致→warn)
1502
+ back_pressure: str = ITEM_SKIP # 背压 (文件解析到且与默认 150 不一致→warn)
1503
+ # 段级 quality: 每类 unit 各自统计 (多段→一档), 无段时默认 skip;
1504
+ # 段匹配/H2/MA 处理遇意外异常(崩溃)时, 由对应 except 分支置 ITEM_FAIL
1505
+ iv_segments: str = ITEM_SKIP # iv_units 各段 status 最高档; 段匹配崩溃→fail
1506
+ imp_segments: str = ITEM_SKIP # imp_units 各段 status 最高档
1507
+ ma_segments: str = ITEM_SKIP # ma_units 各段 status 最高档; 崩溃→fail
1508
+ h2_segments: str = ITEM_SKIP # h2_units 各段 status 最高档; 崩溃→fail
1509
+
1510
+
1511
+ @dataclass
1512
+ class CoEcsaOutcome:
1513
+ '''CO-ECSA 管线结果状态。'''
1514
+ segment_parse: str = ITEM_SKIP # 片段解析 (计算)
1515
+ data_prep: str = ITEM_SKIP # CO 数据准备 (计算)
1516
+ point_select: str = ITEM_SKIP # 端点选择 (计算)
1517
+ co_ecsa: str = ITEM_SKIP # CO ECSA (计算)
1518
+ excel: str = ITEM_SKIP # CO ECSA工作簿 (Excel导出)
1519
+
1520
+
1521
+ @dataclass
1522
+ class HupdEcsaOutcome:
1523
+ '''HUPD-ECSA 管线结果状态。'''
1524
+ segment_parse: str = ITEM_SKIP # 片段解析 (计算)
1525
+ point_select: str = ITEM_SKIP # 端点选择 (计算)
1526
+ hupd_calc: str = ITEM_SKIP # HUPD 计算 (计算)
1527
+ excel: str = ITEM_SKIP # HUPD ECSA工作簿 (Excel导出)
1528
+
1529
+
1530
+ @dataclass
1531
+ class OxygenOutcome:
1532
+ '''O2 管线结果状态。'''
1533
+ file_grouping: str = ITEM_SKIP # 文件分组 (解析)
1534
+ limit_curve: str = ITEM_SKIP # 极限电流曲线 (计算)
1535
+ resistance: str = ITEM_SKIP # O2 阻力 (计算)
1536
+ figure: str = ITEM_SKIP # O2 图 (绘图)
1537
+ excel: str = ITEM_SKIP # O2工作簿 (Excel导出)
1538
+
1539
+
1540
+ @dataclass
1541
+ class EisOutcome:
1542
+ '''EIS 管线结果状态。'''
1543
+ segment_fit: str = ITEM_SKIP # EIS 段搜索/拟合 (计算)
1544
+ r2: str = ITEM_SKIP # EIS 拟合 R² (计算)
1545
+ excel: str = ITEM_SKIP # EIS工作簿 (Excel导出)
1546
+
1547
+
1548
+ def outcome_rows(outcome: object) -> 'list[tuple[str, str, str]]':
1549
+ '''
1550
+ 用途: 把管线 outcome 固定类各字段就地投影为 (阶段, 项目, 状态) 行。
1551
+ CLI 终端打印与 Excel `导出状态` 表共用此单一来源, 避免逐类映射在两端漂移。
1552
+
1553
+ 调用: 由 cli.py `_print_export_report` 与 excel_export.py `write_export_report_sheet` 调用。
1554
+ 返回: 返回 (phase, item, status) 三元组列表; 无法识别的 outcome 返回空列表。
1555
+ '''
1556
+ if isinstance(outcome, IvOutcome):
1557
+ return [
1558
+ (PHASE_PREPROCESS, '活化移除', outcome.conditioning),
1559
+ (PHASE_CALC, 'IV 段', outcome.iv_segments),
1560
+ (PHASE_CALC, '阻抗谱段', outcome.imp_segments),
1561
+ (PHASE_CALC, 'MA 段', outcome.ma_segments),
1562
+ (PHASE_CALC, 'H2 段', outcome.h2_segments),
1563
+ (PHASE_CALC, '电池面积', outcome.cell_area),
1564
+ (PHASE_CALC, '背压', outcome.back_pressure),
1565
+ (PHASE_PLOT, 'IV 图', outcome.figure),
1566
+ (PHASE_EXCEL, 'IV工作簿', outcome.excel),
1567
+ ]
1568
+ if isinstance(outcome, CoEcsaOutcome):
1569
+ return [
1570
+ (PHASE_CALC, '片段解析', outcome.segment_parse),
1571
+ (PHASE_CALC, 'CO 数据准备', outcome.data_prep),
1572
+ (PHASE_CALC, '端点选择', outcome.point_select),
1573
+ (PHASE_CALC, 'CO ECSA', outcome.co_ecsa),
1574
+ (PHASE_EXCEL, 'CO ECSA工作簿', outcome.excel),
1575
+ ]
1576
+ if isinstance(outcome, HupdEcsaOutcome):
1577
+ return [
1578
+ (PHASE_CALC, '片段解析', outcome.segment_parse),
1579
+ (PHASE_CALC, '端点选择', outcome.point_select),
1580
+ (PHASE_CALC, 'HUPD 计算', outcome.hupd_calc),
1581
+ (PHASE_EXCEL, 'HUPD ECSA工作簿', outcome.excel),
1582
+ ]
1583
+ if isinstance(outcome, OxygenOutcome):
1584
+ return [
1585
+ (PHASE_PARSE, '文件分组', outcome.file_grouping),
1586
+ (PHASE_CALC, '极限电流曲线', outcome.limit_curve),
1587
+ (PHASE_CALC, 'O2 阻力', outcome.resistance),
1588
+ (PHASE_PLOT, 'O2 图', outcome.figure),
1589
+ (PHASE_EXCEL, 'O2工作簿', outcome.excel),
1590
+ ]
1591
+ if isinstance(outcome, EisOutcome):
1592
+ return [
1593
+ (PHASE_CALC, 'EIS 段搜索/拟合', outcome.segment_fit),
1594
+ (PHASE_CALC, 'EIS 拟合 R²', outcome.r2),
1595
+ (PHASE_EXCEL, 'EIS工作簿', outcome.excel),
1596
+ ]
1597
+ return []
1598
+
1599
+
1600
+ # =========================
1601
+ # 运行期服务
1602
+ # =========================
1603
+
1604
+ class RuntimeServices:
1605
+ '''
1606
+ 用途: 为辅助函数提供轻量的界面和日志边界, 自动识别 UI / CLI 模式。
1607
+
1608
+ 调用: 由 `Application` 创建, 并由各处理流程使用。
1609
+ '''
1610
+ config: Any
1611
+
1612
+ def __init__(self, config: Config):
1613
+ self.config = config
1614
+ self._messages = []
1615
+ # 测试用: 置 True 时 show() 不打印 stderr (conftest mock_services / 各测试静默输出用)。
1616
+ # 产品代码 (CLI/GUI) 从不切换此开关, 默认 False → 管线日志经 show() 打到 stderr (CLI 日志出口)。
1617
+ self._quiet: bool = False
1618
+
1619
+ def _record_message(self, label, text, level='INFO') -> 'LogMessage':
1620
+ '''记录消息到 _messages 列表, 返回 LogMessage 对象。'''
1621
+ ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
1622
+ msg = LogMessage(level=level, text=str(text), timestamp=ts, label=label)
1623
+ self._messages.append(msg)
1624
+ return msg
1625
+
1626
+ def show(self, label, text, level='INFO', capture_exc_info=True):
1627
+ '''
1628
+ 用途: 记录并显示消息。在 except 块中以 ERROR/WARNING 级别调用时,
1629
+ 自动捕获异常调用栈存入 text, 人工信息并入 label (调用方无需手动拼接 {ex} 或 format_exc)。
1630
+
1631
+ Level 说明:
1632
+ - LOG: 仅写入 _messages + stderr(内部流程记录, 不展示到 LogPanel)
1633
+ - INFO: 写入 _messages + stderr,GUI 模式下展示到 LogPanel
1634
+ - WARNING/ERROR/SUCCESS: 同上, 额外在 LogPanel 高亮
1635
+ - ASK: 展示并等待用户确认,CLI 模式自动同意
1636
+
1637
+ 参数:
1638
+ label: 修饰/数据项名(发生在哪, 这是什么), 缺省空串, 语义上位于 text 前。
1639
+ text: 消息文本(结果/值)。
1640
+ level: 日志级别。
1641
+ capture_exc_info: True 时在 except 块中自动追加异常调用栈;
1642
+ False 时禁用此行为, 避免误捕获 stale 异常。
1643
+
1644
+ 注意: 存在调用栈时, 人工信息(label + text)并入 label, text 只承载全部调用栈,
1645
+ 使 out.warnings 等程序化消费处 text 保持一致结构 (栈或结果文本, 不混排)。
1646
+ 有栈仅在 except 块内且 capture_exc_info=True 时出现;
1647
+ INFO/SUCCESS/LOG/ASK 以及 except 块外调用不受影响。
1648
+ '''
1649
+ tb = None
1650
+ if capture_exc_info and level in ('ERROR', 'WARNING'):
1651
+ exc_info = sys.exc_info()
1652
+ if exc_info[0] is not None:
1653
+ tb = traceback.format_exc().rstrip('\n')
1654
+ # 有栈时: 人工信息并入 label, text 只承载全部调用栈 (供程序化消费取一致结构)
1655
+ label = text if not label else f'{label}: {text}'
1656
+ text = tb
1657
+
1658
+ msg = self._record_message(label, text, level)
1659
+ if not self._quiet: # 测试静默开关; 有栈时分两行展示人工信息与调用栈
1660
+ if tb is not None:
1661
+ print(f'[{msg.timestamp}] {level}\t : {label}\n{text}', file=sys.stderr)
1662
+ else:
1663
+ # 无栈时 label 与 text 以 ': ' 分隔展示 (label 缺省空串时只输出 text, 避免多余冒号)
1664
+ display = f'{label}: {text}' if label else text
1665
+ print(f'[{msg.timestamp}] {level}\t : {display}', file=sys.stderr)
1666
+ if level == 'ASK':
1667
+ self._record_message('', 'Auto Accept', 'ASK FEEDBACK')
1668
+ return True
1669
+ return None
1670
+
1671
+ def get_messages(self) -> list:
1672
+ '''
1673
+ 用途: 获取所有累积的消息列表 (转为 LogMessage 对象)。
1674
+ '''
1675
+ return list(self._messages)
1676
+
1677
+ def clear_messages(self):
1678
+ '''
1679
+ 用途: 清空累积的消息列表。
1680
+ '''
1681
+ self._messages.clear()
1682
+
1683
+
1684
+
1685
+ # =========================
1686
+ # 数据容器
1687
+ # =========================
1688
+
1689
+ @dataclass
1690
+ class IvMeanResult:
1691
+ '''
1692
+ 用途: 保存指定尾部点数对应的 IV 均值表。
1693
+
1694
+ 调用: 由 `build_iv_mean_result` 返回, 并缓存在 `IvContext` 使用。
1695
+ '''
1696
+ mean_num: int
1697
+ final_df: "pd.DataFrame | None" = None
1698
+ detail_df: "pd.DataFrame | None" = None
1699
+
1700
+
1701
+ @dataclass
1702
+ class IvContext:
1703
+ '''
1704
+ 用途: IV 管道计算期私有中间态载体 (结果不落此; 结果归 run_iv_pipeline 局部 + IvPipelineOutput)。
1705
+ 说明: 仅存共享工作表 data_df + 轻中间索引用 + 渲染中间态。段级 units / final_df / detail_df /
1706
+ iv_mean / ma_mean 为 run_iv_pipeline 局部变量, 段计算函数直接 append / 就地修改, 不进本对象。
1707
+
1708
+ 调用: 由 `run_iv_pipeline` 创建, 作为 scratch 传给段计算函数与导出/渲染。
1709
+ '''
1710
+ data_df: "pd.DataFrame | None" = None # 共享工作表 (随流程删行, 属中间态)
1711
+ segment_index_list: list = field(default_factory=list) # 均值分段 (懒建)
1712
+ mean_cache: dict = field(default_factory=dict) # mean_num -> IvMeanResult (GUI 换点数时懒重建)
1713
+ MA_index_list: "list | None" = None # MA 段起始索引 (含保留的 warn 段, 驱动 Excel 布局)
1714
+ IsH2Manual: bool = False # H2 手动模式标志
1715
+
1716
+ # 渲染中间态
1717
+ _ma_full_indices: "list | None" = None
1718
+ _iv_export_figure_list: "list | None" = None
1719
+
1720
+
1721
+ @dataclass
1722
+ class IvRunParams:
1723
+ '''
1724
+ 用途: 保存已校验的 IV 运行参数。
1725
+
1726
+ 调用: 由 `create_iv_run_params` 创建。
1727
+ '''
1728
+ cell_area: float
1729
+ is_del_conditioning: bool
1730
+ del_condition_rh: float
1731
+ process_imp: bool
1732
+ output_zmag: bool
1733
+ iv_mean_num: int
1734
+ iv_e_list: list
1735
+ iv_e_match_min_num: int
1736
+ iv_lookup_e_value_list: list
1737
+ iv_lookup_j_value_list: list
1738
+ h2_mean_num: int
1739
+ h2_volt_list: list
1740
+ h2_e_tol: float
1741
+ h2_anode_flow: float
1742
+ h2_cathode_flow: float
1743
+ h2_to_entry: bool
1744
+ ma_mean_num: int
1745
+ ma_volt_list: list
1746
+ ma_match_min_num: int
1747
+ ma_erange_tol: float
1748
+ draw_results: bool
1749
+
1750
+
1751
+ def create_iv_run_params(config: Config, cell_area: float) -> IvRunParams:
1752
+ '''
1753
+ 用途: 根据 Config 和电池面积构建一次 IV 运行参数快照。
1754
+
1755
+ 调用: 由 `run_iv_pipeline` 创建; `gui.py` 和 `cli.py` 也可在需要时调用。
1756
+ 返回: 返回 `IvRunParams`。
1757
+ '''
1758
+ return IvRunParams(
1759
+ cell_area=cell_area,
1760
+ is_del_conditioning=config.is_del_conditioning,
1761
+ del_condition_rh=config.del_condition_rh,
1762
+ process_imp=config.process_imp,
1763
+ output_zmag=config.output_zmag,
1764
+ iv_mean_num=config.iv_mean_num,
1765
+ iv_e_list=list(config.iv_e_list),
1766
+ iv_e_match_min_num=config.iv_e_match_min_num,
1767
+ iv_lookup_e_value_list=list(config.iv_lookup_e_value_list),
1768
+ iv_lookup_j_value_list=list(config.iv_lookup_j_value_list),
1769
+ h2_mean_num=config.h2_mean_num,
1770
+ h2_volt_list=list(config.h2_volt_list),
1771
+ h2_e_tol=config.h2_e_tol,
1772
+ h2_anode_flow=config.h2_anode_flow,
1773
+ h2_cathode_flow=config.h2_cathode_flow,
1774
+ h2_to_entry=config.h2_to_entry,
1775
+ ma_mean_num=config.ma_mean_num,
1776
+ ma_volt_list=list(config.ma_volt_list),
1777
+ ma_match_min_num=config.ma_match_min_num,
1778
+ ma_erange_tol=config.ma_erange_tol,
1779
+ draw_results=config.draw_results,
1780
+ )
1781
+
1782
+
1783
+ @dataclass
1784
+ class H2CrossoverData:
1785
+ """
1786
+ 用途: 保存 H2 crossover 纯数据识别结果 (过滤后的数据, 有效循环)。
1787
+
1788
+ 调用: 由调用方直接构造, 并由 `build_h2_crossover_data` 返回。
1789
+ """
1790
+ gas_df: Any = None
1791
+ valid_cycles: list = field(default_factory=list)
1792
+
1793
+
1794
+ class InteractivePeakSelector:
1795
+ '''
1796
+ 用途: 跟踪 ECSA 交互图中的选点索引。
1797
+
1798
+ 调用: 由 `Application` 的 CO/HUPD 点击处理器使用。
1799
+ '''
1800
+ def __init__(self, index_finder, redraw_axis):
1801
+ self.index_finder = index_finder
1802
+ self.redraw_axis_callback = redraw_axis
1803
+ self.point_indices = {}
1804
+
1805
+ def select_point(self, x, y, ax_id, key=None):
1806
+ '''
1807
+ 用途: 根据鼠标坐标查找最近数据点索引, 记录到选点字典并重绘坐标轴。
1808
+
1809
+ 调用: 由 ECSA 点击回调 (如 `CoPanelController._on_click`,`HupdPanelController._on_click`) 通过 selector 调用。
1810
+ 返回: 返回数据点索引。
1811
+ '''
1812
+ key = ax_id if key is None else key
1813
+ index = int(self.index_finder(x, y, ax_id))
1814
+ self.point_indices[key] = index
1815
+ self.redraw_axis(ax_id)
1816
+ return index
1817
+
1818
+ def redraw_axis(self, ax_id):
1819
+ '''
1820
+ 用途: 通知重绘回调, 用当前选点状态刷新指定坐标轴。
1821
+
1822
+ 调用: 由 `select_point` 调用。
1823
+ '''
1824
+ self.redraw_axis_callback(ax_id, self.point_indices)
1825
+
1826
+ def get_point(self, key):
1827
+ '''
1828
+ 用途: 获取已选点的索引。
1829
+
1830
+ 调用: 由 CO/HUPD 交互流程读取。
1831
+ 返回: 返回数据点索引或 None。
1832
+ '''
1833
+ return self.point_indices.get(key)
1834
+
1835
+ def set_point(self, key, index):
1836
+ '''
1837
+ 用途: 设置指定键的选点索引 (用于自动选点结果回填)。
1838
+
1839
+ 调用: 由 CO/HUPD 自动选点后回填到 selector 调用。
1840
+ '''
1841
+ self.point_indices[key] = index
1842
+
1843
+ def has_all_points(self, *keys):
1844
+ '''
1845
+ 用途: 判断所有指定键的选点是否都已设置。
1846
+
1847
+ 调用: 由 CO 交互流程判断两个端点是否都已选择。
1848
+ 返回: 全部已设置时返回 True。
1849
+ '''
1850
+ return all(self.get_point(key) is not None for key in keys)
1851
+
1852
+
1853
+ @dataclass
1854
+ class CoEcsaResult:
1855
+ '''
1856
+ 用途: 保存 CO ECSA 数值结果, 端点选点, 面积和绘图曲线 (读取面唯一)。
1857
+
1858
+ 对象类型本身隐含 mode (CO), 无需 mode 字段。类型化标量即为权威读取面;
1859
+ CO 的绘图原始曲线以具名数组字段承载。交互选点用的左右端点索引同样在此。
1860
+
1861
+ 调用: 由 `calculate_co_ecsa_result` 返回。
1862
+ '''
1863
+ baseline_ecsa: 'float | None' = None # 扣基线背景 ECSA (m²/g_Pt)
1864
+ linear_ecsa: 'float | None' = None # 扣直线背景 ECSA (m²/g_Pt)
1865
+ left_E: 'float | None' = None # 左端点电压 V
1866
+ right_E: 'float | None' = None # 右端点电压 V
1867
+ baseline_area: 'float | None' = None # 基线峰面积 A·V
1868
+ linear_area: 'float | None' = None # 直线峰面积 A·V
1869
+ # 交互选点端点索引 (GUI 回填 selector 用)
1870
+ left_index: 'int | None' = None # 左端点索引
1871
+ right_index: 'int | None' = None # 右端点索引
1872
+ # 绘图原始数据 (第三方读取原始曲线用)
1873
+ ecsa_x: 'np.ndarray | None' = None # 电位 (n,)
1874
+ ecsa_y: 'np.ndarray | None' = None # 电流 (n,)
1875
+ baseline: 'np.ndarray | None' = None # 基线 y (n,)
1876
+
1877
+
1878
+ @dataclass
1879
+ class HupdEcsaResult:
1880
+ '''
1881
+ 用途: 保存 HUPD ECSA 数值结果, 基线点选点, 面积和绘图曲线 (每 cycle 一对象)。
1882
+
1883
+ 对象类型本身隐含 mode (HUPD), 无需 mode 字段。类型化标量即为权威读取面;
1884
+ 积分区曲线以具名数组字段承载。
1885
+
1886
+ 调用: 由 `calculate_hupd_ecsa_result` 返回。
1887
+ '''
1888
+ ecsa: 'float | None' = None # ECSA (m²/g_Pt)
1889
+ area: 'float | None' = None # 峰面积 A·V
1890
+ point_E: 'float | None' = None # 基线点电压 V
1891
+ point_end: 'int | None' = None # 基线终点索引 (GUI 回填/绘图用)
1892
+ direction: str = HUPD_DIRECTION_TUPLE[0] # 基线方向 ('baseline above'/'baseline below', 回显实际解析方向)
1893
+ # 绘图原始数据 (第三方读取原始曲线用)
1894
+ area_curve: 'np.ndarray | None' = None # 积分区 [E, I] 二维数组, shape (n,2), 列 0=E(V)/列 1=I(A)
1895
+ baseline: 'np.ndarray | None' = None # 基线 y 一维数组, shape (n,), 与 area_curve 行数相同
1896
+
1897
+
1898
+ @dataclass
1899
+ class CoEcsaSession:
1900
+ '''
1901
+ 用途: 保存 CO ECSA 交互图的运行状态。
1902
+
1903
+ 调用: 由 `Application` 的 CO 计算创建, 并由 CO 点击/重绘流程读取。
1904
+ '''
1905
+ fig: Any
1906
+ ax: Any
1907
+ e: Any
1908
+ i: Any
1909
+ baseline: Any
1910
+ selector: InteractivePeakSelector
1911
+ left_index: int | None = None
1912
+ right_index: int | None = None
1913
+ result: "CoEcsaResult | None" = None
1914
+
1915
+ def has_complete_selection(self):
1916
+ '''
1917
+ 用途: 判断 CO ECSA 左右端点是否都已选择。
1918
+
1919
+ 调用: 由 CO 交互流程调用。
1920
+ 返回: 两端点均已选择时返回 True。
1921
+ '''
1922
+ return self.left_index is not None and self.right_index is not None
1923
+
1924
+
1925
+ @dataclass
1926
+ class HupdEcsaSession:
1927
+ '''
1928
+ 用途: 保存 HUPD ECSA 多子图交互的运行状态。
1929
+
1930
+ 调用: 由 `Application` 的 HUPD 计算创建, 并由 HUPD 点击/重绘流程读取。
1931
+ '''
1932
+ fig: Any
1933
+ axes: list
1934
+ segments: list
1935
+ selector: InteractivePeakSelector
1936
+ point_indices: list = field(default_factory=list)
1937
+ ecsa_values: list = field(default_factory=list)
1938
+ results: list = field(default_factory=list)
1939
+
1940
+ def __post_init__(self):
1941
+ '''
1942
+ 用途: dataclass 初始化后为每个循环创建点索引,ECSA 值, 结果占位列表。
1943
+
1944
+ 调用: dataclass 实例化时自动调用。
1945
+ '''
1946
+ cycle_count = len(self.segments)
1947
+ if not self.point_indices:
1948
+ self.point_indices = [None] * cycle_count
1949
+ if not self.ecsa_values:
1950
+ self.ecsa_values = [None] * cycle_count
1951
+ if not self.results:
1952
+ self.results = [None] * cycle_count
1953
+
1954
+ def update_point(self, ax_id, selected_index):
1955
+ '''
1956
+ 用途: 更新指定子图的选点索引。
1957
+
1958
+ 调用: 由 HUPD 点击回调调用。
1959
+ 返回: 返回选点索引。
1960
+ '''
1961
+ updated = list(self.point_indices)
1962
+ updated[ax_id] = selected_index
1963
+ self.point_indices = updated
1964
+ return selected_index
1965
+
1966
+
1967
+ @dataclass
1968
+ class LinearFitResult:
1969
+ '''
1970
+ 用途: 保存线性回归的纯数据结果。
1971
+
1972
+ 调用: 由 `safe_linear_fit` 返回, 并由 `draw_linear_fit` 使用。
1973
+ '''
1974
+ slope: float
1975
+ intercept: float
1976
+ r: float
1977
+ x: 'np.ndarray' # 拟合自变量原始点, numpy (n,) (物理量/单位随调用场景)
1978
+ y: 'np.ndarray' # 拟合因变量原始点, numpy (n,)
1979
+ y_fit: 'np.ndarray' # 线性拟合值, numpy (n,) (= slope*x + intercept)
1980
+
1981
+
1982
+ @dataclass
1983
+ class OxygenResult:
1984
+ '''
1985
+ 用途: 保存 O2 处理表格和图对象引用。
1986
+
1987
+ 调用: 由 `Application` 的 O2 工作流持有。
1988
+ '''
1989
+ all_LC_df: "pd.DataFrame | None" = None
1990
+ xi_df: "pd.DataFrame | None" = None
1991
+ result_df: "pd.DataFrame | None" = None
1992
+ fig_curves: Any = None # matplotlib Figure 句柄 (曲线网格图, 左, p_num×x_num 子图; 非数据)
1993
+ fig_ix: Any = None # matplotlib Figure 句柄 (I-xO2 拟合图; 非数据)
1994
+ fig_rp: Any = None # matplotlib Figure 句柄 (Rtotal-Pressure 拟合图; 非数据)
1995
+ records: list = field(default_factory=list) # list[OxygenCurveRecord], 供管线外绘图使用 (含各曲线限流点)
1996
+
1997
+
1998
+ @dataclass
1999
+ class OxygenCurveRecord:
2000
+ '''
2001
+ 用途: 保存一条 O2 曲线, 限流点和 Excel 行数据。
2002
+
2003
+ 调用: 由 computations 的 `build_oxygen_curve_result_from_groups` 构建, 绘图时供 `draw_oxygen_curve` 读取。
2004
+ '''
2005
+ name: str
2006
+ pressure: float
2007
+ x_o2: float
2008
+ vm: float
2009
+ im: float
2010
+ jm: float
2011
+ data_df: "pd.DataFrame"
2012
+
2013
+ def to_excel_row(self):
2014
+ '''
2015
+ 用途: 将限流点数据转为 Excel 行列表。
2016
+
2017
+ 调用: 由 O2 曲线构建流程和 `build_oxygen_curve_record` 的调用者使用。
2018
+ 返回: 返回列表, 可用于写入 Excel 行。
2019
+ '''
2020
+ return [self.name, self.pressure, self.x_o2, self.vm, self.im, self.jm, 0.0]
2021
+
2022
+
2023
+ @dataclass
2024
+ class OxygenFitRecord:
2025
+ '''
2026
+ 用途: 保存一组 O2 阻力拟合的原始点和线性拟合结果。
2027
+
2028
+ 调用: 由 `calculate_oxygen_resistance_result` 返回, 并由 `draw_oxygen_resistance_summary` 使用。
2029
+ '''
2030
+ label: str
2031
+ x: 'list[float]' # 拟合自变量原始点 (current_fits 为各曲线 O2 摩尔分数, pressure_fit 为压力 bar)
2032
+ y: 'list[float]' # 拟合因变量原始点 (current_fits 为极限电流密度 jm mA/cm², pressure_fit 为 R_total)
2033
+ fit: LinearFitResult
2034
+ x_min: float | None = None
2035
+
2036
+
2037
+ @dataclass
2038
+ class OxygenResistanceResult:
2039
+ '''
2040
+ 用途: 保存 O2 阻力纯计算结果和绘图所需拟合记录。
2041
+
2042
+ 调用: 由 `calculate_oxygen_resistance_result` 返回, 并回填到 `OxygenResult`。
2043
+ '''
2044
+ xi_df: "pd.DataFrame"
2045
+ result_df: "pd.DataFrame"
2046
+ current_fits: 'list[OxygenFitRecord]' = field(default_factory=list) # 各压力组 I-xO2 拟合 (x=O2 摩尔分数, y=极限电流密度 jm)
2047
+ pressure_fit: 'OxygenFitRecord | None' = None # Rtotal-Pressure 的最终拟合 (x=压力 bar, y=R_total), 斜率经修正得 R_PI
2048
+ j_fit_by_pressure: 'dict[float, np.ndarray]' = field(default_factory=dict) # {压力(bar): 拟合 y 值 numpy}, 各压力组 I-xO2 拟合值
2049
+
2050
+
2051
+ @dataclass
2052
+ class FigureBundle:
2053
+ '''
2054
+ 用途: 统一保存 figure, 坐标轴列表和可选计算结果。
2055
+
2056
+ 调用: 由通用 figure 构建 helper 返回, 供绘图/导出端消费。
2057
+ '''
2058
+ fig: Any
2059
+ axes: list = field(default_factory=list)
2060
+ result: Any = None
2061
+ title: str = ''
2062
+ export_name: str = ''
2063
+ color_cycle: Any = None
2064
+ twin_ax: Any = None
2065
+
2066
+ @property
2067
+ def ax(self):
2068
+ return self.axes[0] if self.axes else None
2069
+
2070
+
2071
+ @dataclass
2072
+ class ExportFigure:
2073
+ '''
2074
+ 用途: 描述一张需要插入 Excel 的 matplotlib 图。
2075
+
2076
+ 调用: 由 Excel 图片导出辅助函数创建并传给 `insert_export_figures`。
2077
+ '''
2078
+ fig: Any
2079
+ sheet_name: str | None
2080
+ cell: str
2081
+ dpi: int | None = None
2082
+ close: bool = False
2083
+
2084
+
2085
+
2086
+
2087
+ # =========================
2088
+ # per-file 结论块与单元类
2089
+ # =========================
2090
+
2091
+ @dataclass
2092
+ class FileConclusion:
2093
+ '''
2094
+ 用途: 单文件管线 (IV/CO/HUPD) 的顶层结论, 以 `conclusion: FileConclusion` 实例持有一个。
2095
+ 其内字段不另行在 Output 顶层重复。DTA 无需仪器号/不含 time。
2096
+
2097
+ cell_temp/rh 供 ECSA (CO/HUPD, FCD) 文件级使用; IV 的温度/RH 走 unit 级 (此处可空)。
2098
+ loading/cell_area 来源: config.pt_loading 与 config.cell_area。
2099
+ date 来源: DTA 表头 DATE / FCD 表头 `Time:` 行。
2100
+ '''
2101
+ file_name: str
2102
+ date: 'str | None' = None # DTA 表头 DATE / FCD 表头 Time 行日期
2103
+ loading: 'float | None' = None # 载量 mg/cm² (来源 config.pt_loading)
2104
+ cell_area: 'float | None' = None # 面积 cm² (config; DTA 面积不可用)
2105
+ cell_temp: 'float | None' = None # 电池温度 °C (ECSA 文件级, 取设定温度中位数; IV 走 unit 级可空)
2106
+ rh: 'float | None' = None # 相对湿度 % (ECSA 文件级; IV 走 unit 级可空)
2107
+ fcd_type: str = 'IV' # FCD 文件类型 'IV'/'ECSA' (来源 FCD 头部, 见 FcdInfo.fcd_type)
2108
+ unit_sn: 'str | None' = None # 仪器序列号 (来源 FCD 头部 UnitSN 解析, 见 FcdInfo.unit_sn)
2109
+ device_name: 'str | None' = None # 测试设备名称 (文件预读阶段按 unit_sn 匹配 device_map; 匹配失败为 None)
2110
+ raw_file_path: 'str | None' = None # FCD 头部 Data File 字段读取的原始文件路径 (含目录; 文件内记录值)
2111
+
2112
+
2113
+ @dataclass
2114
+ class TafelFit:
2115
+ '''MA 单个 Tafel 拟合结果 (对应 plot_titles 一个标题)。'''
2116
+ slope: 'float | None' = None
2117
+ intercept: 'float | None' = None
2118
+ r: 'float | None' = None
2119
+ j_at_09: 'float | None' = None
2120
+
2121
+
2122
+ @dataclass
2123
+ class TestCondition:
2124
+ '''
2125
+ 用途: IV 每 unit 的测试条件集 (随单元; 每行 IV 背压可不同)。
2126
+ 来源: cell_temp 取该段设定温度 (TempCell setpoint) 中位数; rh 该段;
2127
+ back_pressure 默认 150 kPa, 否则文件名正则 (数字+bar/kPa, 反引号规避转义)
2128
+ (re.IGNORECASE, 容忍空格/小数), bar×100→kPa。
2129
+
2130
+ 注意: 背压为文件名估算, 同文件各 unit 取同一解析值, 实况可能不同, 留待人工校正。
2131
+ '''
2132
+ cell_temp: 'float | None' = None # 电池温度 °C (设定温度中位数)
2133
+ rh: 'float | None' = None # 相对湿度 %
2134
+ back_pressure_kpa: 'float | None' = 150.0 # 背压 kPa; 默认 150; 文件名正则取 (估算)
2135
+
2136
+
2137
+ @dataclass
2138
+ class EisConclusion:
2139
+ '''
2140
+ 用途: EIS 每文件专属结论 (文件级一次; EIS 每文件条件不同)。
2141
+ loading/cell_area 运行级, 放顶层, 不在此重复。
2142
+ '''
2143
+ file_name: str
2144
+ date: 'str | None' = None # DTA 表头 DATE
2145
+ rh: 'float | None' = None # 文件名 %RH
2146
+ cell_voltage: 'float | None' = None # 以文件解析为准则 (DTA `VDC` 表头), 文件名可作后备
2147
+
2148
+
2149
+ @dataclass
2150
+ class IvUnit:
2151
+ '''每个 RH 极化曲线段 (段算当口直存语义数据)。'''
2152
+ identity: str # 段身份 (如 'RH=65%')
2153
+ status: str = ITEM_OK # 段级: ok / warn (匹配但数据无效)
2154
+ condition: 'TestCondition | None' = None # 本段测试条件 (temp/RH/背压, 实时)
2155
+ # J@E / E@J 查找 (语义 dict: 目标值 -> 插值结果, 越界目标记 None 仍保留在 dict)
2156
+ j_at_e: 'dict[float, float | None]' = field(default_factory=dict) # {电压(V): 电流(mA/cm²)}, 越界为 None
2157
+ e_at_j: 'dict[float, float | None]' = field(default_factory=dict) # {电流密度(mA/cm²): 电压(V)}, 越界为 None
2158
+ matched_idx: 'np.ndarray | None' = None # 本段匹配索引 (Summary 重建 + 无效段判定用)
2159
+
2160
+
2161
+ @dataclass
2162
+ class ImpUnit:
2163
+ '''每条阻抗谱段 (单独一类; 段算当口直存语义数据)。'''
2164
+ identity: str # 段身份
2165
+ status: str = ITEM_OK # 段级: ok (未成谱的候选段不建 unit)
2166
+ condition: 'TestCondition | None' = None # 本段测试条件 (temp/RH/背压, 实时)
2167
+ hfr: 'float | None' = None # HFR (mOhm), 外推或置 0; 不在 imp_df, 必须单存
2168
+ start_time: 'float | None' = None # 段首行 Time
2169
+ end_time: 'float | None' = None # 段末行 Time
2170
+ seg_i: 'float | None' = None # 段首行 I (A)
2171
+ seg_e: 'float | None' = None # 段首行 E (V)
2172
+ seg_gas: Any = None # 段首行 Multigas_Cathode
2173
+ seg_temp_ca: 'float | None' = None # 段首行 TempCa
2174
+ imp_df: 'pd.DataFrame | None' = None # 阻抗谱 (真实数据, DataFrame)
2175
+
2176
+
2177
+ @dataclass
2178
+ class MaUnit:
2179
+ '''每个 MA 点 (段算当口直存语义数据, 公式/摘要表由导出端组装)。'''
2180
+ identity: str # 'MA@xV'
2181
+ status: str = ITEM_OK # 段级: ok / warn (匹配但数据无效)
2182
+ condition: 'TestCondition | None' = None # 本 MA 测试条件 (temp/RH/背压, 实时)
2183
+ ma_df: 'pd.DataFrame | None' = None # MA 曲线基础表 (向量化计算, DataFrame)
2184
+ tafel: 'dict[str, TafelFit]' = field(default_factory=dict) # {title: TafelFit}, title ∈ plot_titles
2185
+ mass_activity: 'float | None' = None # 质量活性 = max(J@0.9V)/1000/loading (A/mg Pt)
2186
+ ma_j_at_09: 'float | None' = None # 取用的 J@0.9V 原始值 (mA/cm², 各数据列最大值)
2187
+ ma_j_at_09_source: 'str | None' = None # 该最大值的来源列名 ('original'/'H2 crossover free')
2188
+
2189
+
2190
+ @dataclass
2191
+ class H2Unit:
2192
+ '''H2 crossover 单元 (段算当口直存语义数据, 每周期自持)。'''
2193
+ identity: str = 'H2' # H2 crossover
2194
+ status: str = ITEM_OK # 段级: ok / skip (手动 override)
2195
+ condition: 'TestCondition | None' = None # 本 H2 段测试条件 (temp/RH/背压, 实时)
2196
+ i_h2: 'float | None' = None # H2_lin_df['i_x,H2'] (截距)
2197
+ r_short: 'float | None' = None # H2_lin_df['1/R_short'] (斜率)
2198
+ r: 'float | None' = None # H2_lin_df['r'] (相关系数)
2199
+ mean_current: 'dict[float, float] | None' = None # {E(V): 本周期均值电流(mA/cm²)}, 每周期自持
2200
+ mean_points: 'list[tuple[float, float, float]] | None' = None # 本周期各电压平台尾段均值点 [(T, E, I), ...], 供 per-cycle I-t 打点
2201
+ trace: 'pd.DataFrame | None' = None # 本周期完整原始时序 (Time/I...), 自持, 供 per-cycle I-t 图
2202
+ cycle_idx: 'int | None' = None # 周期序 (导出重组矩阵/排序用)
2203
+
2204
+
2205
+ # =========================
2206
+ # 引擎管道输入 / 输出数据容器
2207
+ # =========================
2208
+
2209
+ @dataclass
2210
+ class LogMessage:
2211
+ """
2212
+ 用途: 记录单条引擎管道日志消息 (级别, 文本, 时间戳, 修饰词/数据项名)。
2213
+
2214
+ 调用: 由 `RuntimeServices.show` 创建, 并被管道输出对象,Excel 日志写入使用。
2215
+ """
2216
+ level: str
2217
+ text: str
2218
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
2219
+ label: str = ''
2220
+
2221
+
2222
+ @dataclass
2223
+ class IvPipelineInput:
2224
+ """
2225
+ 用途: 描述 IV/H2/MA 管道的完整输入参数 (文件路径, 电池面积, 导出选项, H2 手动覆盖)。
2226
+
2227
+ 调用: 由 `cli.py run()` 和 `IvPanelController.on_calc` 构造, 传给 `run_iv_pipeline`。
2228
+ """
2229
+ fcd_file_path: Path
2230
+ cell_area: float = 5.0
2231
+ output_path: Path | None = None
2232
+ h2_manual: bool = False
2233
+ h2_slope: float | None = None
2234
+ h2_intercept: float | None = None
2235
+ draw_results: bool = True
2236
+ export_excel: bool = True
2237
+ # 预读数据字段: 入口层一次性读入后传入, 管线不再自己读文件
2238
+ data_df: 'pd.DataFrame | None' = None
2239
+ fcd_info: 'FcdInfo | None' = None
2240
+
2241
+ def validate(self) -> None:
2242
+ """检查入口层是否已传入预读数据。"""
2243
+ if self.data_df is None:
2244
+ raise ValueError(
2245
+ "IvPipelineInput.data_df 为 None -- "
2246
+ "入口层必须调用 read_fcd_file() 传入预解析数据"
2247
+ )
2248
+
2249
+
2250
+ @dataclass
2251
+ class IvPipelineOutput:
2252
+ """
2253
+ 用途: 保存 IV/H2/MA 管道的完整输出 (结论块, 共享表, 单元结果列表)。
2254
+
2255
+ 调用: 由 `run_iv_pipeline` 构造返回。四 unit 列表由段计算函数**当口直存**, 此处直接引用, 无投影步。
2256
+ """
2257
+ # ① 结论 (文件级一次)
2258
+ conclusion: 'FileConclusion | None' = None
2259
+ # ② 公开共享表
2260
+ data_df: 'pd.DataFrame | None' = None # 原始数据 (文件级, 共享工作表, 供 Excel 原始数据 sheet)
2261
+ final_df: 'pd.DataFrame | None' = None # 最终均值表 (文件级, IV 均值汇总)
2262
+ detail_df: 'pd.DataFrame | None' = None # 明细表 (文件级)
2263
+ iv_mean: 'IvMeanResult | None' = None
2264
+ ma_mean: 'IvMeanResult | None' = None
2265
+ # ④ 单元结果列表 (一等类; 直存)
2266
+ iv_units: 'list[IvUnit]' = field(default_factory=list)
2267
+ imp_units: 'list[ImpUnit]' = field(default_factory=list) # 阻抗谱段
2268
+ ma_units: 'list[MaUnit]' = field(default_factory=list)
2269
+ h2_units: 'list[H2Unit]' = field(default_factory=list) # H2 crossover 单元
2270
+ # ③ 绘图 (GUI/导出; 是绘图句柄, 非数据, 第三方读取数据请忽略)
2271
+ iv_overview_bundle: 'FigureBundle | None' = None
2272
+ # ⑥ 导出公用
2273
+ output_path: Path | None = None
2274
+ outcome: IvOutcome = field(default_factory=IvOutcome) # 各步骤成败状态
2275
+ warnings: 'list[LogMessage]' = field(default_factory=list) # WARNING/ERROR 级结构化日志 (level/timestamp/label/text), return 前由引擎注入
2276
+
2277
+ @property
2278
+ def MA_num(self) -> int:
2279
+ '''MA 单元数 (含保留的 warn 段, 驱动 Excel 布局)。'''
2280
+ return len(self.ma_units)
2281
+
2282
+ @property
2283
+ def H2_num(self) -> int:
2284
+ return len(self.h2_units)
2285
+
2286
+ @property
2287
+ def total(self) -> str:
2288
+ '''薄派生: 从 outcome 各字段现算最高级 (fail>warn>skip>ok)。'''
2289
+ o = self.outcome
2290
+ return _max_status([o.conditioning, o.figure, o.excel, o.cell_area, o.back_pressure,
2291
+ o.iv_segments, o.imp_segments, o.ma_segments, o.h2_segments])
2292
+
2293
+
2294
+ @dataclass
2295
+ class EcsaPipelineInput:
2296
+ """
2297
+ 用途: 描述 ECSA (CO/HUPD) 管道的完整输入参数 (文件, 模式,Pt 用量, 选点)。
2298
+
2299
+ 调用: 由 `cli.py run()` 和 `CoPanelController.on_calc` / `HupdPanelController.on_calc` 构造。
2300
+ """
2301
+ fcd_file_path: Path
2302
+ mode: str = "Auto"
2303
+ # 文件真实模式 'CO'/'HUPD' (入口层从检测结果回填, 与 mode 解耦: mode 表示
2304
+ # 当前提交计算的模式, detected_mode 表示文件实际属于的模式)。守卫据此防
2305
+ # processing_mode 强制覆盖时静默按错误模式计算。
2306
+ detected_mode: "str | None" = None
2307
+ pt_usage: float | None = None
2308
+ cell_area: float = 5.0
2309
+ output_path: Path | None = None
2310
+ co_left_index: int | None = None
2311
+ co_right_index: int | None = None
2312
+ hupd_point_end: int | None = None
2313
+ hupd_direction: str | None = None
2314
+ auto_select_points: bool = True
2315
+ export_excel: bool = True
2316
+ # 预读数据字段: 入口层一次性读入后传入, 管线不再自己读文件
2317
+ data_df: 'pd.DataFrame | None' = None
2318
+ raw_data_df: 'pd.DataFrame | None' = None
2319
+ fcd_info: 'FcdInfo | None' = None
2320
+
2321
+ def validate(self) -> None:
2322
+ """检查入口层是否已传入预读数据。"""
2323
+ if self.data_df is None:
2324
+ raise ValueError(
2325
+ "EcsaPipelineInput.data_df 为 None -- "
2326
+ "入口层必须提前调用 read_fcd_file()"
2327
+ )
2328
+ if self.fcd_info is None:
2329
+ raise ValueError(
2330
+ "EcsaPipelineInput.fcd_info 为 None -- "
2331
+ "入口层必须提供 FCD 文件信息"
2332
+ )
2333
+
2334
+
2335
+ @dataclass
2336
+ class CoEcsaPipelineOutput:
2337
+ """
2338
+ 用途: 保存 CO ECSA 管道输出 (结论, result 权威读取面, 插值数据, 导出路径)。
2339
+
2340
+ 调用: 由 `run_co_ecsa_pipeline` 返回。
2341
+ """
2342
+ # ① 结论 (文件级一次; 经 FileConclusion 单一持有, 不与他字段重复)
2343
+ conclusion: 'FileConclusion | None' = None
2344
+ result: 'CoEcsaResult | None' = None # 唯一权威读取面: 类型化标量 + 绘图曲线
2345
+ e_data: np.ndarray | None = None
2346
+ i_data: np.ndarray | None = None
2347
+ baseline_data: np.ndarray | None = None
2348
+ output_path: Path | None = None
2349
+ outcome: CoEcsaOutcome = field(default_factory=CoEcsaOutcome) # 各步骤成败状态
2350
+ warnings: 'list[LogMessage]' = field(default_factory=list) # WARNING/ERROR 级结构化日志
2351
+ # GUI 使用: 用于创建交互图和导出
2352
+ segments: 'list[pd.DataFrame] | None' = None # 原始分段数据 (每段一张 E/I 表)
2353
+ calc_segments: 'list[pd.DataFrame] | None' = None # 可用计算片段 (剔除无效段后的 E/I 表)
2354
+ scan_rate: float | None = None # 扫速
2355
+ data_df: 'pd.DataFrame | None' = None # 完整 E/I 数据 (列 E/V, I/A)
2356
+ file_mode: str | None = None # 文件实际模式 'CO'/'HUPD'/'Auto'
2357
+
2358
+ @property
2359
+ def total(self) -> str:
2360
+ '''薄派生: 从 outcome 各字段现算最高级 (fail>warn>skip>ok)。'''
2361
+ o = self.outcome
2362
+ return _max_status([o.segment_parse, o.data_prep, o.point_select, o.co_ecsa, o.excel])
2363
+
2364
+
2365
+ @dataclass
2366
+ class HupdEcsaPipelineOutput:
2367
+ """
2368
+ 用途: 保存 HUPD ECSA 管道输出 (结论, 多循环结果, 平均值, 导出路径)。
2369
+
2370
+ 调用: 由 `run_hupd_ecsa_pipeline` 返回。
2371
+ """
2372
+ # ① 结论 (文件级一次; 各 cycle 条件相同不重复, 只数据不同)
2373
+ conclusion: 'FileConclusion | None' = None
2374
+ results: 'list[HupdEcsaResult | None]' = field(default_factory=list) # 每 cycle: .ecsa/.area/.point_E/.point_end 类型化
2375
+ averages: "tuple[float | None, float | None] | None" = None # 两元组 (mean_without_first=排除 plot 1 的均值, mean_all=全部 cycle 均值) m²/g_Pt; 两值均 None 表示无法计算
2376
+ output_path: Path | None = None
2377
+ outcome: HupdEcsaOutcome = field(default_factory=HupdEcsaOutcome) # 各步骤成败状态
2378
+ warnings: 'list[LogMessage]' = field(default_factory=list) # WARNING/ERROR 级结构化日志
2379
+ # GUI 使用: 用于创建交互图和导出
2380
+ segments: 'list[pd.DataFrame] | None' = None # 原始分段数据 (每段一张 E/I 表)
2381
+ calc_segments: 'list[pd.DataFrame] | None' = None # 可用计算片段 (剔除无效段后的 E/I 表)
2382
+ scan_rate: float | None = None # 扫速
2383
+ data_df: 'pd.DataFrame | None' = None # 完整 E/I 数据 (列 E/V, I/A)
2384
+ file_mode: str | None = None # 文件实际模式 'CO'/'HUPD'/'Auto'
2385
+
2386
+ @property
2387
+ def total(self) -> str:
2388
+ '''薄派生: 从 outcome 各字段现算最高级 (fail>warn>skip>ok)。'''
2389
+ o = self.outcome
2390
+ return _max_status([o.segment_parse, o.point_select, o.hupd_calc, o.excel])
2391
+
2392
+
2393
+ @dataclass
2394
+ class OxygenPipelineInput:
2395
+ """
2396
+ 用途: 描述 O2 管道的完整输入参数 (DTA 文件列表, 电池面积, 导出选项)。
2397
+
2398
+ 调用: 由 `cli.py run()` 和 `O2PanelController.on_calc` 构造, 传给 `run_oxygen_pipeline`。
2399
+ """
2400
+ dta_file_paths: list[Path]
2401
+ cell_area: float = 5.0
2402
+ output_path: Path | None = None
2403
+ draw_results: bool = True
2404
+ export_excel: bool = True
2405
+ # 预读数据字段: 入口层一次性读入后传入, 管线不再自己读文件
2406
+ dta_data_list: 'list[pd.DataFrame] | None' = None
2407
+ dta_file_conditions_list: 'list[tuple[float, float] | None] | None' = None
2408
+ dta_file_dates_list: 'list[str | None] | None' = None # 每文件 DTA 表头 DATE, 用于顶层 date
2409
+
2410
+ def validate(self) -> None:
2411
+ """检查入口层是否已传入预读数据和文件条件。"""
2412
+ if self.dta_data_list is None:
2413
+ raise ValueError(
2414
+ "OxygenPipelineInput.dta_data_list 为 None -- "
2415
+ "入口层必须调用 read_oxygen_curve_data() 传入预读数据"
2416
+ )
2417
+ if self.dta_file_conditions_list is None:
2418
+ raise ValueError(
2419
+ "OxygenPipelineInput.dta_file_conditions_list 为 None -- "
2420
+ "入口层必须调用 parse_oxygen_file_conditions() 传入条件"
2421
+ )
2422
+
2423
+
2424
+ @dataclass
2425
+ class O2InputFile:
2426
+ '''
2427
+ 用途: 保存 O2 管线的一个输入文件身份信息 (路径, 压力, O₂ 浓度, 日期)。
2428
+
2429
+ 调用: 由 `run_oxygen_pipeline` 从输入侧回填到 `OxygenPipelineOutput.input_files`。
2430
+ '''
2431
+ file_path: Path
2432
+ pressure: 'float | None' = None # 压力 (bar), 来自 DTA 文件名/条件解析, 缺失留 None
2433
+ x_o2: 'float | None' = None # O₂ 浓度 (%), = 分数分量×100, 缺失留 None
2434
+ date: 'str | None' = None # DTA 表头 DATE, 缺失留 None
2435
+
2436
+
2437
+ @dataclass
2438
+ class OxygenPipelineOutput:
2439
+ """
2440
+ 用途: 保存 O2 管道输出 (结论, O2 结果, 阻力结果, 导出路径)。
2441
+
2442
+ 调用: 由 `run_oxygen_pipeline` 返回。
2443
+ """
2444
+ # ① 运行级共享 (顶层一次; 每文件身份已在 OxygenCurveRecord, 不加 per-file 结论)
2445
+ loading: 'float | None' = None # 载量 mg/cm² (来源 config.pt_loading)
2446
+ cell_area: 'float | None' = None # 面积 cm² (config)
2447
+ date: 'str | None' = None # 运行级日期 (取首个 DTA DATE; 多文件约定取首文件)
2448
+ input_files: 'list[O2InputFile]' = field(default_factory=list) # 输入文件清单 (路径+条件+日期)
2449
+ oxygen_result: 'OxygenResult | None' = None
2450
+ resistance_result: 'OxygenResistanceResult | None' = None
2451
+ output_path: Path | None = None
2452
+ outcome: OxygenOutcome = field(default_factory=OxygenOutcome) # 各步骤成败状态
2453
+ warnings: 'list[LogMessage]' = field(default_factory=list) # WARNING/ERROR 级结构化日志
2454
+
2455
+ @property
2456
+ def total(self) -> str:
2457
+ '''薄派生: 从 outcome 各字段现算最高级 (fail>warn>skip>ok)。'''
2458
+ o = self.outcome
2459
+ return _max_status([o.file_grouping, o.limit_curve, o.resistance, o.figure, o.excel])
2460
+
2461
+
2462
+ # ---- EIS ----
2463
+ @dataclass
2464
+ class EisFitSegment:
2465
+ """EIS Nyquist linear fit segment."""
2466
+ start: int
2467
+ end: int
2468
+ slope: float
2469
+ intercept: float
2470
+ r: float
2471
+ x: 'np.ndarray' # 拟合段自变量 -Z'' (负虚部, numpy (n,))
2472
+ y: 'np.ndarray' # 拟合段因变量 Z' (实部, numpy (n,))
2473
+ y_fit: 'np.ndarray' # 线性拟合值 numpy (n,)
2474
+ r2: float = 0.0
2475
+ mean_freq: float = 0.0
2476
+
2477
+
2478
+ @dataclass
2479
+ class EisFileResult:
2480
+ """Single EIS file analysis result."""
2481
+ file_path: Path
2482
+ file_label: str = ''
2483
+ freq: 'list[float]' = field(default_factory=list) # 频率 (Hz), numpy .tolist() 转成 Python list, 与 zreal/zimag 等长
2484
+ zreal: 'list[float]' = field(default_factory=list) # 实部阻抗 Z' (mΩ), Python list
2485
+ zimag: 'list[float]' = field(default_factory=list) # 虚部阻抗 Z'' (mΩ, 负值), Python list
2486
+ hf_intercept_interp: 'float | None' = None # Zreal 零点穿越插值截距; 无零点穿越时 None
2487
+ hf_intercept_fit: float = 0.0
2488
+ lf_intercept: 'float | None' = None # 低频段截距; 无有效 LF 段时 None
2489
+ hf_fit_start: int = 0
2490
+ hf_fit_end: int = 0
2491
+ lf_fit_start: int = 0
2492
+ lf_fit_end: int = 0
2493
+ hf_r2: float = 0.0
2494
+ lf_r2: float = 0.0
2495
+ result_value: 'float | None' = None # 3×|LF截距-HF拟合截距|; 无有效 LF 段时 None
2496
+ hf_segment: EisFitSegment | None = None
2497
+ lf_segment: EisFitSegment | None = None
2498
+ conclusion: 'EisConclusion | None' = None # 每文件: file_name/date/rh/cell_voltage
2499
+
2500
+ # 计算: 3 x |lf_intercept - hf_intercept_interp| (混搭拟合截距与内插截距)
2501
+ @property
2502
+ def result_interp(self) -> 'float | None':
2503
+ if self.lf_segment is None or self.lf_intercept is None or self.hf_intercept_interp is None:
2504
+ return None
2505
+ return 3.0 * abs(self.lf_intercept - self.hf_intercept_interp)
2506
+
2507
+
2508
+ @dataclass
2509
+ class EisPipelineInput:
2510
+ """
2511
+ 用途: 描述 EIS 阻抗分析管道的完整输入参数 (单文件)。
2512
+
2513
+ 调用: 由 `cli.py run()` 和 GUI EIS 面板构造, 传给 `run_eis_pipeline`。
2514
+ """
2515
+ dta_file_path: Path
2516
+ output_path: Path | None = None
2517
+ draw_results: bool = True
2518
+ export_excel: bool = True
2519
+
2520
+ # 预读数据 (入口层必须提供)
2521
+ freq: 'np.ndarray | None' = None
2522
+ zreal: 'np.ndarray | None' = None
2523
+ zimag: 'np.ndarray | None' = None
2524
+
2525
+ # per-file 结论元数据 (入口层可提供; 缺失留 None)
2526
+ date: 'str | None' = None # DTA 表头 DATE
2527
+ cell_voltage: 'float | None' = None # DTA 表头 VDC (缺失留 None)
2528
+
2529
+ # 手动拟合段边界 (含入式索引, 全部 4 项提供时跳过自动段搜索)
2530
+ hf_start: int | None = None
2531
+ hf_end: int | None = None
2532
+ lf_start: int | None = None
2533
+ lf_end: int | None = None
2534
+
2535
+ def validate(self) -> None:
2536
+ """检查入口层是否已传入预读的频率/阻抗数据。"""
2537
+ if self.freq is None or self.zreal is None or self.zimag is None:
2538
+ raise ValueError(
2539
+ "EisPipelineInput 预读数据 (freq/zreal/zimag) 为 None -- "
2540
+ "入口层必须调用 read_eis_curve_data() 传入预读数据"
2541
+ )
2542
+
2543
+
2544
+ @dataclass
2545
+ class EisPipelineOutput:
2546
+ """
2547
+ 用途: 保存 EIS 阻抗分析管道输出 (结论, 结果列表)。
2548
+
2549
+ 调用: 由 `run_eis_pipeline` 返回。
2550
+ """
2551
+ # ① 运行级共享 (顶层一次)
2552
+ loading: 'float | None' = None # 载量 mg/cm² (来源 config.pt_loading)
2553
+ cell_area: 'float | None' = None # 面积 cm² (config)
2554
+ output_path: Path | None = None
2555
+ outcome: EisOutcome = field(default_factory=EisOutcome) # 各步骤成败状态
2556
+ warnings: 'list[LogMessage]' = field(default_factory=list) # WARNING/ERROR 级结构化日志
2557
+ result: 'EisFileResult | None' = None # EIS 单文件结果 (含每文件 conclusion); 计算失败时为 None
2558
+
2559
+ @property
2560
+ def total(self) -> str:
2561
+ '''薄派生: 从 outcome 各字段现算最高级 (fail>warn>skip>ok)。'''
2562
+ o = self.outcome
2563
+ return _max_status([o.segment_fit, o.r2, o.excel])
2564
+
2565
+
2566
+ # =========================
2567
+ # FCD / ECSA 文件读取 (纯数据)
2568
+ # =========================
2569
+
2570
+ @dataclass
2571
+ class FcdInfo:
2572
+ """
2573
+ 用途: FCD 文件元数据。
2574
+
2575
+ 调用: 由管道输入类 (`IvPipelineInput`, `EcsaPipelineInput`) 的 `fcd_info` 字段引用。
2576
+ """
2577
+ file_name: "str | None" = None
2578
+ cell_area: "float | None" = None
2579
+ fcd_type: str = 'IV'
2580
+ scan_rate_vps: "float | None" = None
2581
+ unit_sn: "str | None" = None
2582
+ device_name: "str | None" = None # 测试设备名称 (检测层按 unit_sn 匹配 device_map; 匹配失败为 None)
2583
+ date: "str | None" = None # FCD 文件表头 `Time:` 行的日期
2584
+
2585
+
2586
+ @dataclass
2587
+ class FcdFileResult:
2588
+ """
2589
+ 用途: 保存 .fcd 文件读取结果 (DataFrame, 元数据, 电池面积)。
2590
+
2591
+ 所有字段均为文件解析值, 不做合并默认值。cell_area 为 None 时表示
2592
+ 文件中未找到 SurfaceArea 字段, 由调用方自行处理默认值。
2593
+
2594
+ 调用: 由 `read_fcd_file` 返回。使用 `to_fcd_info()` 构造轻量元数据对象供管线输入。
2595
+ """
2596
+ data_df: "pd.DataFrame | None" = None
2597
+ cell_area: "float | None" = None # FCD 文件头部 SurfaceArea 的解析值, 可能为 None
2598
+ file_name: "str | None" = None # FCD 文件头部 Data File 的解析值
2599
+ fcd_type: str = 'IV' # FCD 文件头部 PSTAT Sweep V → 'ECSA', 否则 'IV'
2600
+ scan_rate_vps: "float | None" = None # FCD 文件头部扫速解析值
2601
+ unit_sn: "str | None" = None # FCD 文件头部 UnitSN 解析值
2602
+ date: "str | None" = None # FCD 文件表头 `Time:` 行的日期 (如 '2026年6月14日')
2603
+
2604
+ def to_fcd_info(self) -> 'FcdInfo':
2605
+ """构造轻量元数据 FcdInfo (不含 data_df), 用于管线输入。"""
2606
+ return FcdInfo(
2607
+ file_name=self.file_name,
2608
+ cell_area=self.cell_area,
2609
+ fcd_type=self.fcd_type,
2610
+ scan_rate_vps=self.scan_rate_vps,
2611
+ unit_sn=self.unit_sn,
2612
+ date=self.date,
2613
+ )
2614
+
2615
+
2616
+ @dataclass
2617
+ class FilePreRead:
2618
+ """
2619
+ 用途: 通用文件预读结果, 由 detect_file_mode 返回。
2620
+
2621
+ 覆盖 FCD,DTA-O2,DTA-EIS 三种文件类型。
2622
+ mode 指示处理模式, 调用方根据 mode 使用对应字段:
2623
+ - 'iv'/'ecsa-co'/'ecsa-hupd': 读 FCD 字段
2624
+ - 'o2': 读 O2 字段
2625
+ - 'eis': 读 EIS 字段
2626
+
2627
+ 调用: 由 `detect_file_mode` 返回。
2628
+ """
2629
+ mode: str = 'iv'
2630
+
2631
+ # === FCD (iv / ecsa-co / ecsa-hupd) ===
2632
+ # ECSA 文件自动检测出的真实模式 'CO'/'HUPD' (与 mode 不同: mode 可能被
2633
+ # processing_mode 覆盖, 此字段保留 resolve_ecsa_mode 的原始判定)
2634
+ detected_ecsa_mode: "str | None" = None
2635
+ data_df: "pd.DataFrame | None" = None
2636
+ cell_area: "float | None" = None
2637
+ file_name: "str | None" = None
2638
+ fcd_type: str = 'IV'
2639
+ scan_rate_vps: "float | None" = None
2640
+ unit_sn: "str | None" = None
2641
+ device_name: "str | None" = None # 测试设备名称 (检测层按 unit_sn 匹配 device_map; 匹配失败为 None)
2642
+ date: "str | None" = None # FCD 表头 `Time:` 行日期
2643
+
2644
+ def to_fcd_info(self) -> 'FcdInfo':
2645
+ """构造轻量元数据 FcdInfo (不含 data_df), 用于管线输入。"""
2646
+ return FcdInfo(
2647
+ file_name=self.file_name,
2648
+ cell_area=self.cell_area,
2649
+ fcd_type=self.fcd_type,
2650
+ scan_rate_vps=self.scan_rate_vps,
2651
+ unit_sn=self.unit_sn,
2652
+ device_name=self.device_name,
2653
+ date=self.date,
2654
+ )
2655
+
2656
+ # === DTA-O2 (o2): 已填充数据的 OxygenFileData 对象 (由 detect_file_mode 创建) ===
2657
+ o2_file_objects: "list[OxygenFileData] | None" = None
2658
+
2659
+ # === DTA-EIS (eis) ===
2660
+ eis_file_data: "tuple[np.ndarray, np.ndarray, np.ndarray, 'str | None', 'float | None'] | None" = None
2661
+
2662
+
2663
+ @dataclass
2664
+ class EcsaDataResult:
2665
+ """
2666
+ 用途: 保存 ECSA 数据读取与分段结果 (原始数据, 模式, 片段列表, 扫速)。
2667
+
2668
+ 调用: 由 `_begin_ecsa_pipeline` 返回。
2669
+ """
2670
+ data_df: "pd.DataFrame | None" = None
2671
+ raw_data_df: "pd.DataFrame | None" = None
2672
+ mode: str = "Auto"
2673
+ cell_area: float = 5.0
2674
+ data_df_list: list = field(default_factory=list)
2675
+ ecsa_calc_df_list: list = field(default_factory=list)
2676
+ scan_rate_vps: "float | None" = field(default=None)
2677
+ fcd_info: "FcdInfo | None" = None
2678
+
2679
+
2680
+ def concat_plot_data_indices(index_series):
2681
+ '''
2682
+ 用途: 拼接用于绘图的非空数据索引数组。
2683
+ 调用: 由 `draw_iv_overview` 调用。
2684
+ 返回: 返回 Numpy 索引数组或 None。
2685
+ '''
2686
+ valid_indices = [item for item in index_series.values if len(item) > 0]
2687
+ if not valid_indices:
2688
+ return None
2689
+ return np.concatenate(valid_indices)
2690
+
2691
+
2692
+ # =========================
2693
+ # 引擎导出辅助
2694
+ # =========================
2695
+ # 输出路径辅助
2696
+ # =========================
2697
+
2698
+ def _auto_output_path(mode: str, file_paths: list[Path]) -> Path:
2699
+ """
2700
+ 用途: 管线在 output_path 为 None 时的兜底输出路径。
2701
+ 目录取首个输入文件所在目录 (管线兜底层无 result_path 概念), 文件名用
2702
+ build_output_filename 统一命名。
2703
+
2704
+ 调用: 由 IV/ECSA/O2/EIS 管道引擎在 output_path 为 None 时调用。
2705
+ 返回: 返回 Path。
2706
+ """
2707
+ first = Path(file_paths[0])
2708
+ return first.parent / build_output_filename(mode, file_paths)
2709
+
2710
+
2711
+ def extract_folder_name(first_path: Path) -> str:
2712
+ '''
2713
+ 用途: 从单个文件路径提取自动命名用的文件夹名 (通常含实验日期)。
2714
+ 父文件夹名 <=5 字符时回退祖父目录名 (所有模式一致); 路径为空时回退 'data'。
2715
+ first_path 可为 str/Path 或 OxygenFileData 包装 (内部先解包 .path)。
2716
+
2717
+ 参数:
2718
+ first_path: 首个输入文件路径 (str / Path / OxygenFileData 包装)。
2719
+
2720
+ 调用: 由 `build_output_filename` 调用, 供 GUI 与 CLI 自动命名共用。
2721
+ 返回: 命名用文件夹名字符串。
2722
+ '''
2723
+ if not first_path:
2724
+ return 'data'
2725
+ first = Path(getattr(first_path, 'path', first_path))
2726
+ folder_name = first.parent.name
2727
+ if len(folder_name) <= 5:
2728
+ folder_name = first.parent.parent.name
2729
+ return folder_name
2730
+
2731
+
2732
+ _PANEL_KEY_TO_MODE = {'iv': 'iv', 'co': 'ecsa-co', 'hupd': 'ecsa-hupd',
2733
+ 'oxygen': 'o2', 'eis': 'eis'}
2734
+
2735
+
2736
+ def panel_key_to_mode(panel_key: str) -> str:
2737
+ '''
2738
+ 用途: GUI panel_key ('co'/'hupd'/'oxygen') 转管线 mode; 不认识的键原样返回。
2739
+
2740
+ 调用: 由 GUI `_set_file_paths`/`_on_save_path` 调用, 使 GUI 传给共享命名函数的是标准 mode。
2741
+ 返回: mode 字符串。
2742
+ '''
2743
+ return _PANEL_KEY_TO_MODE.get(panel_key, panel_key)
2744
+
2745
+
2746
+ def build_output_filename(mode: str, file_paths: list[Path]) -> str:
2747
+ '''
2748
+ 用途: 生成统一命名的输出 Excel 文件名 {mode}_{文件夹名}_{YYYYMMDD_HHMMSS}.xlsx。
2749
+ 文件夹名经 extract_folder_name 提取 (父目录 ≤5 字符回退祖父, 所有模式一致)。
2750
+ 只返回文件名, 不含目录, 目录由各调用方 (管线兜底=文件同目录, GUI=result_path,
2751
+ manifest=output_dir) 自行前置。file_paths 可含 OxygenFileData 包装 (extract_folder_name
2752
+ 内部解包 .path)。
2753
+
2754
+ 参数:
2755
+ mode: 管线 mode ('iv'/'ecsa-co'/'ecsa-hupd'/'o2'/'eis')。
2756
+ file_paths: 输入文件路径列表 (至少 1 个, 命名仅取首个文件)。
2757
+
2758
+ 调用: 由 `_auto_output_path`, GUI `_set_file_paths`/`_on_save_path`, CLI `_run_manifest`
2759
+ 共用, 统一自动命名格式。
2760
+ 返回: 文件名 str (如 'ecsa-co_H2数据_20260810_143055.xlsx')。
2761
+ '''
2762
+ first_path = next(iter(file_paths), None)
2763
+ folder_name = extract_folder_name(first_path)
2764
+ ts = datetime.now().strftime('%Y%m%d_%H%M%S')
2765
+ return f'{mode}_{folder_name}_{ts}.xlsx'
2766
+
2767
+
2768
+ # =========================
2769
+ # 工具函数 (供 computations.py / plotting.py 复用, 避免循环导入)
2770
+ # =========================
2771
+
2772
+ def safe_linear_fit(x: Any, y: Any, min_points: int = 2) -> LinearFitResult:
2773
+ '''
2774
+ 用途: 执行带校验的线性回归并保留过滤后的拟合数据。
2775
+
2776
+ 调用: 由 `build_linear_fit_figure_from_data` / `build_linear_fit_figure_from_result`, H2/MA 拟合和 O2 阻力计算流程调用。
2777
+ 返回: 返回 `LinearFitResult`。
2778
+ '''
2779
+ x_arr = pd.to_numeric(pd.Series(x), errors='coerce').to_numpy(dtype=float)
2780
+ y_arr = pd.to_numeric(pd.Series(y), errors='coerce').to_numpy(dtype=float)
2781
+ valid_mask = np.isfinite(x_arr) & np.isfinite(y_arr)
2782
+ x_valid = x_arr[valid_mask]
2783
+ y_valid = y_arr[valid_mask]
2784
+ if len(x_valid) < min_points:
2785
+ raise ValueError(f'线性拟合有效点少于 {min_points} 个')
2786
+ if np.ptp(x_valid) == 0:
2787
+ raise ValueError('线性拟合的 x 值全部相同')
2788
+ slope, intercept, r, *_ = linregress(x_valid, y_valid)
2789
+ y_fit = x_valid * slope + intercept
2790
+ return LinearFitResult(slope, intercept, r, x_valid, y_valid, y_fit)
2791
+
2792
+
2793
+ def calculate_hupd_ecsa_averages(ecsa_values: list[float | None]) -> tuple[float | None, float | None]:
2794
+ '''
2795
+ 用途: 在所有 HUPD 子图处理完成后计算平均值。
2796
+
2797
+ 调用: 由 `run_hupd_ecsa_pipeline`, `apply_hupd_result_to_ui` 调用。
2798
+ 返回: 返回 `(mean_without_first, mean_all)`。
2799
+ '''
2800
+ mean_all = None
2801
+ mean_without_first = None
2802
+ completed_values = [value for value in ecsa_values if value is not None]
2803
+ if len(completed_values) == len(ecsa_values) and completed_values:
2804
+ mean_all = sum(completed_values) / len(completed_values)
2805
+
2806
+ tail_values = ecsa_values[1:]
2807
+ completed_tail_values = [value for value in tail_values if value is not None]
2808
+ if tail_values and len(completed_tail_values) == len(tail_values):
2809
+ mean_without_first = sum(completed_tail_values) / len(completed_tail_values)
2810
+ return mean_without_first, mean_all
2811
+
2812
+
2813
+ def calculate_oxygen_resistance_result(data_dict: dict[float, list[Any]], flow_rate: float, r_corr_func: Any) -> OxygenResistanceResult:
2814
+ '''
2815
+ 用途: 纯计算 O2 阻力拟合表, 最终结果和拟合记录。
2816
+
2817
+ 调用: 由 `run_oxygen_pipeline` 调用。
2818
+ 返回: 返回 `OxygenResistanceResult`。
2819
+ '''
2820
+ xi_rows = []
2821
+ xi_index = []
2822
+ current_fits = []
2823
+ j_fit_by_pressure = {}
2824
+
2825
+ for pressure, file_list in data_dict.items():
2826
+ x_list = [file.o2_fraction for file in file_list]
2827
+ j_list = [file.jm for file in file_list]
2828
+ fit = safe_linear_fit(x_list, j_list)
2829
+ if abs(fit.slope) < 1e-12:
2830
+ raise ValueError(f'{pressure}bar 的 I-xO2 拟合斜率为 0, 无法计算 R_total')
2831
+ r_total = (4 * PHYS.FARADAY_CONSTANT * (pressure - PHYS.WATER_VAPOR_PRESSURE_BAR) * PHYS.BAR_TO_KPA) / (fit.slope * PHYS.GAS_CONSTANT * PHYS.CELL_TEMPERATURE_K) / PHYS.R_TOTAL_UNIT_DIVISOR
2832
+ xi_rows.append([fit.slope, fit.intercept, fit.r, r_total])
2833
+ xi_index.append(f'{pressure}bar')
2834
+ j_fit_by_pressure[pressure] = np.array(x_list) * fit.slope + fit.intercept
2835
+ current_fits.append(OxygenFitRecord(f'{pressure}bar', x_list, j_list, fit, x_min=0))
2836
+
2837
+ xi_df = pd.DataFrame(xi_rows, columns=['slope', 'intercept', 'r', 'R_total'], index=xi_index, dtype=float)
2838
+
2839
+ if len(xi_df) < 2:
2840
+ raise ValueError('O2 Rtotal-Pressure 拟合有效压力点少于 2 个')
2841
+
2842
+ pressure_fit = safe_linear_fit(list(data_dict.keys()), xi_df['R_total'].tolist())
2843
+ xi_df['R_total_fit'] = pressure_fit.y_fit
2844
+ rpi_correct = r_corr_func(pressure_fit.slope, pressure_fit.intercept, flow_rate)
2845
+ result_df = pd.DataFrame({
2846
+ 'R_PI(s/cm)': [pressure_fit.intercept],
2847
+ 'slope(s/(cm*kPa))': [pressure_fit.slope / PHYS.R_TOTAL_UNIT_DIVISOR],
2848
+ 'r': [pressure_fit.r],
2849
+ 'R_PI_correct': [rpi_correct],
2850
+ })
2851
+
2852
+ result = OxygenResistanceResult(
2853
+ xi_df=xi_df,
2854
+ result_df=result_df,
2855
+ current_fits=current_fits,
2856
+ pressure_fit=OxygenFitRecord(
2857
+ 'Rtotal-Pressure',
2858
+ list(data_dict.keys()),
2859
+ xi_df['R_total'].tolist(),
2860
+ pressure_fit,
2861
+ ),
2862
+ j_fit_by_pressure=j_fit_by_pressure,
2863
+ )
2864
+ return result
2865
+
2866
+
2867
+ def calculate_oxygen_corrected_resistance(slope: float, intercept: float, flowrate: float, config: Config) -> float:
2868
+ '''
2869
+ 用途: 根据 O2 流道和水修正参数计算修正阻力。
2870
+ 调用: 由 `calculate_oxygen_resistance_result` 调用。
2871
+ 返回: 返回修正后的阻力值。
2872
+ '''
2873
+ return (
2874
+ intercept
2875
+ - (slope / 100) * PHYS.WATER_VAPOR_PRESSURE_KPA * ((PHYS.O2_D_ON_NAFION / PHYS.O2_D_OW_WATER) - 1)
2876
+ + PHYS.O2_CORRECTION_FACTOR_1
2877
+ * 2
2878
+ * config.o2_n_flow_channel
2879
+ * config.o2_l_flow_channel
2880
+ * PHYS.O2_CORRECTION_FACTOR_2
2881
+ * PHYS.CELSIUS_TO_KELVIN_OFFSET
2882
+ * PHYS.WATER_VAPOR_PRESSURE_KPA
2883
+ * PHYS.O2_D_ON_NAFION
2884
+ / (
2885
+ (flowrate * PHYS.LPM_TO_MLS)
2886
+ * (config.o2_cell_temp + PHYS.CELSIUS_TO_KELVIN_OFFSET)
2887
+ * PHYS.AMBIENT_PRESSURE_KPA
2888
+ * PHYS.O2_D_OW_WATER
2889
+ )
2890
+ )
2891
+
2892
+
2893
+ # =========================
2894
+ # 文件类型检测工具函数
2895
+ # =========================
2896
+
2897
+
2898
+ def detect_dta_subtype(file_path: Path) -> str | None:
2899
+ """读取 DTA 文件第 2 行的 TAG 标记, 返回子类型: 'o2' | 'eis' | None。
2900
+
2901
+ 用途: 在 auto 模式下区分 O2 (LSV) 和 EIS (阻抗谱) 数据。
2902
+ 仅读取前 2 行, 轻量快速。
2903
+
2904
+ 调用: 由 `gui.py._on_file_selected` 和 `cli.py.run_pipeline` 调用。
2905
+ """
2906
+ try:
2907
+ with open(file_path, encoding='utf-8', errors='ignore') as f:
2908
+ lines = [f.readline() for _ in range(2)]
2909
+ tag_line = lines[1].strip() if len(lines) >= 2 else ''
2910
+ if '\tLSV' in tag_line:
2911
+ return DTA_SUBTYPE_O2
2912
+ if '\tEISPOT' in tag_line:
2913
+ return DTA_SUBTYPE_EIS
2914
+ return None # 未知 TAG
2915
+ except Exception:
2916
+ return None
2917
+
2918
+
2919
+ # =========================
2920
+ # O2 文件模型
2921
+ # =========================
2922
+
2923
+
2924
+ def parse_oxygen_file_conditions(filename: str, fallback_pattern: str, services: 'RuntimeServices') -> tuple[float, float] | None:
2925
+ '''
2926
+ 用途: 从文件名解析压力和 O2 浓度。
2927
+
2928
+ 参数:
2929
+ filename: 文件名 (不含路径, 如 "1.0bar-2.0%O2.DTA")。
2930
+ fallback_pattern: o2_filename_pattern 配置值, 关键字匹配失败时做盲匹配降级。
2931
+ services: RuntimeServices 实例, 用于输出警告日志。
2932
+
2933
+ 调用: 由入口层和 `group_oxygen_files_by_pressure` 调用。
2934
+ 返回: 返回 `(pressure, x_o2)` 或 `None`。
2935
+ '''
2936
+ p_pattern = r'(\d+(?:\.\d+)?)\s*bar'
2937
+ x_pattern = r'(\d+(?:\.\d+)?)\s*(?:%O2|%|O2)'
2938
+ p_match = re.findall(p_pattern, filename)
2939
+ x_match = re.findall(x_pattern, filename)
2940
+ try:
2941
+ pressure = float(p_match[0])
2942
+ except Exception:
2943
+ services.show('', f'无法从文件名解析压力 ({filename})', 'WARNING')
2944
+ pressure = None
2945
+
2946
+ try:
2947
+ x_value = float(x_match[0])
2948
+ except Exception:
2949
+ services.show('', f'无法从文件名解析 O₂ 浓度 ({filename})', 'WARNING')
2950
+ x_value = None
2951
+
2952
+ if (pressure is not None) and (x_value is not None):
2953
+ return (pressure, x_value / 100)
2954
+
2955
+ match = re.findall(fallback_pattern, filename)
2956
+ try:
2957
+ pressure = float(match[0])
2958
+ x_value = float(match[1])
2959
+ return (pressure, x_value / 100)
2960
+ except Exception:
2961
+ services.show('', f'无法从文件名解析 O₂ 浓度 ({filename})', 'WARNING')
2962
+ return None
2963
+
2964
+
2965
+ class OxygenFileData:
2966
+ '''
2967
+ 用途: 封装一个 O2 数据文件的路径, 预读数据和解析出的条件。
2968
+
2969
+ 调用: 由 O2 管线创建和分组流程使用。
2970
+ '''
2971
+ def __init__(self, path: 'Path | str'):
2972
+ self.path = Path(path)
2973
+ self.data_df: 'pd.DataFrame | None' = None
2974
+ self.conditions: 'tuple[float, float] | None' = None
2975
+ self.vm: float | None = None
2976
+ self.im: float | None = None
2977
+ self.jm: float | None = None
2978
+ self.date: 'str | None' = None # DTA 表头 DATE (运行级日期来源)
2979
+
2980
+ def apply_record(self, record) -> None:
2981
+ '''回填 O2 曲线限流点结果。'''
2982
+ self.vm = record.vm
2983
+ self.im = record.im
2984
+ self.jm = record.jm
2985
+
2986
+ @property
2987
+ def pressure(self) -> float | None:
2988
+ return self.conditions[0] if self.conditions else None
2989
+
2990
+ @property
2991
+ def o2_fraction(self) -> float | None:
2992
+ return self.conditions[1] if self.conditions else None
2993
+
2994
+
2995
+ # =========================
2996
+ # 自更新
2997
+ # =========================
2998
+ #
2999
+ # 用途: 让 GUI 里的「检查更新」从"只告知"升级为"发现新版 -> 用户确认 -> 自动执行 pip 更新"。
3000
+ #
3001
+ # 为什么必须由**另一个进程**执行:
3002
+ # 程序自己没有安装权限也没有干净的文件状态。做法是程序拉起一个脱离父进程的更新器
3003
+ # (python -c <payload>, DETACHED_PROCESS), 然后自己退出; 更新器等父进程消失后再动手。
3004
+ # 实测纯 .py 模块在 Windows 上**不会**被运行中的解释器锁住 (另一进程可覆盖/删除已 import 的
3005
+ # 源文件), 因此"等父进程退出"不是文件锁的需要, 而是语义需要: 让磁盘代码与内存代码的
3006
+ # 版本不一致窗口尽量短, 也避免更新器与仍在运行的旧进程同时改动同一批文件。用户下次打开
3007
+ # 程序时读结果弹一次。
3008
+ #
3009
+ # 为什么更新目标是"当前程序目录"而不是写死 site-packages:
3010
+ # pip 形态下本进程加载的就是 site-packages 里那份, 传"本进程代码所在目录"比写字符串更可靠
3011
+ # (虚拟环境 / --user 安装 / 多份 dist-info 时路径各不相同); 源码副本形态不进入本流程
3012
+ # (「立即更新」置灰), 因为 pip 只会改 site-packages 里另一份, 用户跑的那份不会变。
3013
+ #
3014
+ # 注意: 本分区的 pip 开关是**唯一一处**定义; 测试里守住开关内容与拼出的命令形状,
3015
+ # 防止后续无意放宽。
3016
+
3017
+ # 更新结果文件: 更新器写一次, 程序下次启动读一次后**立即删除** (不保留日志, 不产生垃圾)
3018
+ UPDATE_RESULT_FILE_NAME = 'update_result.json'
3019
+ PIP_INSTALL_FLAGS: tuple[str, ...] = ('--upgrade', '--no-deps',
3020
+ '--disable-pip-version-check', '--no-input')
3021
+ # 更新器的等待策略: 轮询父进程消失的上限, 以及消失后额外等待的秒数
3022
+ # (额外等待是给 Windows 释放句柄与杀毒扫描留余量, 避免"刚退就写"的偶发失败)
3023
+ UPDATE_WAIT_PID_TIMEOUT_S = 60.0
3024
+ UPDATE_POST_EXIT_DELAY_S = 2.0
3025
+ UPDATE_POLL_INTERVAL_S = 0.5
3026
+
3027
+
3028
+ def is_updatable_install(module_file: Path | str = __file__) -> bool:
3029
+ '''
3030
+ 用途: 判断当前这份代码能否使用程序内自更新 (决定「立即更新」按钮是否可点)。
3031
+
3032
+ 参数:
3033
+ module_file: 任一核心模块的 __file__ (默认本模块)。
3034
+
3035
+ 调用: 由 GUI 的 `CheckUpdateDialog` (按钮置灰) 与其防御性兜底分支调用。
3036
+
3037
+ 返回: 仅当这份代码位于 site-packages 之下 (pip 安装形态) 时为 True; 其余形态与任何
3038
+ 判别异常一律 False。
3039
+
3040
+ 注意: 判据是"**装在哪**", 不是"能不能写"。
3041
+ 非 pip 形态一律置灰, 原因: 自更新走 `pip install --target <本进程代码所在目录>`,
3042
+ 而 pip 只会改 site-packages 里的那一份; 源码副本 / 开发仓库里跑的代码不在 pip
3043
+ 管辖范围内, 装了也不生效, 容易让用户以为更新成功了。这些形态的正路是
3044
+ `pip install fcdp` (唯一分发方式), 弹窗文案 (`CHECK_UPDATE_NOT_AVAILABLE`) 直接
3045
+ 给出这条命令; 源码副本可用但不提供维护。
3046
+ 不用可写性的原因: `os.access(dir, os.W_OK)` 在 Windows 上只看只读属性, 不查 ACL,
3047
+ 对目录几乎恒为 True, 拦不住无写权限的安装; pip 形态下的权限失败由 pip 的退出码
3048
+ 与下次启动的回执 (`CHECK_UPDATE_FAILED_HINT`) 告知。
3049
+ 本函数**保证不抛异常** (判别属旁路逻辑, 不得阻断启动)。
3050
+ '''
3051
+ try:
3052
+ return detect_install_mode(module_file) == 'pip-install'
3053
+ except Exception:
3054
+ return False
3055
+
3056
+
3057
+ def build_install_command(python: str, target: Path | str, version: str | None = None) -> list[str]:
3058
+ '''
3059
+ 用途: 拼出程序内自更新使用的 pip 命令。
3060
+
3061
+ 参数:
3062
+ python: 解释器路径 (通常 sys.executable)。
3063
+ target: 安装目标目录 (必须指向本进程实际加载代码的目录, 见分区说明)。
3064
+ version: 目标版本; None 表示装索引上的默认版本 (最新)。
3065
+
3066
+ 调用: 由 `start_self_update` 拼更新器命令行; 测试用它核对开关与命令形状。
3067
+
3068
+ 返回: 命令参数列表。
3069
+
3070
+ 注意: **带** `--no-deps`: 只覆盖程序自己的模块文件, 依赖 (pandas/numpy/...) 由用户环境提供,
3071
+ 更新时绝不擅自改动用户的依赖环境 (依赖不满足时由用户自行补装, 启动器会在缺依赖时
3072
+ 列出模块名与补装命令)。
3073
+ '''
3074
+ spec = f'{DIST_NAME}=={version}' if version else DIST_NAME
3075
+ return [python, '-m', 'pip', 'install', *PIP_INSTALL_FLAGS, '--target', str(target), spec]
3076
+
3077
+
3078
+ def _temp_dir() -> Path | None:
3079
+ '''系统临时目录; 取不到时返回 None (候选表里跳过这一档)。'''
3080
+ try:
3081
+ import tempfile
3082
+ return Path(tempfile.gettempdir())
3083
+ except Exception:
3084
+ return None
3085
+
3086
+
3087
+ def _local_appdata_root(create: bool = True) -> Path:
3088
+ '''更新结果文件的**首选**目录; 逐级回退, 保证一定有一个候选。
3089
+
3090
+ 参数:
3091
+ create: True 时确保目录存在 (写结果时用); False 时只挑第一个已存在且可写的候选,
3092
+ 不产生任何建目录副作用 (启动时读结果用)。
3093
+
3094
+ 注意: 首选 `user_config_dir()` (pip 安装形态即配置文件所在目录); 源码副本 / 开发仓库形态下
3095
+ 配置在程序目录里, 本目录只是用户数据目录, 与配置归属无关; 结果文件属"机器级一次性
3096
+ 通知", 读写的两端都是本机同一个进程组, 放这里不会串。
3097
+ 这里的可写性判断 (`os.access`) 在 Windows 上只看只读属性, 不查 ACL, 因此**不保证**
3098
+ 结果文件真能写进来。它只是候选 #1: 读回执遍历 `_update_result_dirs()` 的全部候选,
3099
+ 写回执遍历 `update_result_paths()` 并逐条重试, 两侧不会因判断口径不同而错开。
3100
+ '''
3101
+ candidates = [user_config_dir()]
3102
+ temp_dir = _temp_dir()
3103
+ if temp_dir is not None:
3104
+ candidates.append(temp_dir)
3105
+ candidates.append(_PROJECT_ROOT)
3106
+ if not create:
3107
+ for target in candidates:
3108
+ if target.is_dir() and os.access(target, os.W_OK):
3109
+ return target
3110
+ return candidates[0]
3111
+ for target in candidates:
3112
+ try:
3113
+ target.mkdir(parents=True, exist_ok=True)
3114
+ return target
3115
+ except OSError:
3116
+ continue
3117
+ return _PROJECT_ROOT
3118
+
3119
+
3120
+ def _update_result_dirs() -> list[Path]:
3121
+ '''读回执的候选目录 (有序去重): 首选 `_local_appdata_root`, 再补临时目录与程序目录。
3122
+
3123
+ 调用: 由 `read_update_result` 与 `update_result_paths` 调用。
3124
+
3125
+ 注意: 这三档就是 `_local_appdata_root` 内部使用的同一批候选。之所以把整张候选表暴露出来,
3126
+ 是因为"写进去的那个目录"未必等于"判断为可写的那个目录" (只读属性与 ACL 不是一回事,
3127
+ 且写入失败是静默的)。读侧遍历全部候选, 写入端无论落在哪一档都能被读到。
3128
+ 本函数只拼路径, 不建目录, 不抛异常。
3129
+ '''
3130
+ dirs: list[Path] = []
3131
+ for candidate in (_local_appdata_root(create=False), _temp_dir(), _PROJECT_ROOT):
3132
+ if candidate is not None and candidate not in dirs:
3133
+ dirs.append(candidate)
3134
+ return dirs
3135
+
3136
+
3137
+ def update_result_path() -> Path:
3138
+ '''
3139
+ 用途: 更新结果文件的**首选**绝对路径 (会确保所在目录存在)。
3140
+
3141
+ 调用: 测试用它定位结果文件; 写路径见 `update_result_paths` (逐条重试)。
3142
+ 读路径 (GUI 启动) 不经过本函数, 而是遍历 `_update_result_dirs()`,
3143
+ 避免启动期产生建目录副作用 (见 `read_update_result`)。
3144
+ '''
3145
+ return _local_appdata_root() / UPDATE_RESULT_FILE_NAME
3146
+
3147
+
3148
+ def update_result_paths() -> list[Path]:
3149
+ '''
3150
+ 用途: 写回执时的候选路径 (有序去重): 首选 `update_result_path()`, 再补其余候选目录。
3151
+
3152
+ 调用: 由 `write_update_result` 与 `build_updater_payload` (内联进更新器的 RESULT 列表) 调用。
3153
+
3154
+ 返回: 路径列表 (至少一项, 不会为空)。
3155
+
3156
+ 注意: 写端逐条尝试, 第一条成功即返回 -- 首选目录因 ACL / 只读属性写不进去时, 回执会落到
3157
+ 后面的候选目录, 而读端覆盖同一批目录, 因此仍然读得到 ("写了却永远读不到"是本次
3158
+ 复核修掉的问题之一)。
3159
+ '''
3160
+ paths: list[Path] = [update_result_path()]
3161
+ for directory in _update_result_dirs():
3162
+ candidate = directory / UPDATE_RESULT_FILE_NAME
3163
+ if candidate not in paths:
3164
+ paths.append(candidate)
3165
+ return paths
3166
+
3167
+
3168
+ def write_update_result(payload: dict) -> None:
3169
+ '''
3170
+ 用途: 写入一份更新结果; 失败时静默 (不能让结果文件写失败反过来报错)。
3171
+
3172
+ 参数:
3173
+ payload: 结果字典, 至少含 status ('ok' / 'failed') 与 message。
3174
+
3175
+ 调用: 测试调用 (验证结果文件格式与读回语义)。
3176
+
3177
+ 注意: **生产路径不调用本函数**, 真正写文件的是 `build_updater_payload` 生成的更新器
3178
+ 进程, 它必须自包含 (不能 import 项目模块, 见该函数说明), 因此内联了一份等价实现。
3179
+ 本函数是该写法的可读参考实现: 改格式 (ensure_ascii / indent / 吞 OSError) 或改候选
3180
+ 路径枚举 (`update_result_paths`) 时必须同步 `build_updater_payload` 里的 `write()`。
3181
+ 逐个候选路径重试, 第一条写成功即返回。
3182
+
3183
+ 返回: 无。
3184
+ '''
3185
+ text = json.dumps(payload, ensure_ascii=False, indent=2)
3186
+ for path in update_result_paths():
3187
+ try:
3188
+ path.write_text(text, encoding='utf-8')
3189
+ return
3190
+ except OSError:
3191
+ continue
3192
+
3193
+
3194
+ def read_update_result() -> dict | None:
3195
+ '''
3196
+ 用途: 读取上次更新留下的结果, **读完即删** (一次性通知, 不留垃圾)。
3197
+
3198
+ 调用: 由 GUI 启动流程调用。
3199
+
3200
+ 返回: 结果字典; 所有候选目录里都没有 / 内容损坏 / 缺 status 字段时返回 None。
3201
+
3202
+ 注意: 本函数保证不抛异常 (启动路径上的旁路逻辑); 只有一种结果格式, 不设 schema 版本号。
3203
+ 按 `_update_result_dirs()` 的顺序遍历候选目录, 命中即读并删除, 不依赖"写端当时挑了
3204
+ 哪个目录"; 全程只读不建目录。命中一个候选但内容不可解析时直接返回 None (已删),
3205
+ 不再继续找后面的候选, 避免把上一次的旧回执当成这一次的结果。
3206
+ '''
3207
+ try:
3208
+ for directory in _update_result_dirs():
3209
+ path = directory / UPDATE_RESULT_FILE_NAME
3210
+ if not path.is_file():
3211
+ continue
3212
+ try:
3213
+ text = path.read_text(encoding='utf-8')
3214
+ except OSError:
3215
+ return None
3216
+ try:
3217
+ path.unlink() # 先读后删: 无论内容是否可解析, 都不留下垃圾
3218
+ except OSError:
3219
+ pass
3220
+ record = json.loads(text)
3221
+ if not isinstance(record, dict):
3222
+ return None
3223
+ if record.get('status') not in ('ok', 'failed'):
3224
+ return None
3225
+ return record
3226
+ return None
3227
+ except Exception:
3228
+ return None
3229
+
3230
+
3231
+ def build_updater_payload(target: Path | str, version: str | None, python: str,
3232
+ command: list[str] | None = None) -> str:
3233
+ '''
3234
+ 用途: 生成更新器的 Python 源码 (由 `python -c` 执行, 不落任何脚本文件)。
3235
+
3236
+ 参数:
3237
+ target: 安装目标目录 (本进程实际加载代码的目录)。
3238
+ version: 目标版本; None 表示索引默认版本。
3239
+ python: 执行 pip 的解释器路径。
3240
+ command: 覆盖实际执行的命令 (仅测试注入用): None 时按 `build_install_command` 拼 pip 命令。
3241
+ 测试传一条轻量命令就能**真跑**更新器并断言行为, 不必按源码文本断言。
3242
+
3243
+ 调用: 由 `start_self_update` 使用。
3244
+
3245
+ 返回: 可直接传给 `python -c` 的源码字符串。
3246
+
3247
+ 注意: payload 内部**不再 import 项目模块**, 它会在等待父进程期间先执行,
3248
+ 期间 pip 还会替换磁盘上的代码, 因此它必须完全自包含 (只依赖标准库):
3249
+ 进程存活判定与结果写盘都在 payload 内联实现, 模块级只保留 `read_update_result`
3250
+ 这个"读回执"的轻量入口 (启动路径上使用), 见 `read_update_result` 的说明。
3251
+ 回执目标是一张**候选路径表** (`update_result_paths`), 逐条尝试写入, 与读侧的
3252
+ `_update_result_dirs` 覆盖同一批目录。pip 成功即记 status='ok', 此时 `version`
3253
+ 可能为 None (装是装上了, 只是读不到 _shared.py 里的版本号)。
3254
+ payload 成功后写入 `version` 与 (非空时) `removed`,
3255
+ GUI 启动时读回执并向用户展示这次更新清理了哪些残留模块。
3256
+ '''
3257
+ active = command if command is not None else build_install_command(python, target, version)
3258
+ quoted = ', '.join(repr(item) for item in active)
3259
+ lines = [
3260
+ 'import json, os, subprocess, sys, time',
3261
+ f'TARGET = {str(target)!r}',
3262
+ f'COMMAND = [{quoted}]',
3263
+ f'MODULES = {CORE_MODULE_NAMES!r}',
3264
+ f'RESULT = {[str(path) for path in update_result_paths()]!r}',
3265
+ f'TIMEOUT = {UPDATE_WAIT_PID_TIMEOUT_S!r}',
3266
+ f'DELAY = {UPDATE_POST_EXIT_DELAY_S!r}',
3267
+ f'POLL = {UPDATE_POLL_INTERVAL_S!r}',
3268
+ 'PID = int(sys.argv[1]) if len(sys.argv) > 1 else 0',
3269
+ '',
3270
+ 'def alive(pid):',
3271
+ ' if pid <= 0:',
3272
+ ' return False',
3273
+ ' try:',
3274
+ " if os.name == 'nt':",
3275
+ ' import ctypes',
3276
+ ' handle = ctypes.windll.kernel32.OpenProcess(0x00100000, False, pid)',
3277
+ ' if not handle:',
3278
+ ' return False',
3279
+ ' try:',
3280
+ ' return ctypes.windll.kernel32.WaitForSingleObject(handle, 0) == 0x00000102',
3281
+ ' finally:',
3282
+ ' ctypes.windll.kernel32.CloseHandle(handle)',
3283
+ ' os.kill(pid, 0)',
3284
+ ' return True',
3285
+ ' except Exception:',
3286
+ ' return False',
3287
+ '',
3288
+ 'def write(payload):',
3289
+ ' for path in RESULT:',
3290
+ ' try:',
3291
+ ' with open(path, \'w\', encoding=\'utf-8\') as handle:',
3292
+ ' json.dump(payload, handle, ensure_ascii=False, indent=2)',
3293
+ ' return',
3294
+ ' except OSError:',
3295
+ ' continue',
3296
+ '',
3297
+ 'def read_version():',
3298
+ ' import re',
3299
+ ' try:',
3300
+ " text = open(os.path.join(TARGET, '_shared.py'), encoding='utf-8').read()",
3301
+ ' except OSError:',
3302
+ ' return None',
3303
+ ' found = re.search(r"^__version__\\s*(?::\\s*str\\s*)?=\\s*[\'\\"](\\d{8})[\'\\"]",',
3304
+ ' text, re.M)',
3305
+ ' return found.group(1) if found else None',
3306
+ '',
3307
+ '# 1) 等父进程退出 (上限 TIMEOUT), 再多等 DELAY 秒让系统释放句柄',
3308
+ 'deadline = time.time() + TIMEOUT',
3309
+ 'while alive(PID) and time.time() < deadline:',
3310
+ ' time.sleep(POLL)',
3311
+ 'time.sleep(DELAY)',
3312
+ '',
3313
+ '# 2) 执行 pip。此处**不在 pip 之前删除任何模块**: 那会在更新失败时把安装毁掉,',
3314
+ '# 而 pip 自己会覆盖同名文件 (--upgrade 的语义)。',
3315
+ 'try:',
3316
+ ' done = subprocess.run(COMMAND, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,',
3317
+ ' env=dict(os.environ, PYTHONIOENCODING=\'utf-8\'), timeout=1800)',
3318
+ ' code = done.returncode',
3319
+ 'except Exception as ex:',
3320
+ ' write({\'status\': \'failed\', \'message\': f"无法启动 pip: {type(ex).__name__}: {ex}",',
3321
+ ' \'target\': TARGET})',
3322
+ ' sys.exit(1)',
3323
+ '',
3324
+ '# 3) pip 成功后, 按新版的 RECORD 清掉它已不再提供的核心模块; 读不到 RECORD 就不动,',
3325
+ '# 因为"不知道新版提供什么"时删除等于自毁安装。',
3326
+ 'def drop_obsolete(installed_version):',
3327
+ ' import glob as _glob',
3328
+ ' records = _glob.glob(os.path.join(TARGET, "fcdp-*.dist-info", "RECORD"))',
3329
+ ' if not records:',
3330
+ ' return []',
3331
+ ' # 目录名形如 fcdp-<版本>.dist-info。pip 的 --target 升级只替换同名条目, 旧版元数据目录',
3332
+ ' # 会一直残留, 所以不能取列举到的第一条 (那往往是最早装的那份): 优先精确匹配本次装出的',
3333
+ ' # 版本, 否则取数字段最大的一份。这里逐段转 int 比较, 避免多段版本号被字典序排错。',
3334
+ ' def rank(path):',
3335
+ ' name = os.path.basename(os.path.dirname(path))',
3336
+ ' parts = name.split("-")[1] if "-" in name else ""',
3337
+ ' return ([int(p) for p in parts.split(".") if p.isdigit()], path)',
3338
+ ' chosen = None',
3339
+ ' if installed_version:',
3340
+ ' exact = os.path.join(TARGET, f"fcdp-{installed_version}.dist-info", "RECORD")',
3341
+ ' if exact in records:',
3342
+ ' chosen = exact',
3343
+ ' if chosen is None:',
3344
+ ' chosen = sorted(records, key=rank)[-1]',
3345
+ ' try:',
3346
+ ' text = open(chosen, encoding="utf-8", errors="replace").read()',
3347
+ ' except OSError:',
3348
+ ' return []',
3349
+ ' provided = {line.split(",", 1)[0].strip().replace("/", os.sep)',
3350
+ ' for line in text.splitlines() if line.strip()}',
3351
+ ' dropped = []',
3352
+ ' for name in MODULES:',
3353
+ ' if name in provided:',
3354
+ ' continue',
3355
+ ' path = os.path.join(TARGET, name)',
3356
+ ' if os.path.isfile(path):',
3357
+ ' try:',
3358
+ ' os.remove(path)',
3359
+ ' dropped.append(name)',
3360
+ ' except OSError:',
3361
+ ' pass',
3362
+ ' return dropped',
3363
+ '',
3364
+ 'version = read_version()',
3365
+ 'if code == 0: # pip 成功即更新成功; 读不到版本号不改判为失败 (装是装上了)',
3366
+ ' dropped = drop_obsolete(version)',
3367
+ ' record = {\'status\': \'ok\', \'version\': version, \'target\': TARGET,',
3368
+ ' \'message\': f\'已更新到 {version}\' if version else \'更新已完成, 未能读取版本号\'}',
3369
+ ' if dropped: # 空列表不写, 免得回执里出现"已清理: "这样的空话',
3370
+ ' record[\'removed\'] = dropped',
3371
+ ' write(record)',
3372
+ 'else:',
3373
+ ' write({\'status\': \'failed\', \'target\': TARGET,',
3374
+ ' \'message\': f\'pip 退出码 {code}\'})',
3375
+ 'sys.exit(code)',
3376
+ ]
3377
+ return '\n'.join(lines)
3378
+
3379
+
3380
+ def start_self_update(target: Path | str, version: str | None = None,
3381
+ python: str | None = None) -> int | None:
3382
+ '''
3383
+ 用途: 拉起一个脱离父进程的更新器, 由它在本进程退出后执行 pip 更新。
3384
+
3385
+ 参数:
3386
+ target: 安装目标目录 (应为本进程实际加载代码的目录)。
3387
+ version: 目标版本; None 表示索引默认版本。
3388
+ python: 执行更新器的解释器; None 表示 sys.executable。
3389
+
3390
+ 调用: 由 GUI 的「立即更新」确认流程调用, 随后调用方应立即退出程序。
3391
+
3392
+ 返回: 更新器进程号; 拉起失败返回 None (调用方据此提示用户改用 pip install --upgrade fcdp)。
3393
+
3394
+ 注意: 更新器用 `python -c <payload>` 启动, **不写任何脚本文件**;
3395
+ Windows 上带 DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP, 与父进程完全脱钩,
3396
+ 标准流全部丢弃 (不弹控制台窗口); 结果由更新器写入 update_result.json
3397
+ (首选用户数据目录, 写不进去时落到候选表里的下一档), 程序下次启动时读取并删除。
3398
+ 调用方 (GUI) 必须**紧接着退出程序**: 更新器只会等父进程 60 s, 之后无论父进程是否
3399
+ 还在都会开始装, 留着界面不退出只会拉长"磁盘已换, 内存还是旧版"的时间。
3400
+ '''
3401
+ interpreter = python or sys.executable
3402
+ payload = build_updater_payload(target, version, interpreter)
3403
+ kwargs: dict = {'stdin': subprocess.DEVNULL, 'stdout': subprocess.DEVNULL,
3404
+ 'stderr': subprocess.DEVNULL, 'close_fds': True}
3405
+ if os.name == 'nt':
3406
+ kwargs['creationflags'] = (subprocess.DETACHED_PROCESS
3407
+ | subprocess.CREATE_NEW_PROCESS_GROUP)
3408
+ else:
3409
+ kwargs['start_new_session'] = True
3410
+ try:
3411
+ process = subprocess.Popen([interpreter, '-c', payload, str(os.getpid())], **kwargs)
3412
+ except Exception:
3413
+ return None
3414
+ return process.pid
3415
+
3416
+
3417
+ if __name__ == '__main__':
3418
+ print("This module is not meant to be run directly. Use 'python gui.py' to start the application.")