Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ release/
*.p12
settings.json
SHA256SUMS.txt
context-summary.md
!site/assets/*.png
!site/assets/*.jpg
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Log Analysis 将分散的镀膜日志只读汇总为可筛选、可视化、可
- 保留总览、理论趋势、光学、设备、异常关联五个模块和 CSV 导出。
- 中文界面为主,核心入口保持 `analyze_folder(path) -> AnalysisResult`。
- 第一版只支持 Windows x64,不提供在线分析、数据库、账号或自动更新。
- 首个二进制版本是未签名测试版 `v0.1.0-beta.1`。
- 当前二进制版本是未签名测试版 `v0.1.0-beta.3`。

## Brand Commitments

Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,38 @@

## 安装

从 [GitHub Releases](https://github.com/Reyppp/log-analysis/releases) 下载 Windows x64 安装程序,并使用同一版本的 `SHA256SUMS.txt` 核对文件。
### 系统要求

首个 `v0.1.0-beta.1` 是未签名测试版,Windows SmartScreen 或组织安全策略可能发出警告。运行需要 Windows 10/11 x64 和 Microsoft Edge WebView2 Runtime。
- Windows 10/11 x64。
- Microsoft Edge WebView2 Runtime;Windows 10/11 通常已经安装,缺失时安装程序会显示下载指引。

### 下载与校验

1. 升级前先关闭所有正在运行的 Log Analysis 窗口。
2. 下载 [Log Analysis v0.1.0-beta.3 安装程序](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.3/Log-Analysis-Setup-v0.1.0-beta.3-x64.exe) 和同一版本的 [SHA256SUMS.txt](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.3/SHA256SUMS.txt)。也可以先查看[完整版本说明](https://github.com/Reyppp/log-analysis/releases/tag/v0.1.0-beta.3)。
3. 在下载目录打开 PowerShell,运行:

```powershell
Get-FileHash -Algorithm SHA256 .\Log-Analysis-Setup-v0.1.0-beta.3-x64.exe
Get-Content .\SHA256SUMS.txt
```

4. 确认两处显示的 64 位 SHA-256 完全一致;不一致时不要运行安装程序,请重新下载。

### 安装与首次使用

1. 双击 `Log-Analysis-Setup-v0.1.0-beta.3-x64.exe`。
2. 按安装向导继续;程序默认安装到当前用户目录,不需要管理员权限,桌面快捷方式可选。
3. 当前版本是未签名测试版。若 SmartScreen 显示“Windows 已保护你的电脑”,请先确认 SHA-256 已匹配,再选择“更多信息”,核对文件名后选择“仍要运行”。如果组织安全策略禁止运行,请联系管理员,不要关闭系统安全功能。
4. 安装完成后,从开始菜单打开 **Log Analysis**。
5. 点击“选择文件夹”,选择炉次日志目录,再点击“开始分析”。所有数据只在本机处理。

覆盖安装会保留上次选择的炉次路径。卸载默认保留设置,也可以在卸载时选择同时删除。

### 已知限制

- 这是未签名测试版,SmartScreen 或组织安全策略可能发出警告。
- 部分安装环境中,单层详情图表或原始截图可能不显示;该问题仍在修复,不影响逐层汇总和其他全炉分析页面。

## 开发运行

Expand Down
15 changes: 13 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
# Log Analysis v0.1.0-beta.1
# Log Analysis v0.1.0-beta.3

首个公开测试版,将镀膜日志分析工作台封装为 Windows x64 独立应用。
文件夹选择与性能稳定性测试版,使用 Windows 原生资源管理器选择炉次目录。

## 主要功能

- 本地只读导入单炉次日志文件夹。
- 总览、理论趋势、光学、设备、异常关联五个分析模块。
- 单层曲线、理论曲线、原始截图和前后切层。
- UTF-8 BOM 逐层汇总与异常清单导出。
- 新炉次选择后自动清空旧分析结果。
- 安装版使用 Windows 原生资源管理器式文件夹选择窗口,并自动定位上次目录。
- 文件未变化时复用会话内结果,异常阈值调整不再重读全部 CSV。

## 本版改进

- 优化逐列数值校验,黄金样本首次分析时间由约 9 秒降低至约 5.3 秒。
- 增加分析进度、会话缓存、异常阈值快速重算和重复分析保护。
- 修复窗口关闭后的残留进程,并限制为单实例运行。
- 文件夹选择改用 Windows 原生资源管理器界面;取消选择不会清空已有路径。

## 系统要求

Expand All @@ -23,3 +33,4 @@

这是未签名测试版,Windows SmartScreen 或组织安全策略可能发出警告或阻止运行。
统计异常只用于提示复核,不代表工艺或产品合格判定。
部分安装环境中,单层详情图表或原始截图可能不显示;该问题仍在修复,不影响逐层汇总和其他全炉分析页面。
26 changes: 22 additions & 4 deletions analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, Callable

import pandas as pd

Expand Down Expand Up @@ -154,7 +154,10 @@ def _convert_numeric(frame: pd.DataFrame, columns: list[str], scope: str, layer:
for column in columns:
raw = result[column]
converted = pd.to_numeric(raw, errors="coerce")
invalid = raw.notna() & raw.astype(str).str.strip().ne("") & converted.isna()
invalid = raw.notna() & converted.isna()
if invalid.any():
candidates = raw.loc[invalid].astype(str).str.strip()
invalid.loc[candidates.index[candidates.eq("")]] = False
if invalid.any():
_quality(
quality,
Expand Down Expand Up @@ -286,11 +289,17 @@ def _parse_events(log_paths: list[Path], recipe_materials: list[str], quality: l
}


def analyze_folder(folder: str | Path) -> AnalysisResult:
def analyze_folder(
folder: str | Path,
progress: Callable[[str, int, int], None] | None = None,
) -> AnalysisResult:
root = Path(folder).expanduser().resolve()
if not root.is_dir():
raise AnalysisError(f"文件夹不存在:{root}")

if progress:
progress("索引文件", 0, 0)

quality: list[dict[str, Any]] = []
profile: dict[str, set[str]] = {}
recipe_path, recipe = _find_recipe(root)
Expand Down Expand Up @@ -386,7 +395,8 @@ def analyze_folder(folder: str | Path) -> AnalysisResult:
}
machine_numeric = sorted(machine_required - {"LayNum", "Timer"})

for recipe_row in recipe.to_dict("records"):
total_layers = len(expected)
for layer_index, recipe_row in enumerate(recipe.to_dict("records"), start=1):
layer = int(recipe_row["layer"])
material = str(recipe_row["Material"]).strip()
method = str(recipe_row["Method"]).strip()
Expand Down Expand Up @@ -525,13 +535,17 @@ def analyze_folder(folder: str | Path) -> AnalysisResult:
row["machine_rows"] = len(machine)

summaries.append(row)
if progress:
progress("逐层读取", layer_index, total_layers)

layer_summary = pd.DataFrame(summaries).sort_values("layer").reset_index(drop=True)
for column in SUMMARY_COLUMNS:
if column not in layer_summary:
layer_summary[column] = math.nan
layer_summary = layer_summary[SUMMARY_COLUMNS]
log_paths = sorted((root / "CoatingLog").glob("*.log")) if (root / "CoatingLog").is_dir() else []
if progress:
progress("事件汇总", total_layers, total_layers)
if not log_paths:
_quality(quality, "警告", "CoatingLog", "日志缺失", "未找到 .log 文件")
for path in log_paths:
Expand Down Expand Up @@ -585,7 +599,11 @@ def analyze_folder(folder: str | Path) -> AnalysisResult:
"ignored_date_csvs": ignored_date_csvs,
"hidden_summary_metrics": hidden_summary_metrics,
}
if progress:
progress("统计计算", total_layers, total_layers)
anomalies = detect_anomalies(layer_summary, 3.5)
if progress:
progress("完成", total_layers, total_layers)
return AnalysisResult(batch_summary, layer_summary, anomalies, data_quality, constant_fields, file_index, events)


Expand Down
141 changes: 120 additions & 21 deletions desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,56 @@
import threading
import urllib.request
import webbrowser
import ctypes
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from urllib.parse import quote

import webview

import server
from analyzer import csv_bytes, detect_anomalies
from version import APP_NAME, APP_VERSION, BUILD_CHANNEL, RELEASES_URL


LOGGER = logging.getLogger(__name__)
MUTEX_NAME = "Local\\LogAnalysisDesktop"


def configure_logging() -> Path:
from server import settings_path

log_path = settings_path().parent / "logs" / "app.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=2, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
return log_path


def show_message(message: str, title: str = APP_NAME) -> None:
ctypes.windll.user32.MessageBoxW(None, message, title, 0x40)


def acquire_single_instance() -> int | None:
kernel32 = ctypes.windll.kernel32
user32 = ctypes.windll.user32
kernel32.CreateMutexW.restype = ctypes.c_void_p
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
user32.FindWindowW.restype = ctypes.c_void_p
handle = kernel32.CreateMutexW(None, False, MUTEX_NAME)
if handle and kernel32.GetLastError() != 183:
return handle
if handle:
kernel32.CloseHandle(handle)
window = user32.FindWindowW(None, APP_NAME)
if window:
user32.ShowWindow(window, 9)
user32.SetForegroundWindow(window)
else:
show_message("Log Analysis 已在运行。若窗口没有显示,请先在任务管理器中结束旧进程后重试。")
return None


class DesktopApi:
def __init__(self) -> None:
self.window = None
Expand All @@ -26,7 +66,26 @@ def get_version(self) -> dict[str, str]:
def open_releases(self) -> bool:
return webbrowser.open(RELEASES_URL)

def choose_folder(self) -> dict[str, object]:
import server

previous = server.saved_path()
directory = previous if Path(previous).is_dir() else str(Path.home())
try:
selected = self.window.create_file_dialog(webview.FileDialog.FOLDER, directory=directory)
path, cancelled, changed = server.apply_folder_selection(selected[0] if selected else "")
return {"path": path, "cancelled": cancelled, "changed": changed}
except Exception as exc:
return {
"error": f"无法打开 Windows 文件夹选择器:{exc}",
"code": "folder_picker_failed",
"recovery": "请关闭残留的选择窗口后重试;如仍失败,请重新启动 Log Analysis。",
}

def save_export(self, kind: str, threshold: float = 3.5) -> dict[str, object]:
import server
from analyzer import csv_bytes, detect_anomalies

result = server.STATE.get("result")
if result is None:
return {"ok": False, "error": "请先完成炉次分析。"}
Expand All @@ -46,6 +105,8 @@ def save_export(self, kind: str, threshold: float = 3.5) -> dict[str, object]:


def smoke_test() -> None:
import server

httpd = server.create_server(0, secrets.token_urlsafe(32))
worker = threading.Thread(target=httpd.serve_forever, daemon=True)
worker.start()
Expand All @@ -63,29 +124,67 @@ def smoke_test() -> None:


def main() -> None:
mutex = acquire_single_instance()
if mutex is None:
return
import server

try:
log_path = configure_logging()
except OSError:
log_path = server.settings_path().parent / "logs" / "app.log"
logging.basicConfig(level=logging.INFO, force=True)
LOGGER.info("starting version=%s channel=%s", APP_VERSION, BUILD_CHANNEL)
os.environ.setdefault("PYWEBVIEW_GUI", "edgechromium")
token = secrets.token_urlsafe(32)
server.STATE["path"] = server.saved_path()
httpd = server.create_server(0, token)
worker = threading.Thread(target=httpd.serve_forever, name="log-analysis-http", daemon=True)
worker.start()
api = DesktopApi()
url = f"http://127.0.0.1:{httpd.server_port}/?token={quote(token)}"
api.window = webview.create_window(
APP_NAME,
url,
js_api=api,
width=1440,
height=900,
min_size=(960, 640),
text_select=True,
)
httpd = None
worker = None
ready = threading.Event()
stopped = threading.Event()
try:
token = secrets.token_urlsafe(32)
server.STATE["path"] = server.saved_path()
httpd = server.create_server(0, token)
worker = threading.Thread(target=httpd.serve_forever, name="log-analysis-http", daemon=True)
worker.start()
LOGGER.info("local service ready port=%s", httpd.server_port)
api = DesktopApi()
url = f"http://127.0.0.1:{httpd.server_port}/?token={quote(token)}"
api.window = webview.create_window(
APP_NAME,
url,
js_api=api,
width=1440,
height=900,
min_size=(960, 640),
text_select=True,
)

def page_loaded() -> None:
ready.set()
LOGGER.info("window loaded")

api.window.events.loaded += page_loaded

def watch_startup() -> None:
if not ready.wait(15) and not stopped.is_set():
LOGGER.error("window did not load within 15 seconds")
show_message(f"Log Analysis 启动时间过长。请关闭后重试;诊断日志位于:\n{log_path}")

threading.Thread(target=watch_startup, name="startup-watchdog", daemon=True).start()
webview.start(gui="edgechromium", debug=False)
except Exception as exc:
LOGGER.exception("desktop startup failed")
show_message(f"Log Analysis 启动失败:{exc}\n\n诊断日志:{log_path}")
finally:
httpd.shutdown()
httpd.server_close()
worker.join(timeout=3)
stopped.set()
ready.set()
if httpd is not None:
httpd.shutdown()
httpd.server_close()
if worker is not None:
worker.join(timeout=3)
LOGGER.info("stopped")
ctypes.windll.kernel32.CloseHandle(mutex)


if __name__ == "__main__":
Expand Down
Loading