From de7040738e1a480ebb5e83daf29724a979529e5d Mon Sep 17 00:00:00 2001 From: Reyppp <1310590802zss@gmail.com> Date: Sun, 30 Aug 2026 16:20:22 +0800 Subject: [PATCH] Support incomplete and partial log alignment --- PRODUCT.md | 3 +- README.md | 10 +-- RELEASE_NOTES.md | 24 ++++-- analyzer.py | 172 ++++++++++++++++++++++++++++++++------ index.html | 6 +- installer/LogAnalysis.iss | 2 +- site/demo.js | 4 +- site/index.html | 26 +++--- test_analyzer.py | 132 ++++++++++++++++++++++++++++- version.py | 2 +- 10 files changed, 321 insertions(+), 60 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index d4f2687..9538346 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -32,9 +32,10 @@ Log Analysis 将连续工控log与逐层监控log只读汇总为可筛选、可 - 工控log设备趋势采用自适应采样,优先保留真实边界与极值;不强制加载全部采样点。 - 层范围、材料、监控方式和区段筛选统一作用于可计算的图表、表格和汇总;来源完整性、完成状态、数据质量及关联诊断保持整炉口径。 - 综合模式下 `Motor Speed` 以工控log为准,并保留监控log参考值用于审计。 +- 双源关联以实际完成层为准;未完成尾部理论层继续保留。工控log只覆盖局部镀膜时间时,可关联到唯一的连续监控层区间。 - 中文界面为主,核心入口保持 `analyze_folder(path) -> AnalysisResult`。 - 第一版只支持 Windows x64,不提供在线分析、数据库、账号或自动更新。 -- 当前二进制版本是未签名测试版 `v0.1.0-beta.7`。 +- 当前二进制版本是未签名测试版 `v0.1.0-beta.8`。 ## Brand Commitments diff --git a/README.md b/README.md index 38b9afb..90bef2a 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ ## 核心功能 -- **综合分析**:自动关联工控log与监控log,校正时间偏移,并按层数或时间对照双源设备数据。 +- **综合分析**:自动关联工控log与监控log,校正时间偏移,并按层数或时间对照双源设备数据。未完成炉次按实际完成层关联;局部工控时间段可映射到唯一的连续监控层区间。 - **监控log分析**:提供总览、理论趋势、光学、设备和异常复核五个模块,支持单层曲线、理论曲线、原始截图及前后切层。 - **工控log分析**:连续查看功率、电流、电压、真空、温度、转速和气体数据,并按镀膜区段及材料筛选工作时间。 - **统一筛选**:层范围、材料、监控方式和区段同步作用于可计算的汇总、图表和表格。 -- **设备趋势**:综合时间图在整张图内最多保留 8,000 个真实边界点和极值点;缩放与平移不重新请求数据。 +- **设备趋势**:综合时间图展示日志中的实际采样值,并优先保留真实边界与极值;缩放与平移不重新请求数据。 - **数据来源**:综合模式下转速以工控log为准,同时保留监控log参考值用于核验。 ## 安装 @@ -33,11 +33,11 @@ ### 下载与校验 1. 升级前先关闭所有正在运行的 Log Analysis 窗口。 -2. 下载 [Log Analysis v0.1.0-beta.7 安装程序](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.7/Log-Analysis-Setup-v0.1.0-beta.7-x64.exe) 和同一版本的 [SHA256SUMS.txt](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.7/SHA256SUMS.txt)。也可以先查看[完整版本说明](https://github.com/Reyppp/log-analysis/releases/tag/v0.1.0-beta.7)。 +2. 下载 [Log Analysis v0.1.0-beta.8 安装程序](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.8/Log-Analysis-Setup-v0.1.0-beta.8-x64.exe) 和同一版本的 [SHA256SUMS.txt](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.8/SHA256SUMS.txt)。也可以先查看[完整版本说明](https://github.com/Reyppp/log-analysis/releases/tag/v0.1.0-beta.8)。 3. 在下载目录打开 PowerShell,运行: ```powershell -Get-FileHash -Algorithm SHA256 .\Log-Analysis-Setup-v0.1.0-beta.7-x64.exe +Get-FileHash -Algorithm SHA256 .\Log-Analysis-Setup-v0.1.0-beta.8-x64.exe Get-Content .\SHA256SUMS.txt ``` @@ -45,7 +45,7 @@ Get-Content .\SHA256SUMS.txt ### 安装与首次使用 -1. 双击 `Log-Analysis-Setup-v0.1.0-beta.7-x64.exe`。 +1. 双击 `Log-Analysis-Setup-v0.1.0-beta.8-x64.exe`。 2. 在安装向导中选择安装目录;程序默认安装到当前用户目录,不需要管理员权限,桌面快捷方式可选。 3. 桌面位于其他磁盘或使用目录链接时,安装程序会把已验证的开始菜单快捷方式复制到 Windows 登记的真实桌面路径;创建失败也不会中断应用安装。 4. 当前版本是未签名测试版。若 SmartScreen 显示“Windows 已保护你的电脑”,请先确认 SHA-256 已匹配,再选择“更多信息”,核对文件名后选择“仍要运行”。如果组织安全策略禁止运行,请联系管理员,不要关闭系统安全功能。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7dccf88..b29f141 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,16 @@ -# Log Analysis v0.1.0-beta.7 +# Log Analysis v0.1.0-beta.8 -安装与打包可靠性修复,保留 beta.6 的双日志分析与设备工作台功能。 +双日志关联扩展到未完成炉次和局部工控时间段,保留 beta.7 的安装与设备工作台修复。 -## 主要更新 +## 关联更新 + +- 关联范围改为实际完成层。未完成尾部理论层继续保留,不参与材料顺序、持续时间和时钟偏移计算。 +- 工控log只覆盖部分镀膜时间时,可自动匹配监控log中唯一的连续层区间;局部关联至少需要 30 层。 +- 支持忽略日志边缘最多一个首段和一个末段,以兼容工控log从某层中途开始或结束。 +- 唯一高可信候选优先于中可信候选;同级最高候选不唯一时停止自动关联并提示复核。 +- 综合总览新增理论层数、已完成层数、已关联层数和关联层范围。未关联层继续使用监控log转速参考值。 + +## 既有能力 - 安装向导始终显示安装目录页面,可在安装前选择目标位置。 - 桌面快捷方式改为复制已验证的开始菜单快捷方式,避免 Windows 在重定向桌面路径上直接保存 `.lnk` 失败。 @@ -11,7 +19,7 @@ - 自动识别日期命名的工控log和逐层监控log,并根据文件夹内容提供综合、监控log、工控log三种分析范围。 - 综合模式自动匹配镀膜层顺序并校正两类日志的时间偏移;关联失败时保留两类日志的独立分析,不强制合并。 -- 综合设备支持层数和时间两种横坐标。时间图以监控log时间为基准,工控log与监控log共享每张图最多 8,000 个真实采样点。 +- 综合设备支持层数和时间两种横坐标。时间图以监控log时间为基准,展示两类日志中的实际采样值。 - 时间图顶部增加同步镀膜层副坐标。层号保持水平,并根据图表宽度自动控制密度。 - 工控log提供独立总览和连续设备趋势,覆盖电气、真空、温度、转速及 O₂/Ar 气体通道。 - 层范围、材料、监控方式和区段筛选同步作用于可计算的汇总、图表与表格。 @@ -22,16 +30,16 @@ - 综合模式精简为总览和设备,工控log精简为总览和设备,监控log保留五个分析模块。 - 设备页统一图表尺寸、指标选择栏、H/L 配色、图例顺序和数据详情格式。 - 缩放、平移和坐标轴拖动只改变本地视口,不重新请求设备数据,也不重置 Pan 状态。 -- 工控log趋势使用圆点显示;综合时间图使用高密度圆点,避免跨层或断轴连接出不存在的趋势。 +- 工控log与综合时间趋势使用圆点显示,避免跨层或断轴连接出不存在的趋势。 - 单选 H 或 L 时只显示该材料参与工作的采样,未工作时间不占横轴。 - 监控log“异常关联”更名为“异常复核”,移除 Spearman 相关表格;综合和工控log不再展示独立异常页面。 ## 数据与性能 - 工控log连续数据在服务端会话中缓存,指标与筛选切换不重复读取原始文件。 -- 高密度采样保留真实边界点和桶内极值,不计算均值、不插值。 +- 时间趋势优先保留真实边界点和桶内极值,不计算均值、不插值。 - 关联算法允许跳过夹在完整镀膜序列之间的非镀膜工作段,避免额外工控序列导致整炉关联失败。 -- 37 项自动测试覆盖单源、双源、时间对齐、8,000 点预算、筛选、桌面接口、安装器和公开网站。 +- 自动测试覆盖单源、双源、未完成炉次、局部区间、候选冲突、时间对齐、采样预算、筛选、桌面接口、安装器和公开网站。 ## 系统要求 @@ -40,7 +48,7 @@ ## 安装与校验 -同时下载 `Log-Analysis-Setup-v0.1.0-beta.7-x64.exe` 和 `SHA256SUMS.txt`。使用 PowerShell 的 `Get-FileHash` 核对 SHA-256 后运行安装程序。 +同时下载 `Log-Analysis-Setup-v0.1.0-beta.8-x64.exe` 和 `SHA256SUMS.txt`。使用 PowerShell 的 `Get-FileHash` 核对 SHA-256 后运行安装程序。 ## 已知限制 diff --git a/analyzer.py b/analyzer.py index 7677af3..db874d9 100644 --- a/analyzer.py +++ b/analyzer.py @@ -935,17 +935,34 @@ def _align_sources(layer_summary: pd.DataFrame, segments: pd.DataFrame) -> tuple summary["target_time_delta"] = math.nan summary["machine_segment_id"] = math.nan reviews: list[dict[str, Any]] = [] + planned_layers = len(summary) + required = [column for column in ("layer_start", "layer_end", "actual_time") if column in summary] + complete_mask = summary[required].notna().all(axis=1) if len(required) == 3 else pd.Series(False, index=summary.index) + completed_layers = int(complete_mask.sum()) failed = { "status": "failed", "confidence": "关联失败", "matched_segments": 0, "candidate_count": 0, "duration_correlation": None, "time_offset_seconds": None, "offset_std_seconds": None, + "coverage": None, "planned_layers": planned_layers, "completed_layers": completed_layers, + "matched_layer_start": None, "matched_layer_end": None, + "ignored_machine_edge_segments": [], "candidate_ranges": [], "reason": "未找到与监控log材料顺序一致的连续工控工作时段。", } - if summary.empty or segments.empty or summary["layer_start"].isna().any(): + if summary.empty or segments.empty or completed_layers == 0: failed["reason"] = "镀膜层时间或工控工作时段不足,无法自动关联。" reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": None, "level": "重点复核", "reason": failed["reason"]}) return failed, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) + last_completed = complete_mask[complete_mask].index[-1] + if not complete_mask.loc[:last_completed].all(): + failed["reason"] = "已完成镀膜层之间存在时间数据缺口,未自动关联。" + reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": None, "level": "重点复核", "reason": failed["reason"]}) + return failed, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) + if completed_layers < planned_layers and completed_layers < 30: + failed["reason"] = "未完成炉次少于 30 个连续已完成层,未自动关联。" + reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": completed_layers, "level": "重点复核", "reason": failed["reason"]}) + return failed, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) + completed = summary.loc[complete_mask].reset_index(drop=True) eligible = segments[segments["material"].isin(["H", "L"])].sort_values("start").reset_index(drop=True) - target = summary["material"].astype(str).str.strip().tolist() + target = completed["material"].astype(str).str.strip().tolist() def candidate_metrics(monitor: pd.DataFrame, candidate: pd.DataFrame) -> tuple[float, float, float, float]: monitor_duration = pd.to_numeric(monitor["actual_time"], errors="coerce").reset_index(drop=True) @@ -958,18 +975,58 @@ def candidate_metrics(monitor: pd.DataFrame, candidate: pd.DataFrame) -> tuple[f score = (-1 if not math.isfinite(correlation) else correlation) - offset_std / 1000 return score, correlation, offset_std, float(offsets.median()) - candidates = [] + def confidence_for(correlation: float, offset_std: float) -> str | None: + if math.isfinite(correlation) and correlation >= 0.98 and offset_std <= 5: + return "高可信" + if math.isfinite(correlation) and correlation >= 0.90 and offset_std <= 15: + return "中可信" + return None + + def make_candidate( + monitor: pd.DataFrame, machine: pd.DataFrame, coverage: str, ignored: list[int] | None = None, + ) -> dict[str, Any]: + score, correlation, offset_std, offset = candidate_metrics(monitor, machine) + layer_start = int(monitor["layer"].iloc[0]) + layer_end = int(monitor["layer"].iloc[-1]) + return { + "score": score, "correlation": correlation, "offset_std": offset_std, "offset": offset, + "monitor": monitor.reset_index(drop=True), "machine": machine.reset_index(drop=True), + "coverage": coverage, "ignored": ignored or [], "layer_start": layer_start, "layer_end": layer_end, + "confidence": confidence_for(correlation, offset_std), + } + + def candidate_range(candidate: dict[str, Any]) -> dict[str, Any]: + machine = candidate["machine"] + return { + "layer_start": candidate["layer_start"], "layer_end": candidate["layer_end"], + "confidence": candidate["confidence"], + "duration_correlation": candidate["correlation"], + "time_offset_seconds": candidate["offset"], "offset_std_seconds": candidate["offset_std"], + "machine_segment_start": int(machine["segment_id"].iloc[0]), + "machine_segment_end": int(machine["segment_id"].iloc[-1]), + } + + def select_candidate(candidates: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + for confidence in ("高可信", "中可信"): + tier = [candidate for candidate in candidates if candidate["confidence"] == confidence] + if len(tier) == 1: + return tier[0], tier + if len(tier) > 1: + return None, tier + return None, [] + + candidates: list[dict[str, Any]] = [] for start in range(len(eligible) - len(target) + 1): candidate = eligible.iloc[start:start + len(target)] if candidate["material"].tolist() != target: continue - candidates.append((*candidate_metrics(summary, candidate), candidate)) + candidates.append(make_candidate(completed, candidate, "all_completed")) - if not candidates and "layer_end" in summary: - gap_seconds = pd.to_datetime(summary["layer_start"], errors="coerce").sub( - pd.to_datetime(summary["layer_end"], errors="coerce").shift() + if not candidates: + gap_seconds = pd.to_datetime(completed["layer_start"], errors="coerce").sub( + pd.to_datetime(completed["layer_end"], errors="coerce").shift() ).dt.total_seconds() - monitor_runs = [group.reset_index(drop=True) for _, group in summary.groupby(gap_seconds.ge(300).cumsum())] + monitor_runs = [group.reset_index(drop=True) for _, group in completed.groupby(gap_seconds.ge(300).cumsum())] if len(monitor_runs) > 1: run_options: list[list[tuple[int, int, pd.DataFrame]]] = [] for monitor_run in monitor_runs: @@ -1008,33 +1065,93 @@ def candidate_metrics(monitor: pd.DataFrame, candidate: pd.DataFrame) -> tuple[f if not paths: break candidates.extend( - (*metrics, pd.concat([item[2] for item in path], ignore_index=True)) - for path, _, metrics in paths + make_candidate(completed, pd.concat([item[2] for item in path], ignore_index=True), "all_completed") + for path, _, _ in paths ) + + chosen, conflicts = select_candidate(candidates) + if chosen is None and conflicts: + failed["candidate_count"] = len(candidates) + failed["candidate_ranges"] = [candidate_range(candidate) for candidate in conflicts] + failed["reason"] = "存在多个同级最高可信候选区间,未自动关联。" + reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": None, "level": "重点复核", "reason": failed["reason"]}) + return failed, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) + + partial_candidates: list[dict[str, Any]] = [] + if chosen is None and len(completed) >= 30: + session_groups = [group.sort_values("start").reset_index(drop=True) for _, group in eligible.groupby("session_id", sort=False)] if "session_id" in eligible else [eligible] + + def search_partial(machine: pd.DataFrame, ignored: list[int]) -> list[dict[str, Any]]: + if not 30 <= len(machine) < len(completed): + return [] + machine_target = machine["material"].astype(str).str.strip().tolist() + found = [] + for start in range(len(completed) - len(machine) + 1): + monitor = completed.iloc[start:start + len(machine)] + if monitor["material"].astype(str).str.strip().tolist() == machine_target: + found.append(make_candidate(monitor, machine, "partial_completed", ignored)) + return found + + for group in session_groups: + partial_candidates.extend(search_partial(group, [])) + partial_chosen, partial_conflicts = select_candidate(partial_candidates) + + if partial_chosen is None: + exact_conflicts = partial_conflicts + partial_conflicts = [] + for variants in (((1, 0), (0, 1)), ((1, 1),)): + edge_candidates = [] + for group in session_groups: + for drop_first, drop_last in variants: + stop = len(group) - drop_last if drop_last else len(group) + machine = group.iloc[drop_first:stop].reset_index(drop=True) + ignored_rows = pd.concat([group.iloc[:drop_first], group.iloc[stop:]], ignore_index=True) + ignored = [int(value) for value in ignored_rows.get("segment_id", pd.Series(dtype=int)).tolist()] + edge_candidates.extend(search_partial(machine, ignored)) + partial_candidates.extend(edge_candidates) + partial_chosen, partial_conflicts = select_candidate(edge_candidates) + if partial_chosen is not None or partial_conflicts: + break + if partial_chosen is None and not partial_conflicts: + partial_conflicts = exact_conflicts + + candidates.extend(partial_candidates) + if partial_chosen is not None: + chosen = partial_chosen + elif partial_conflicts: + failed["candidate_count"] = len(candidates) + failed["candidate_ranges"] = [candidate_range(candidate) for candidate in partial_conflicts] + failed["reason"] = "存在多个同级最高可信候选区间,未自动关联。" + reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": None, "level": "重点复核", "reason": failed["reason"]}) + return failed, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) + failed["candidate_count"] = len(candidates) - if not candidates: + failed["candidate_ranges"] = [candidate_range(candidate) for candidate in candidates if candidate["confidence"]] + if chosen is None and not candidates: reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": None, "level": "重点复核", "reason": failed["reason"]}) return failed, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) - _, correlation, offset_std, offset, matched = max(candidates, key=lambda item: item[0]) - if math.isfinite(correlation) and correlation >= 0.98 and offset_std <= 5: - confidence = "高可信" - elif math.isfinite(correlation) and correlation >= 0.90 and offset_std <= 15: - confidence = "中可信" - else: + if chosen is None: + best = max(candidates, key=lambda item: item["score"]) failed.update( - candidate_count=len(candidates), duration_correlation=correlation, - time_offset_seconds=offset, offset_std_seconds=offset_std, + candidate_count=len(candidates), duration_correlation=best["correlation"], + time_offset_seconds=best["offset"], offset_std_seconds=best["offset_std"], reason="材料顺序匹配,但持续时间相关性或时钟偏移稳定性不足。", ) - reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": correlation, "level": "重点复核", "reason": failed["reason"]}) + reviews.append({"source": "跨源关联", "layer": None, "material": None, "metric": "alignment", "value": best["correlation"], "level": "重点复核", "reason": failed["reason"]}) return failed, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) - matched = matched.copy().reset_index(drop=True) - matched["layer"] = summary["layer"].astype(int).tolist() + + correlation, offset_std, offset = chosen["correlation"], chosen["offset_std"], chosen["offset"] + confidence = chosen["confidence"] + matched_monitor = chosen["monitor"] + matched = chosen["machine"].copy().reset_index(drop=True) + matched["layer"] = matched_monitor["layer"].astype(int).tolist() matched_by_layer = matched.set_index("layer") layer_index = summary["layer"] for suffix in ("mean", "std", "min", "max", "range"): - summary[f"motor_{suffix}"] = layer_index.map(matched_by_layer[f"motor_{suffix}"]) - summary["motor_source"] = "工控log" + mapped = layer_index.map(matched_by_layer[f"motor_{suffix}"]) + summary.loc[mapped.notna(), f"motor_{suffix}"] = mapped[mapped.notna()] + matched_rows = layer_index.isin(matched_by_layer.index) + summary.loc[matched_rows, "motor_source"] = "工控log" summary["target_power_seconds"] = layer_index.map(matched_by_layer["target_seconds"]) summary["target_time_delta"] = summary["target_power_seconds"] - summary["actual_time"] summary["machine_segment_id"] = layer_index.map(matched_by_layer["segment_id"]) @@ -1068,8 +1185,13 @@ def candidate_metrics(monitor: pd.DataFrame, candidate: pd.DataFrame) -> tuple[f "status": "matched", "confidence": confidence, "matched_segments": len(matched), "candidate_count": len(candidates), "duration_correlation": correlation, "time_offset_seconds": offset, "offset_std_seconds": offset_std, + "coverage": chosen["coverage"], "planned_layers": planned_layers, + "completed_layers": completed_layers, "matched_layer_start": chosen["layer_start"], + "matched_layer_end": chosen["layer_end"], + "ignored_machine_edge_segments": chosen["ignored"], + "candidate_ranges": [candidate_range(candidate) for candidate in candidates if candidate["confidence"]], "machine_start": matched["start"].min(), "machine_end": matched["end"].max(), - "reason": "材料顺序、持续时间和时钟偏移满足自动关联条件。", + "reason": "材料顺序、持续时间和时钟偏移满足自动关联条件。" if chosen["coverage"] == "all_completed" else "工控log与监控log的连续层区间满足自动关联条件。", } return alignment, summary, pd.DataFrame(reviews, columns=CROSS_SOURCE_COLUMNS) diff --git a/index.html b/index.html index 86321e8..cc2bf35 100644 --- a/index.html +++ b/index.html @@ -21,7 +21,7 @@ const SESSION_TOKEN=new URLSearchParams(location.search).get('token')||'';const withToken=url=>{if(!SESSION_TOKEN)return url;const join=url.includes('?')?'&':'?';return `${url}${join}token=${encodeURIComponent(SESSION_TOKEN)}`}; const FIELD_META={layer:{label:'层号',digits:0},material:{label:'材料'},method:{label:'监控方式'},phy_thick:{label:'物理厚度',unit:'nm',digits:2},recipe_rate:{label:'理论速率',unit:'nm/s',digits:3},planned_time:{label:'理论时间',unit:'s',digits:1},start_t:{label:'理论 Start T',unit:'%',digits:2,percent:true},end_t:{label:'理论 End T',unit:'%',digits:2,percent:true},recipe_extreme:{label:'理论极值状态'},final_meas:{label:'终点 Meas',unit:'%',digits:2,percent:true},final_calc:{label:'终点动态 Calc',unit:'%',digits:2,percent:true},fit_end_residual:{label:'终点 Meas−动态 Calc',unit:'%',digits:2,percent:true},recipe_end_offset:{label:'Meas−理论 End T',unit:'%',digits:2,percent:true},fit_mae:{label:'全层 MAE',unit:'%',digits:2,percent:true},fit_rmse:{label:'全层 RMSE',unit:'%',digits:2,percent:true},fit_p95_abs:{label:'P95 绝对残差',unit:'%',digits:2,percent:true},fit_max_abs:{label:'最大绝对残差',unit:'%',digits:2,percent:true},actual_time:{label:'实际镀膜时间',unit:'s',digits:1},time_delta:{label:'实际−理论时间',unit:'s',digits:1},actual_rate:{label:'实际速率',unit:'nm/s',digits:3},rate_delta:{label:'实际−理论速率',unit:'nm/s',digits:3},actual_extreme:{label:'实际极值状态'},signal_median:{label:'信号中位数',unit:'%',digits:2,percent:true},signal_std:{label:'信号标准差',unit:'%',digits:2,percent:true},signal_cv:{label:'信号变异系数',unit:'%',digits:2,percent:true},optical_rows:{label:'光学采样数',digits:0},substrate_shutter_mismatch:{label:'基片挡板不一致次数',digits:0},material_shutter_mismatch:{label:'材料挡板不一致次数',digits:0},shutter_mismatch:{label:'挡板不一致次数',digits:0},layer_start:{label:'层开始时间'},layer_end:{label:'层结束时间'},machine_span:{label:'设备记录跨度',unit:'s',digits:1},machine_rows:{label:'设备采样数',digits:0},severity:{label:'级别'},scope:{label:'范围'},issue:{label:'问题'},detail:{label:'详情'},metric:{label:'指标'},value:{label:'数值',digits:4},change:{label:'相邻层变化',digits:4},group:{label:'分组基准'},baseline:{label:'分组中位数',digits:4},mad:{label:'MAD',digits:4},robust_z:{label:'稳健 Z 分数',digits:2},level:{label:'复核等级'},reason:{label:'异常依据'},source:{label:'数据源'},field:{label:'字段'},count:{label:'样本数',digits:0},mean:{label:'均值',digits:4},min:{label:'最小值',digits:4},max:{label:'最大值',digits:4},SamTime:{label:'采样时间',unit:'s'},thickness:{label:'理论厚度',unit:'nm'},calculated_t:{label:'理论透过率',unit:'%',percent:true}}; const DEVICE_BASE={power:{label:'有效功率',unit:'kW',digits:3},current:{label:'有效电流',unit:'A',digits:3},voltage:{label:'有效电压',unit:'V',digits:2},vacuum:{label:'腔体真空',unit:'日志原始值',digits:4},chamber_temp:{label:'腔体温度',unit:'°C',digits:2},water_in:{label:'进水温度',unit:'°C',digits:2},water_out:{label:'出水温度',unit:'°C',digits:2},motor:{label:'转速',unit:'rpm',digits:1},o2:{label:'O₂',unit:'日志原始值',digits:3},ar:{label:'Ar',unit:'日志原始值',digits:3}},SUFFIX={mean:'均值',std:'标准差',min:'最小值',max:'最大值',range:'范围'};for(const [base,b] of Object.entries(DEVICE_BASE))for(const [suffix,s] of Object.entries(SUFFIX))FIELD_META[`${base}_${suffix}`]={label:`${b.label}${s}`,unit:b.unit,digits:b.digits}; -Object.assign(FIELD_META,{session_id:{label:'区段编号',digits:0},session_name:{label:'区段'},start:{label:'开始时间'},end:{label:'结束时间'},elapsed_seconds:{label:'记录跨度',unit:'s',digits:0},target_seconds:{label:'靶材工作时间',unit:'s',digits:0},is_default:{label:'默认区段'},rows:{label:'采样数',digits:0},category:{label:'类别'},time:{label:'开始时间'},end_time:{label:'结束时间'},motor_source:{label:'转速来源'},target_power_seconds:{label:'靶材工作时间',unit:'s',digits:0},target_time_delta:{label:'靶材工作时间 - 实际镀膜时间',unit:'s',digits:0},machine_segment_id:{label:'工控工作时段',digits:0},alignment:{label:'关联状态'},confidence:{label:'关联状态'},matched_segments:{label:'镀膜层数',digits:0},candidate_count:{label:'候选序列数',digits:0},duration_correlation:{label:'持续时间相关系数',digits:5},time_offset_seconds:{label:'时钟偏移',unit:'s',digits:1},offset_std_seconds:{label:'偏移标准差',unit:'s',digits:2},sample_gap:{label:'采样时间缺口',unit:'s',digits:0},simultaneous_power:{label:'H/L 同时工作',unit:'s',digits:0},'H Power':{label:'H 材料功率',unit:'kW',digits:3},'L Power':{label:'L 材料功率',unit:'kW',digits:3},'H Current':{label:'H 材料电流',unit:'A',digits:3},'L Current':{label:'L 材料电流',unit:'A',digits:3},'H Voltage':{label:'H 材料电压',unit:'V',digits:2},'L Voltage':{label:'L 材料电压',unit:'V',digits:2},'Motor Speed':{label:'转速',unit:'rpm',digits:1},'Chammber Vacuum':{label:'腔体真空',unit:'日志原始值',digits:6},'Pipe Vacuum':{label:'管道真空',unit:'日志原始值',digits:6},'Chammber Temperture':{label:'腔体温度',unit:'°C',digits:1},'Water-In':{label:'进水温度',unit:'°C',digits:2},'Water-Out':{label:'出水温度',unit:'°C',digits:2},'Gas-O2(1)':{label:'O₂ 通道 1',unit:'日志原始值',digits:3},'Gas-O2(4)':{label:'O₂ 通道 4',unit:'日志原始值',digits:3},'Gas-Ar(2)':{label:'Ar 通道 2',unit:'日志原始值',digits:3},'Gas-Ar(3)':{label:'Ar 通道 3',unit:'日志原始值',digits:3}});for(const suffix of Object.keys(SUFFIX))FIELD_META[`monitor_motor_${suffix}`]={label:`监控log转速参考值${SUFFIX[suffix]}`,unit:'rpm',digits:1}; +Object.assign(FIELD_META,{session_id:{label:'区段编号',digits:0},session_name:{label:'区段'},start:{label:'开始时间'},end:{label:'结束时间'},elapsed_seconds:{label:'记录跨度',unit:'s',digits:0},target_seconds:{label:'靶材工作时间',unit:'s',digits:0},is_default:{label:'默认区段'},rows:{label:'采样数',digits:0},category:{label:'类别'},time:{label:'开始时间'},end_time:{label:'结束时间'},motor_source:{label:'转速来源'},target_power_seconds:{label:'靶材工作时间',unit:'s',digits:0},target_time_delta:{label:'靶材工作时间 - 实际镀膜时间',unit:'s',digits:0},machine_segment_id:{label:'工控工作时段',digits:0},alignment:{label:'关联状态'},confidence:{label:'关联状态'},coverage:{label:'关联覆盖'},planned_layers:{label:'理论层数',digits:0},completed_layers:{label:'已完成层数',digits:0},matched_segments:{label:'已关联层数',digits:0},matched_layer_start:{label:'关联起始层',digits:0},matched_layer_end:{label:'关联结束层',digits:0},matched_layer_range:{label:'关联层范围'},ignored_machine_edge_segments:{label:'忽略的首尾工控时段'},candidate_ranges:{label:'候选层范围'},candidate_count:{label:'候选序列数',digits:0},duration_correlation:{label:'持续时间相关系数',digits:5},time_offset_seconds:{label:'时钟偏移',unit:'s',digits:1},offset_std_seconds:{label:'偏移标准差',unit:'s',digits:2},sample_gap:{label:'采样时间缺口',unit:'s',digits:0},simultaneous_power:{label:'H/L 同时工作',unit:'s',digits:0},'H Power':{label:'H 材料功率',unit:'kW',digits:3},'L Power':{label:'L 材料功率',unit:'kW',digits:3},'H Current':{label:'H 材料电流',unit:'A',digits:3},'L Current':{label:'L 材料电流',unit:'A',digits:3},'H Voltage':{label:'H 材料电压',unit:'V',digits:2},'L Voltage':{label:'L 材料电压',unit:'V',digits:2},'Motor Speed':{label:'转速',unit:'rpm',digits:1},'Chammber Vacuum':{label:'腔体真空',unit:'日志原始值',digits:6},'Pipe Vacuum':{label:'管道真空',unit:'日志原始值',digits:6},'Chammber Temperture':{label:'腔体温度',unit:'°C',digits:1},'Water-In':{label:'进水温度',unit:'°C',digits:2},'Water-Out':{label:'出水温度',unit:'°C',digits:2},'Gas-O2(1)':{label:'O₂ 通道 1',unit:'日志原始值',digits:3},'Gas-O2(4)':{label:'O₂ 通道 4',unit:'日志原始值',digits:3},'Gas-Ar(2)':{label:'Ar 通道 2',unit:'日志原始值',digits:3},'Gas-Ar(3)':{label:'Ar 通道 3',unit:'日志原始值',digits:3}});for(const suffix of Object.keys(SUFFIX))FIELD_META[`monitor_motor_${suffix}`]={label:`监控log转速参考值${SUFFIX[suffix]}`,unit:'rpm',digits:1}; const DEVICE_GROUPS={电气:['power_mean','current_mean','voltage_mean'],环境:['vacuum_mean'],温度:['chamber_temp_mean','water_in_mean','water_out_mean'],转速:['motor_mean'],气体:['o2_mean','ar_mean']},COLORS=['#075e91','#c5691a','#6d68a8','#267451']; const NAV={combined:[['combined-overview','总览'],['combined-device','设备']],machine:[['machine-overview','总览'],['machine-trend','设备']],monitor:[['overview','总览'],['theory','理论趋势'],['optical','光学'],['device','设备'],['anomaly','异常复核']]}; const MACHINE_METRICS={power:{label:'功率',unit:'kW',group:'电气',api:'power_mean',columns:['H Power','L Power']},current:{label:'电流',unit:'A',group:'电气',api:'current_mean',columns:['H Current','L Current']},voltage:{label:'电压',unit:'V',group:'电气',api:'voltage_mean',columns:['H Voltage','L Voltage']},'Chammber Vacuum':{label:'腔体真空',unit:'日志原始值',group:'真空',api:'vacuum_mean',columns:['Chammber Vacuum']},'Pipe Vacuum':{label:'管道真空',unit:'日志原始值',group:'真空',api:'pipe_vacuum',columns:['Pipe Vacuum']},'Chammber Temperture':{label:'腔体温度',unit:'°C',group:'温度',api:'chamber_temp_mean',columns:['Chammber Temperture']},'Water-In':{label:'进水温度',unit:'°C',group:'温度',api:'water_in_mean',columns:['Water-In']},'Water-Out':{label:'出水温度',unit:'°C',group:'温度',api:'water_out_mean',columns:['Water-Out']},'Motor Speed':{label:'转速',unit:'rpm',group:'转速',api:'motor_mean',columns:['Motor Speed']},o2:{label:'O₂',unit:'日志原始值',group:'气体',api:'o2_mean',columns:['Gas-O2(1)','Gas-O2(4)']},ar:{label:'Ar',unit:'日志原始值',group:'气体',api:'ar_mean',columns:['Gas-Ar(2)','Gas-Ar(3)']}}; @@ -33,7 +33,7 @@ class ApiError extends Error{constructor(message,code='request_failed',recovery='请稍后重试。'){super(message);this.code=code;this.recovery=recovery}}function setService(state,text){$('service').dataset.state=state;$('status').textContent=text}function setBusy(kind,on,text){if(kind==='choose')$('choose').disabled=on;else{$('choose').disabled=$('analyze').disabled=on;$('threshold').disabled=on}$('skeleton').classList.toggle('hidden',!(on&&kind==='analyze'));setService(on?'busy':'online',text||'本地服务已连接')}async function api(url,options={}){let r;const headers={...(options.headers||{})};if(SESSION_TOKEN)headers['X-Log-Analysis-Token']=SESSION_TOKEN;try{r=await fetch(url,{...options,headers})}catch(error){if(error?.name==='AbortError')throw error;throw new ApiError('无法连接本地分析服务。','service_unavailable','请重新启动 Log Analysis,然后点击“重新连接”。')}let d;try{d=await r.json()}catch{throw new ApiError('本地服务返回了无法识别的响应。','invalid_response','请重新启动分析工具后重试。')}if(!r.ok)throw new ApiError(d.error||'请求未完成。',d.code||'request_failed',d.recovery||'请检查炉次文件夹后重试。');return d} function resetAnalysisView(){if(viewState.focused)toggleChartFocus(viewState.focused.querySelector('.focus-chart'));payload=null;Object.assign(viewState,{scope:'combined',activeByScope:{combined:'combined-overview',machine:'machine-overview',monitor:'overview'},active:'combined-overview',selectedLayer:1,opticalResidual:'fit_end_residual',opticalSignal:'signal_median',deviceMetric:'power_mean',anomalyMetric:'',combinedDeviceAxis:'layer',machineMetric:'power',machineSession:''});$('controls').classList.add('hidden');$('source-switch').classList.add('hidden');$('sidebar').classList.add('hidden');$('modules').replaceChildren();$('scope-options').replaceChildren();$('layer-min').value=$('layer-max').value='';$('threshold').value='3.5';$('material').replaceChildren();$('method').replaceChildren();$('page').replaceChildren();$('empty').classList.remove('hidden');$('empty').querySelector('h2').textContent='日志文件夹已选择,请开始分析';$('empty').querySelector('p').textContent='已清空上一份分析结果。原始日志保持只读。'} function availableScopes(){const s=payload?.sources||{};return s.combined?['combined','monitor','machine']:s.machine?['machine']:['monitor']} -function alignmentLabel(alignment=payload?.alignment||{}){if(alignment.status!=='matched')return '关联失败';return alignment.confidence==='中可信'?'已关联(待复核)':'已关联'} +function alignmentLabel(alignment=payload?.alignment||{}){if(alignment.status!=='matched')return '关联失败';const pending=alignment.confidence==='中可信',partial=alignment.coverage==='partial_completed';if(partial)return pending?'已关联(部分区间,待复核)':'已关联(部分区间)';return pending?'已关联(待复核)':'已关联'} function renderSourceSwitch(){const names={combined:'综合',machine:'工控log',monitor:'监控log'},available=availableScopes();$('scope-options').innerHTML=available.map(scope=>``).join('');const s=payload.sources;$('source-note').textContent=s.combined?`${alignmentLabel()} ${payload.alignment.matched_segments||0} 个镀膜层`:s.machine?'当前仅包含工控log':'当前仅包含监控log';$('source-switch').classList.remove('hidden')} function renderNavigation(){const items=NAV[viewState.scope]||[];if(!items.some(([key])=>key===viewState.active))viewState.active=viewState.activeByScope[viewState.scope]||items[0]?.[0];$('modules').innerHTML=items.map(([key,name])=>``).join('')} function setScope(scope){if(!availableScopes().includes(scope))return;viewState.activeByScope[viewState.scope]=viewState.active;viewState.scope=scope;viewState.active=viewState.activeByScope[scope]||NAV[scope][0][0];renderSourceSwitch();renderNavigation();updateToolbar();renderActivePage()} @@ -140,7 +140,7 @@ } function renderMachineTrend(){cancelDeviceSeries();const available=availableMachineMetrics();if(!available.includes(viewState.machineMetric))viewState.machineMetric=available[0]||'power';const spec=MACHINE_METRICS[viewState.machineMetric];$('page').innerHTML=pageHeader('设备','按区段与材料查看工控采样;关联成功时显示镀膜层')+`
${chartPanel('machine-trend-chart',spec.label,{className:'tall'})}
`;drawDeviceTimeSeries('machine-trend-chart',spec.api,['machine'],spec.label,spec.unit,{markersOnly:true}).catch(showError);document.querySelectorAll('[data-machine-metric]').forEach(button=>button.onclick=()=>{viewState.machineMetric=button.dataset.machineMetric;renderMachineTrend()})} function alignmentBadge(){const a=payload.alignment||{},cls=a.status!=='matched'?'danger':a.confidence==='高可信'?'success':'warning';return `${esc(alignmentLabel(a))}`} -function renderCombinedOverview(r){const a=payload.alignment||{},displayAlignment={...a,confidence:alignmentLabel(a)},layers=new Set(r.map(row=>Number(row.layer))),segments=(payload.machine_segments||[]).filter(row=>row.layer!=null&&layers.has(Number(row.layer))),matched=r.filter(row=>row.target_power_seconds!=null&&Number.isFinite(Number(row.target_power_seconds))),start=segments.length?segments.reduce((value,row)=>new Date(row.start)new Date(row.end)>new Date(value)?row.end:value,segments[0].end):null,wallSeconds=start&&end?(new Date(end)-new Date(start))/1000:null,h=r.filter(row=>row.material==='H').length,l=r.filter(row=>row.material==='L').length;$('page').innerHTML=pageHeader('综合总览','当前筛选双源统计与整炉关联状态')+`
${alignmentBadge()}整炉关联:${esc(a.reason||'暂无关联说明')}
当前监控层数${r.length}H ${h} / L ${l}
当前镀膜层数${matched.length}整炉偏移 ${formatValue('target_time_delta',a.time_offset_seconds)}
当前实际镀膜时间${fmtSec(sumField(r,'actual_time'))}来自监控log
当前靶材工作时间${fmtSec(sumField(r,'target_power_seconds'))}来自工控log
当前镀膜层墙钟时间${fmtSec(wallSeconds)}工控首层开始至末层结束
${chartPanel('combined-overview-chart','实际镀膜时间与靶材工作时间',{className:'tall'})}
整炉关联诊断${table([displayAlignment],['confidence','matched_segments','candidate_count','duration_correlation','time_offset_seconds','offset_std_seconds','reason'])}
`;plot('combined-overview-chart',[lineTrace(r,'actual_time','监控实际镀膜时间',COLORS[0]),lineTrace(r,'target_power_seconds','工控靶材工作时间',COLORS[1])],{title:'实际镀膜时间与靶材工作时间',yTitle:'时间(s)'})} +function renderCombinedOverview(r){const a=payload.alignment||{},range=a.matched_layer_start!=null&&a.matched_layer_end!=null?`${a.matched_layer_start}–${a.matched_layer_end}层`:'暂无',ranges=(a.candidate_ranges||[]).map(item=>`${item.layer_start}–${item.layer_end}层${item.confidence?`(${item.confidence})`:''}`).join(' / ')||'无',displayAlignment={...a,confidence:alignmentLabel(a),coverage:a.coverage==='all_completed'?'全部已完成层':a.coverage==='partial_completed'?'部分已完成层':'未关联',matched_layer_range:range,ignored_machine_edge_segments:(a.ignored_machine_edge_segments||[]).join('、')||'无',candidate_ranges:ranges},layers=new Set(r.map(row=>Number(row.layer))),segments=(payload.machine_segments||[]).filter(row=>row.layer!=null&&layers.has(Number(row.layer))),completed=r.filter(row=>row.layer_start&&row.layer_end&&Number.isFinite(Number(row.actual_time))),matched=r.filter(row=>row.target_power_seconds!=null&&Number.isFinite(Number(row.target_power_seconds))),start=segments.length?segments.reduce((value,row)=>new Date(row.start)new Date(row.end)>new Date(value)?row.end:value,segments[0].end):null,wallSeconds=start&&end?(new Date(end)-new Date(start))/1000:null,h=r.filter(row=>row.material==='H').length,l=r.filter(row=>row.material==='L').length;$('page').innerHTML=pageHeader('综合总览','当前筛选双源统计与整炉关联状态')+`
${alignmentBadge()}整炉关联:${esc(a.reason||'暂无关联说明')}
当前理论层数${r.length}H ${h} / L ${l}
当前已完成层数${completed.length}具备完整镀膜时间
当前已关联层数${matched.length}工控log已映射
整炉关联范围${range}${esc(alignmentLabel(a))}
当前实际镀膜时间${fmtSec(sumField(completed,'actual_time'))}来自监控log
当前靶材工作时间${fmtSec(sumField(matched,'target_power_seconds'))}来自工控log
当前镀膜层墙钟时间${fmtSec(wallSeconds)}已关联首层开始至末层结束
${chartPanel('combined-overview-chart','实际镀膜时间与靶材工作时间',{className:'tall'})}
整炉关联诊断${table([displayAlignment],['confidence','coverage','planned_layers','completed_layers','matched_segments','matched_layer_range','ignored_machine_edge_segments','candidate_ranges','candidate_count','duration_correlation','time_offset_seconds','offset_std_seconds','reason'])}
`;plot('combined-overview-chart',[lineTrace(r,'actual_time','监控实际镀膜时间',COLORS[0]),lineTrace(r,'target_power_seconds','工控靶材工作时间',COLORS[1])],{title:'实际镀膜时间与靶材工作时间',yTitle:'时间(s)'})} function renderCombinedDevice(r){ cancelDeviceSeries(); const available=Object.values(DEVICE_GROUPS).flat().filter(key=>r.some(x=>x[key]!=null)); diff --git a/installer/LogAnalysis.iss b/installer/LogAnalysis.iss index 859cb5e..8ca73dc 100644 --- a/installer/LogAnalysis.iss +++ b/installer/LogAnalysis.iss @@ -1,6 +1,6 @@ #define MyAppName "Log Analysis" #ifndef MyAppVersion - #define MyAppVersion "0.1.0-beta.7" + #define MyAppVersion "0.1.0-beta.8" #endif #define MyNumericVersion "0.1.0.0" #define MyAppPublisher "Reyppp" diff --git a/site/demo.js b/site/demo.js index f701eb8..c3fadaf 100644 --- a/site/demo.js +++ b/site/demo.js @@ -13,10 +13,10 @@ }; const details = { - "scope-combined": ["综合", "双源关联", "自动匹配工控工作时段与监控镀膜层,并校正时钟偏移。", "需要在同一时间或层号范围内核对两类日志时使用。", "选择综合,再通过总览确认关联状态并进入设备模块。", "关联失败时不会强制合并,两类日志仍可独立分析。"], + "scope-combined": ["综合", "双源关联", "按实际完成层匹配工控工作时段与监控镀膜层,并校正时钟偏移。", "核对完整、未完成或仅提供局部工控时间段的双源日志时使用。", "选择综合,再通过总览确认关联状态、层数和范围。", "局部时间段仅在候选区间唯一时自动关联;关联失败时两类日志仍可独立分析。"], "scope-monitor": ["监控log", "逐层分析", "关联理论、光学、设备记录和统计复核提示。", "需要按镀膜层复核理论设定、光学曲线或设备变化时使用。", "选择监控log,再从左侧进入五个逐层分析模块。", "监控log以层为核心,不包含连续工控时间轴。"], "scope-machine": ["工控log", "连续设备记录", "合并连续日期文件并展示设备的实际采样点。", "需要核对功率、真空、温度、气体或转速的连续变化时使用。", "选择工控log,再按材料、区段和设备指标筛选。", "仅有工控log时不推测镀膜层;双源关联后可显示对应层。"], - "combined-alignment": ["综合总览", "双源关联", "汇总两类日志的完整性、镀膜层匹配和时钟偏移。", "开始跨源设备对照前确认关联结果时使用。", "查看关联状态、匹配层数和时钟偏移,再进入设备模块。", "关联状态描述数据匹配结果,不代表工艺质量。"], + "combined-alignment": ["综合总览", "双源关联", "汇总理论层、已完成层、已关联层、关联范围和时钟偏移。", "开始跨源设备对照前确认关联覆盖范围时使用。", "查看关联状态、层数和范围,再进入设备模块。", "未完成尾部理论层继续保留;关联状态描述数据匹配结果,不代表工艺质量。"], "combined-motor": ["综合总览", "转速来源", "综合模式以工控log的 Motor Speed 作为逐层转速。", "核对监控log与工控log转速差异时使用。", "查看工控log权威值,并保留监控log参考值用于审计。", "其他设备字段不互相覆盖,仍按数据源分别展示。"], "combined-device": ["综合设备", "双源设备对照", "按镀膜层或时间对照工控log与监控log设备数据。", "排查同一层或同一时刻的跨源差异时使用。", "选择横坐标与设备指标,再使用层范围、材料和监控方式筛选。", "时间图展示日志中的实际采样点,不计算插值。"], "machine-samples": ["工控log总览", "实际采样点", "统计当前筛选范围内的连续设备记录。", "确认日期文件覆盖范围和可用数据量时使用。", "选择材料或镀膜区段后查看采样点与工作时间变化。", "采样点来自原始日志;趋势展示会优先保留真实边界与极值。"], diff --git a/site/index.html b/site/index.html index 0750a9c..ef3a942 100644 --- a/site/index.html +++ b/site/index.html @@ -36,16 +36,16 @@

单炉次镀膜日志分析

-

自动关联工控log与监控log,汇总逐层理论、光学与设备数据,定位需复核层。全程本地运行,原始日志只读。

+

自动关联工控log与监控log,汇总逐层理论、光学与设备数据。支持未完成炉次和局部工控时间段,全程本地只读运行。

运行方式
Windows 10/11 x64
数据边界
本地只读,不上传
-
当前版本
v0.1.0-beta.7
+
当前版本
v0.1.0-beta.8
@@ -77,7 +77,7 @@

单炉次镀膜日志分析

三种分析范围,同一工作台

-

程序自动识别工控log与监控log。切换分析范围后,模块、筛选条件和结果在同一界面内更新。演示使用合成数据。

+

程序自动识别工控log与监控log。完整炉次按全部已完成层关联;局部工控时间段匹配唯一的连续监控层区间。演示使用合成数据。

@@ -115,7 +115,7 @@

三种分析范围,同一工作台

综合总览

双源完整性、关联状态与当前筛选统计

合成数据
-
已关联工控log与监控log已匹配 180 个镀膜层
+
已关联工控log与监控log已匹配全部已完成层
镀膜层数180关联结果
@@ -234,20 +234,20 @@

双源关联

-

安装 Windows 测试版

v0.1.0-beta.7 修复重定向桌面快捷方式与本地打包环境污染问题。当前版本未进行代码签名。已知问题:部分环境下单层详情图表或原始截图可能不显示。

+

安装 Windows 测试版

v0.1.0-beta.8 支持未完成炉次与局部工控时间段关联,并保留快捷方式和打包可靠性修复。当前版本未进行代码签名。已知问题:部分环境下单层详情图表或原始截图可能不显示。

  1. 1
    关闭旧版本

    升级前先退出所有正在运行的 Log Analysis 窗口。

  2. -
  3. 2
    下载两个文件

    同时下载 Log-Analysis-Setup-v0.1.0-beta.7-x64.exe 和 SHA256SUMS.txt。

  4. -
  5. 3
    核对 SHA-256

    在下载目录运行 Get-FileHash -Algorithm SHA256 .\Log-Analysis-Setup-v0.1.0-beta.7-x64.exe,与校验文件比较。

  6. +
  7. 2
    下载两个文件

    同时下载 Log-Analysis-Setup-v0.1.0-beta.8-x64.exe 和 SHA256SUMS.txt。

  8. +
  9. 3
    核对 SHA-256

    在下载目录运行 Get-FileHash -Algorithm SHA256 .\Log-Analysis-Setup-v0.1.0-beta.8-x64.exe,与校验文件比较。

  10. 4
    选择安装目录

    双击安装程序并选择位置;桌面快捷方式可选,失败时不影响应用安装。

  11. 5
    处理测试版提示

    哈希匹配后,如 SmartScreen 警告可选择“更多信息”并核对文件名;组织策略阻止时请联系管理员。

  12. 6
    选择炉次并分析

    从开始菜单打开应用,点击“选择文件夹”,再点击“开始分析”。

diff --git a/test_analyzer.py b/test_analyzer.py index c3314cb..085f46e 100644 --- a/test_analyzer.py +++ b/test_analyzer.py @@ -31,6 +31,64 @@ GOLDEN_FOLDER = Path(GOLDEN_PATH) if GOLDEN_PATH else None +def alignment_frames(planned=120, completed=100, machine_layers=None, edge_materials=()): + start = pd.Timestamp("2026-08-20 12:00:00") + durations = [80 + ((index * 37) % 41) + index / 10 for index in range(planned)] + materials = ["H" if index % 2 == 0 else "L" for index in range(planned)] + monitor_rows = [] + cursor = start + for index in range(planned): + is_complete = index < completed + duration = durations[index] + monitor_rows.append( + { + "layer": index + 1, "material": materials[index], + "layer_start": cursor if is_complete else pd.NaT, + "layer_end": cursor + pd.Timedelta(seconds=duration) if is_complete else pd.NaT, + "actual_time": duration if is_complete else None, + **{f"motor_{suffix}": 1280.0 for suffix in ("mean", "std", "min", "max", "range")}, + } + ) + cursor += pd.Timedelta(seconds=duration + 20) + + selected = list(machine_layers or range(1, completed + 1)) + segment_rows = [] + segment_id = 1 + for position, material in enumerate(edge_materials[:1]): + segment_rows.append( + { + "segment_id": segment_id, "session_id": 1, "material": material, + "start": start + pd.Timedelta(seconds=20 + position), + "end": start + pd.Timedelta(seconds=30 + position), "target_seconds": 10, + **{f"motor_{suffix}": 999.0 for suffix in ("mean", "std", "min", "max", "range")}, + } + ) + segment_id += 1 + for layer in selected: + row = monitor_rows[layer - 1] + machine_start = row["layer_start"] + pd.Timedelta(seconds=180) + segment_rows.append( + { + "segment_id": segment_id, "session_id": 1, "material": row["material"], + "start": machine_start, "end": machine_start + pd.Timedelta(seconds=row["actual_time"] - 4), + "target_seconds": row["actual_time"], + **{f"motor_{suffix}": 280.0 for suffix in ("mean", "std", "min", "max", "range")}, + } + ) + segment_id += 1 + for position, material in enumerate(edge_materials[1:]): + edge_start = segment_rows[-1]["end"] + pd.Timedelta(seconds=20 + position) + segment_rows.append( + { + "segment_id": segment_id, "session_id": 1, "material": material, + "start": edge_start, "end": edge_start + pd.Timedelta(seconds=10), "target_seconds": 10, + **{f"motor_{suffix}": 999.0 for suffix in ("mean", "std", "min", "max", "range")}, + } + ) + segment_id += 1 + return pd.DataFrame(monitor_rows), pd.DataFrame(segment_rows) + + class AnalyzerTests(unittest.TestCase): def test_mixed_clock(self): self.assertEqual(parse_mixed_datetime("2026-08-20 13:00:00 PM"), datetime(2026, 8, 20, 13, 0, 0)) @@ -386,6 +444,7 @@ def test_combined_alignment_overrides_motor_and_keeps_reference(self): { "layer": range(1, 7), "material": materials, "layer_start": [start + pd.Timedelta(seconds=index * 120) for index in range(6)], + "layer_end": [start + pd.Timedelta(seconds=index * 120 + duration) for index, duration in enumerate([80, 90, 100, 110, 120, 130])], "actual_time": [80, 90, 100, 110, 120, 130], **{f"motor_{suffix}": [1280.0] * 6 for suffix in ("mean", "std", "min", "max", "range")}, } @@ -412,6 +471,72 @@ def test_combined_alignment_overrides_motor_and_keeps_reference(self): self.assertEqual(set(combined["motor_mean"]), {280.0}) self.assertEqual(set(combined["monitor_motor_mean"]), {1280.0}) + def test_unfinished_run_matches_all_completed_layers_and_keeps_planned_tail(self): + monitor, segments = alignment_frames(planned=120, completed=100) + alignment, combined, _ = _align_sources(monitor, segments) + + self.assertEqual(alignment["status"], "matched") + self.assertEqual(alignment["coverage"], "all_completed") + self.assertEqual(alignment["planned_layers"], 120) + self.assertEqual(alignment["completed_layers"], 100) + self.assertEqual((alignment["matched_layer_start"], alignment["matched_layer_end"]), (1, 100)) + self.assertEqual(set(combined.loc[:99, "motor_source"]), {"工控log"}) + self.assertEqual(set(combined.loc[100:, "motor_source"]), {"监控log参考值"}) + self.assertTrue(combined.loc[100:, "target_power_seconds"].isna().all()) + + def test_partial_machine_log_matches_unique_monitor_window(self): + monitor, segments = alignment_frames(planned=120, completed=100, machine_layers=range(41, 101)) + alignment, combined, _ = _align_sources(monitor, segments) + + self.assertEqual(alignment["status"], "matched") + self.assertEqual(alignment["confidence"], "高可信") + self.assertEqual(alignment["coverage"], "partial_completed") + self.assertEqual((alignment["matched_layer_start"], alignment["matched_layer_end"]), (41, 100)) + self.assertEqual(alignment["matched_segments"], 60) + self.assertEqual(set(combined.loc[40:99, "motor_source"]), {"工控log"}) + self.assertEqual(set(combined.loc[:39, "motor_source"]), {"监控log参考值"}) + + def test_partial_machine_log_can_ignore_one_segment_at_each_edge(self): + monitor, segments = alignment_frames( + planned=120, completed=100, machine_layers=range(41, 101), edge_materials=("L", "H"), + ) + cases = { + "first": (segments.iloc[:-1].copy(), [1]), + "last": (segments.iloc[1:].copy(), [62]), + "both": (segments.copy(), [1, 62]), + } + for name, (candidate, ignored) in cases.items(): + with self.subTest(name=name): + alignment, _, _ = _align_sources(monitor, candidate) + self.assertEqual(alignment["status"], "matched") + self.assertEqual((alignment["matched_layer_start"], alignment["matched_layer_end"]), (41, 100)) + self.assertEqual(alignment["ignored_machine_edge_segments"], ignored) + + def test_unfinished_run_under_thirty_layers_is_not_auto_matched(self): + monitor, segments = alignment_frames(planned=50, completed=20) + alignment, _, _ = _align_sources(monitor, segments) + self.assertEqual(alignment["status"], "failed") + self.assertIn("少于 30", alignment["reason"]) + + def test_internal_monitor_time_gap_is_not_auto_matched(self): + monitor, segments = alignment_frames(planned=50, completed=40) + monitor.loc[20, ["layer_start", "layer_end", "actual_time"]] = [pd.NaT, pd.NaT, None] + alignment, _, _ = _align_sources(monitor, segments) + self.assertEqual(alignment["status"], "failed") + self.assertIn("时间数据缺口", alignment["reason"]) + + def test_multiple_high_confidence_partial_candidates_require_review(self): + monitor, first = alignment_frames(planned=60, completed=60, machine_layers=range(1, 31)) + second = first.copy() + second["segment_id"] += 100 + second["session_id"] = 2 + second["start"] += pd.Timedelta(seconds=180) + second["end"] += pd.Timedelta(seconds=180) + alignment, _, _ = _align_sources(monitor, pd.concat([first, second], ignore_index=True)) + self.assertEqual(alignment["status"], "failed") + self.assertIn("多个同级最高可信候选", alignment["reason"]) + self.assertGreaterEqual(len(alignment["candidate_ranges"]), 2) + def test_combined_alignment_skips_non_coating_segments_between_monitor_runs(self): start = pd.Timestamp("2026-06-15 21:00:00") groups = [ @@ -587,7 +712,12 @@ def test_html_has_folder_picker_layer_navigation_and_smooth_device_curve(self): self.assertIn("if(material==='H')return COLORS[0]", html) self.assertIn("if(material==='L')return COLORS[1]", html) self.assertIn("splitByMaterial=!['o2_mean','ar_mean'].includes(metric)", html) - self.assertIn("alignment.confidence==='中可信'?'已关联(待复核)':'已关联'", html) + self.assertIn("alignment.coverage==='partial_completed'", html) + self.assertIn("已关联(部分区间,待复核)", html) + self.assertIn("当前理论层数", html) + self.assertIn("当前已完成层数", html) + self.assertIn("当前已关联层数", html) + self.assertIn("整炉关联范围", html) self.assertNotIn("原始时间 %{", html) self.assertNotIn("实际时间 %{", html) self.assertNotIn("#62afd2", html) diff --git a/version.py b/version.py index e358f6f..fac022b 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ APP_NAME = "Log Analysis" -APP_VERSION = "0.1.0-beta.7" +APP_VERSION = "0.1.0-beta.8" BUILD_CHANNEL = "beta" REPOSITORY_URL = "https://github.com/Reyppp/log-analysis" RELEASES_URL = f"{REPOSITORY_URL}/releases"