Jelajahi Sumber

feat(M4): experience database + feedback recommendation loop

- src/experience_db.py: SQLite experience DB with auto-accumulation,
  similar case retrieval, trend-based next-round parameter recommendation
- GUI integration: auto-save results after scan, 'Recommend Next' button
  with recommendation dialog
- Experience DB features: add_run, add_runs_from_csv, find_similar,
  recommend_next_round, get_stats, get_all_runs
- Recommendation algorithm: linear trend analysis on similar historical
  runs, suggests parameter increase/decrease direction and range
- All tests passed: DB insert/query/recommend, GUI creation with DB
- Phase 1 complete: M1+M2+M3+M4 all done
javen.ye 1 Minggu lalu
induk
melakukan
f20bef9a91
3 mengubah file dengan 511 tambahan dan 0 penghapusan
  1. 46 0
      docs/CONVERSATION_LOG.md
  2. 325 0
      src/experience_db.py
  3. 140 0
      src/gui/main.py

+ 46 - 0
docs/CONVERSATION_LOG.md

@@ -160,3 +160,49 @@ Car.Lin(项目负责人)
 - 经验库雏形(SQLite + JSON)
 - 反馈调整方案(基于结果推荐下一轮参数范围)
 - GUI集成经验库检索
+
+---
+
+## 2026-08-27 15:45 — M4 完成:经验库雏形 + 反馈闭环
+
+### 成果
+- `src/experience_db.py`:SQLite经验库,支持结果自动积累、相似案例检索、基于趋势分析的下一轮参数推荐
+- GUI集成:扫描完成后自动入库,"Recommend Next"按钮显示推荐
+
+### 经验库功能
+1. **自动积累**:每次扫描完成后,OK结果自动存入SQLite(params_json + metrics_json)
+2. **相似检索**:按拓扑+参数相对距离匹配历史案例(find_similar)
+3. **反馈推荐**:基于历史数据线性趋势分析,推荐每个参数的增大/减小方向和建议范围(recommend_next_round)
+4. **统计查询**:总运行数、不同方案数、按拓扑筛选
+
+### 反馈推荐算法
+- 从经验库中检索相似案例(参数相对距离≤tolerance)
+- 对每个参数,收集(param_value, target_metric)数据对
+- 计算线性回归斜率(cov/var)
+- 根据优化方向(maximize/minimize)推荐参数增大或减小
+- 建议范围:当前值偏向推荐方向±20%
+- 数据不足时返回insufficient_data状态
+
+### GUI集成
+- 扫描完成后自动将所有OK结果存入经验库
+- "Recommend Next"按钮(紫色),扫描完成后启用
+- 推荐对话框显示:最佳结果、各参数推荐方向、建议范围、趋势斜率、数据点数
+- 经验库状态可通过状态栏/日志查看
+
+### 验证
+- experience_db.py:插入/查询/相似检索/推荐 全部测试通过
+- GUI:经验库实例创建正常,Recommend按钮初始禁用,扫描后启用
+- 语法检查全部通过
+- ASCII检查全部通过
+
+### Phase 1 全部完成
+- M1: 单工况仿真 ✅
+- M2: 参数扫描引擎 ✅
+- M3: 方案JSON + PySide6 GUI ✅
+- M4: 经验库 + 反馈闭环 ✅
+
+### 下一步(Phase 2 规划)
+- Web端方案系统基础框架(Vue3 + FastAPI + PostgreSQL)
+- 内网API通信(方案下载 + 结果上传)
+- DRSS拓扑支持
+- 更复杂的优化算法(Morris/LHS/Kriging/NSGA-II)

+ 325 - 0
src/experience_db.py

@@ -0,0 +1,325 @@
+"""Experience database for PCB axial flux motor automated simulation.
+
+Lightweight SQLite-based experience database for Phase 1:
+- Auto-accumulate simulation results after each scan
+- Similar case retrieval by topology + parameter proximity
+- Feedback: recommend next-round parameter ranges based on results
+
+All source is ASCII.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+import sqlite3
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+
+# ---------------------------------------------------------------------------
+# Database schema
+# ---------------------------------------------------------------------------
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS runs (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    plan_id TEXT NOT NULL,
+    topology TEXT NOT NULL DEFAULT 'SSSR',
+    model_path TEXT,
+    timestamp TEXT NOT NULL,
+    status TEXT NOT NULL DEFAULT 'OK',
+    params_json TEXT NOT NULL,
+    metrics_json TEXT NOT NULL,
+    solve_time_s REAL,
+    notes TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_runs_topology ON runs(topology);
+CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp);
+CREATE INDEX IF NOT EXISTS idx_runs_plan_id ON runs(plan_id);
+"""
+
+
+# ---------------------------------------------------------------------------
+# Experience database
+# ---------------------------------------------------------------------------
+
+class ExperienceDB:
+    """SQLite-based experience database for simulation results."""
+
+    def __init__(self, db_path: str | Path = "experience/experience.db"):
+        self.db_path = Path(db_path)
+        self.db_path.parent.mkdir(parents=True, exist_ok=True)
+        self._conn = sqlite3.connect(str(self.db_path))
+        self._conn.row_factory = sqlite3.Row
+        self._conn.executescript(SCHEMA)
+        self._conn.commit()
+
+    def close(self) -> None:
+        self._conn.close()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, *args):
+        self.close()
+
+    # -- Insertion --
+
+    def add_run(
+        self,
+        plan_id: str,
+        params: dict[str, float],
+        metrics: dict[str, float],
+        topology: str = "SSSR",
+        model_path: str = "",
+        status: str = "OK",
+        solve_time_s: float = 0.0,
+        notes: str = "",
+    ) -> int:
+        """Add a single simulation run result. Returns the run ID."""
+        cursor = self._conn.execute(
+            """INSERT INTO runs (plan_id, topology, model_path, timestamp, status,
+               params_json, metrics_json, solve_time_s, notes)
+               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+            (
+                plan_id,
+                topology,
+                model_path,
+                datetime.now().isoformat(timespec="seconds"),
+                status,
+                json.dumps(params),
+                json.dumps(metrics),
+                solve_time_s,
+                notes,
+            ),
+        )
+        self._conn.commit()
+        return cursor.lastrowid
+
+    def add_runs_from_csv(self, csv_path: str | Path, plan_id: str,
+                           topology: str = "SSSR", model_path: str = "") -> int:
+        """Add all runs from a scan results CSV. Returns number added."""
+        import csv as csv_mod
+        count = 0
+        with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
+            reader = csv_mod.DictReader(f)
+            for row in reader:
+                if row.get("status") != "OK":
+                    continue
+                # Extract params (all columns that are not standard fields/metrics)
+                standard = {"run_index", "status", "seconds", "error"}
+                from .solver_core import METRIC_KEYS
+                metric_set = set(METRIC_KEYS)
+                params = {}
+                metrics = {}
+                for key, val in row.items():
+                    if key in standard or not val:
+                        continue
+                    try:
+                        fval = float(val)
+                    except (ValueError, TypeError):
+                        continue
+                    if key in metric_set:
+                        metrics[key] = fval
+                    else:
+                        params[key] = fval
+                self.add_run(
+                    plan_id=plan_id, params=params, metrics=metrics,
+                    topology=topology, model_path=model_path,
+                    status=row.get("status", "OK"),
+                    solve_time_s=float(row.get("seconds", 0) or 0),
+                )
+                count += 1
+        return count
+
+    # -- Query --
+
+    def get_run(self, run_id: int) -> dict[str, Any] | None:
+        """Get a single run by ID."""
+        row = self._conn.execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone()
+        return self._row_to_dict(row) if row else None
+
+    def get_all_runs(self, topology: str | None = None,
+                      limit: int = 100) -> list[dict[str, Any]]:
+        """Get all runs, optionally filtered by topology."""
+        if topology:
+            rows = self._conn.execute(
+                "SELECT * FROM runs WHERE topology = ? ORDER BY timestamp DESC LIMIT ?",
+                (topology, limit),
+            ).fetchall()
+        else:
+            rows = self._conn.execute(
+                "SELECT * FROM runs ORDER BY timestamp DESC LIMIT ?",
+                (limit,),
+            ).fetchall()
+        return [self._row_to_dict(r) for r in rows]
+
+    def find_similar(
+        self,
+        params: dict[str, float],
+        topology: str = "SSSR",
+        top_n: int = 5,
+        tolerance: float = 0.5,
+    ) -> list[dict[str, Any]]:
+        """Find similar runs based on parameter proximity.
+
+        Args:
+            params: target parameter values to match against.
+            topology: filter by topology.
+            top_n: number of results to return.
+            tolerance: relative tolerance for parameter matching (0.5 = 50%).
+
+        Returns:
+            List of similar runs, sorted by similarity score (closest first).
+        """
+        candidates = self.get_all_runs(topology=topology, limit=500)
+        scored = []
+        for run in candidates:
+            run_params = run.get("params", {})
+            # Compute average relative distance across shared parameters
+            distances = []
+            shared = 0
+            for key, target_val in params.items():
+                if key in run_params and target_val != 0:
+                    rel_dist = abs(run_params[key] - target_val) / abs(target_val)
+                    distances.append(rel_dist)
+                    shared += 1
+            if shared == 0:
+                continue
+            avg_dist = sum(distances) / len(distances)
+            if avg_dist <= tolerance:
+                scored.append((avg_dist, run))
+        scored.sort(key=lambda x: x[0])
+        return [run for _, run in scored[:top_n]]
+
+    def get_stats(self, topology: str | None = None) -> dict[str, Any]:
+        """Get database statistics."""
+        if topology:
+            total = self._conn.execute(
+                "SELECT COUNT(*) FROM runs WHERE topology = ?", (topology,)
+            ).fetchone()[0]
+        else:
+            total = self._conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
+        plans = self._conn.execute(
+            "SELECT COUNT(DISTINCT plan_id) FROM runs"
+        ).fetchone()[0]
+        return {"total_runs": total, "distinct_plans": plans, "db_path": str(self.db_path)}
+
+    # -- Feedback / Recommendation --
+
+    def recommend_next_round(
+        self,
+        params: dict[str, float],
+        metrics: dict[str, float],
+        target_metric: str = "efficiency_pct",
+        direction: str = "maximize",
+        topology: str = "SSSR",
+    ) -> dict[str, Any]:
+        """Recommend next-round parameter ranges based on current results.
+
+        Uses simple trend analysis: find similar runs in the experience DB,
+        compare their metric values, and recommend parameter directions.
+
+        Args:
+            params: current parameter values.
+            metrics: current metric values.
+            target_metric: metric to optimize.
+            direction: 'maximize' or 'minimize'.
+            topology: motor topology.
+
+        Returns:
+            dict with recommendations for each parameter.
+        """
+        similar = self.find_similar(params, topology=topology, top_n=10, tolerance=1.0)
+        if not similar:
+            return {
+                "status": "insufficient_data",
+                "message": "Not enough similar runs in experience DB for recommendation. "
+                           "Accumulate more simulation results first.",
+                "recommendations": {},
+            }
+
+        current_val = metrics.get(target_metric)
+        if current_val is None:
+            return {
+                "status": "missing_metric",
+                "message": f"Target metric '{target_metric}' not found in current results.",
+                "recommendations": {},
+            }
+
+        # Analyze each parameter: find runs where this parameter differs
+        recommendations = {}
+        for param_name, current_param_val in params.items():
+            # Collect (param_value, metric_value) pairs from similar runs
+            pairs = []
+            for run in similar:
+                if param_name in run["params"] and target_metric in run["metrics"]:
+                    pairs.append((
+                        run["params"][param_name],
+                        run["metrics"][target_metric],
+                    ))
+            if len(pairs) < 2:
+                recommendations[param_name] = {
+                    "status": "insufficient_data",
+                    "message": f"Not enough data points for parameter '{param_name}'.",
+                }
+                continue
+
+            # Simple linear trend: compute correlation direction
+            pairs.sort(key=lambda x: x[0])
+            n = len(pairs)
+            mean_x = sum(p[0] for p in pairs) / n
+            mean_y = sum(p[1] for p in pairs) / n
+            cov = sum((p[0] - mean_x) * (p[1] - mean_y) for p in pairs) / n
+            var_x = sum((p[0] - mean_x) ** 2 for p in pairs) / n
+            if var_x == 0:
+                recommendations[param_name] = {
+                    "status": "no_variation",
+                    "message": f"Parameter '{param_name}' has no variation in similar runs.",
+                }
+                continue
+            slope = cov / var_x  # metric change per unit parameter change
+
+            # Recommend direction
+            if direction == "maximize":
+                recommend_increase = slope > 0
+            else:
+                recommend_increase = slope < 0
+
+            # Suggest new range: current value +/- 20%, biased toward recommendation
+            delta = abs(current_param_val) * 0.2 if current_param_val != 0 else 0.5
+            if recommend_increase:
+                new_range = [current_param_val, current_param_val + delta * 2]
+            else:
+                new_range = [max(0, current_param_val - delta * 2), current_param_val]
+
+            recommendations[param_name] = {
+                "status": "ok",
+                "current_value": current_param_val,
+                "trend_slope": round(slope, 6),
+                "recommend_increase": recommend_increase,
+                "suggested_range": [round(new_range[0], 4), round(new_range[1], 4)],
+                "data_points": n,
+            }
+
+        return {
+            "status": "ok",
+            "target_metric": target_metric,
+            "direction": direction,
+            "current_value": current_val,
+            "similar_runs_used": len(similar),
+            "recommendations": recommendations,
+        }
+
+    # -- Internal --
+
+    def _row_to_dict(self, row: sqlite3.Row) -> dict[str, Any]:
+        d = dict(row)
+        d["params"] = json.loads(d.get("params_json", "{}"))
+        d["metrics"] = json.loads(d.get("metrics_json", "{}"))
+        del d["params_json"]
+        del d["metrics_json"]
+        return d

+ 140 - 0
src/gui/main.py

@@ -38,6 +38,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
 from src.solver_core import MotorCADSolver, METRIC_LABELS, METRIC_KEYS  # noqa: E402
 from src.scan_engine import ScanEngine, estimate_total_time  # noqa: E402
 from src.plan_schema import SimulationPlan, ScanVariable  # noqa: E402
+from src.experience_db import ExperienceDB  # noqa: E402
 
 
 # ---------------------------------------------------------------------------
@@ -46,6 +47,7 @@ from src.plan_schema import SimulationPlan, ScanVariable  # noqa: E402
 
 DEFAULT_MODEL = str(PROJECT_ROOT / "models" / "MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot")
 DEFAULT_OUTPUT = str(PROJECT_ROOT / "output")
+DEFAULT_EXPERIENCE_DB = str(PROJECT_ROOT / "experience" / "experience.db")
 
 # Phase 1 core metrics (always shown first)
 CORE_METRICS = ["tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w"]
@@ -292,6 +294,8 @@ class MainWindow(QMainWindow):
         self.resize(1400, 900)
         self.worker: ScanWorker | None = None
         self._plan = SimulationPlan(model_path=DEFAULT_MODEL)
+        self._experience_db = ExperienceDB(DEFAULT_EXPERIENCE_DB)
+        self._last_results: list[dict] = []
 
         self._build_ui()
         self._apply_style()
@@ -394,6 +398,20 @@ class MainWindow(QMainWindow):
         self.stop_btn.clicked.connect(self._stop_scan)
         run_btn_layout.addWidget(self.stop_btn)
 
+        self.recommend_btn = QPushButton("Recommend Next")
+        self.recommend_btn.setMinimumHeight(40)
+        self.recommend_btn.setEnabled(False)
+        self.recommend_btn.setStyleSheet("""
+            QPushButton {
+                background: #7c3aed; color: white; border: none;
+                border-radius: 6px; font-weight: 600; font-size: 11pt;
+            }
+            QPushButton:hover { background: #6d28d9; }
+            QPushButton:disabled { background: #94a3b8; }
+        """)
+        self.recommend_btn.clicked.connect(self._show_recommendations)
+        run_btn_layout.addWidget(self.recommend_btn)
+
         run_layout.addLayout(run_btn_layout)
 
         # Progress bar
@@ -575,6 +593,8 @@ class MainWindow(QMainWindow):
         self.results_table.setup_columns(var_names)
         self.progress_bar.setRange(0, len(points))
         self.progress_bar.setValue(0)
+        self._last_results = []
+        self.recommend_btn.setEnabled(False)
 
         # Start worker
         self.worker = ScanWorker(plan, DEFAULT_OUTPUT)
@@ -602,13 +622,52 @@ class MainWindow(QMainWindow):
 
     def _on_result_row(self, row: dict) -> None:
         self.results_table.add_result_row(row)
+        self._last_results.append(row)
 
     def _on_finished(self, result: dict) -> None:
         s = result.get("summary", {})
         self._log(f"SCAN COMPLETE: OK={s.get('ok',0)} FAILED={s.get('failed',0)} SKIPPED={s.get('skipped',0)}")
         self._log(f"Results: {result.get('csv_path', '')}")
+
+        # Auto-save to experience DB
+        saved = 0
+        try:
+            for row in self._last_results:
+                if row.get("status") != "OK":
+                    continue
+                params = {}
+                metrics = {}
+                metric_set = set(METRIC_KEYS)
+                standard = {"run_index", "status", "seconds", "error"}
+                for key, val in row.items():
+                    if key in standard or val == "" or val is None:
+                        continue
+                    try:
+                        fval = float(val)
+                    except (ValueError, TypeError):
+                        continue
+                    if key in metric_set:
+                        metrics[key] = fval
+                    else:
+                        params[key] = fval
+                if params and metrics:
+                    self._experience_db.add_run(
+                        plan_id=self._plan.plan_id,
+                        params=params,
+                        metrics=metrics,
+                        topology=self._plan.topology,
+                        model_path=self._plan.model_path,
+                        solve_time_s=float(row.get("seconds", 0) or 0),
+                    )
+                    saved += 1
+            stats = self._experience_db.get_stats()
+            self._log(f"Experience DB: saved {saved} runs (total: {stats['total_runs']})")
+        except Exception as exc:
+            self._log(f"Experience DB save warning: {exc}")
+
         self.start_btn.setEnabled(True)
         self.stop_btn.setEnabled(False)
+        self.recommend_btn.setEnabled(len(self._last_results) > 0)
         self._update_status("Complete")
 
     def _on_failed(self, error: str) -> None:
@@ -618,6 +677,87 @@ class MainWindow(QMainWindow):
         self.stop_btn.setEnabled(False)
         self._update_status("Failed")
 
+    def _show_recommendations(self) -> None:
+        """Show recommendation dialog based on last scan results."""
+        if not self._last_results:
+            QMessageBox.information(self, "No Data", "No scan results available for recommendation.")
+            return
+
+        # Find best result by efficiency
+        best = None
+        best_eff = -1
+        for row in self._last_results:
+            if row.get("status") != "OK":
+                continue
+            eff = row.get("efficiency_pct", "")
+            try:
+                eff_val = float(eff)
+                if eff_val > best_eff:
+                    best_eff = eff_val
+                    best = row
+            except (ValueError, TypeError):
+                continue
+
+        if best is None:
+            QMessageBox.information(self, "No Data", "No valid results for recommendation.")
+            return
+
+        # Extract params and metrics from best result
+        params = {}
+        metrics = {}
+        metric_set = set(METRIC_KEYS)
+        standard = {"run_index", "status", "seconds", "error"}
+        for key, val in best.items():
+            if key in standard or val == "" or val is None:
+                continue
+            try:
+                fval = float(val)
+            except (ValueError, TypeError):
+                continue
+            if key in metric_set:
+                metrics[key] = fval
+            else:
+                params[key] = fval
+
+        # Get recommendation from experience DB
+        rec = self._experience_db.recommend_next_round(
+            params=params,
+            metrics=metrics,
+            target_metric="efficiency_pct",
+            direction="maximize",
+            topology=self._plan.topology,
+        )
+
+        # Build dialog text
+        lines = []
+        lines.append(f"Best result: efficiency={best_eff:.2f}%")
+        lines.append(f"Parameters: {', '.join(f'{k}={v}' for k, v in params.items())}")
+        lines.append("")
+        lines.append(f"Recommendation status: {rec.get('status', 'unknown')}")
+        lines.append(f"Similar runs in DB: {rec.get('similar_runs_used', 0)}")
+        lines.append("")
+
+        if rec.get("status") == "ok":
+            lines.append("Parameter recommendations:")
+            for param, info in rec.get("recommendations", {}).items():
+                if info.get("status") == "ok":
+                    direction = "INCREASE" if info.get("recommend_increase") else "DECREASE"
+                    lines.append(f"  {param}: {direction}")
+                    lines.append(f"    Current: {info.get('current_value')}")
+                    lines.append(f"    Suggested range: {info.get('suggested_range')}")
+                    lines.append(f"    Trend slope: {info.get('trend_slope')}")
+                    lines.append(f"    Data points: {info.get('data_points')}")
+                else:
+                    lines.append(f"  {param}: {info.get('message', 'insufficient data')}")
+        else:
+            lines.append(rec.get("message", "No recommendation available."))
+
+        lines.append("")
+        lines.append("Note: Recommendations are based on trend analysis of accumulated")
+        lines.append("simulation results. Accumulate more data for better accuracy.")
+
+        QMessageBox.information(self, "Next Round Recommendation", "\n".join(lines))
+
 
 # ---------------------------------------------------------------------------
 # Entry point with 5-layer crash protection