Add persistent trial history DB (append-only, survives --clean)
- history.db: SQLite append-only, never deleted by --clean - history.csv: Auto-exported after each trial (live updates) - Logs: DVs, results, feasibility, status, solve time, iter path - Cross-study queries: full lineage across all runs/phases - --clean only resets Optuna DB, history preserved
This commit is contained in:
235
projects/hydrotech-beam/studies/01_doe_landscape/history.py
Normal file
235
projects/hydrotech-beam/studies/01_doe_landscape/history.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""Persistent trial history — append-only, survives Optuna resets.
|
||||||
|
|
||||||
|
Every trial is logged to `history.db` (SQLite) and exported to `history.csv`.
|
||||||
|
Never deleted by --clean. Full lineage across all studies and phases.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
history = TrialHistory(results_dir)
|
||||||
|
history.log_trial(study_name, trial_id, params, results, ...)
|
||||||
|
history.export_csv()
|
||||||
|
df = history.query("SELECT * FROM trials WHERE mass_kg < 100")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Schema version — bump if columns change
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
CREATE_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS trials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
study_name TEXT NOT NULL,
|
||||||
|
trial_id INTEGER NOT NULL,
|
||||||
|
iteration TEXT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Design variables
|
||||||
|
beam_half_core_thickness REAL,
|
||||||
|
beam_face_thickness REAL,
|
||||||
|
holes_diameter REAL,
|
||||||
|
hole_count INTEGER,
|
||||||
|
|
||||||
|
-- Results
|
||||||
|
mass_kg REAL,
|
||||||
|
tip_displacement_mm REAL,
|
||||||
|
max_von_mises_mpa REAL,
|
||||||
|
|
||||||
|
-- Constraint checks
|
||||||
|
disp_feasible INTEGER, -- 0/1
|
||||||
|
stress_feasible INTEGER, -- 0/1
|
||||||
|
geo_feasible INTEGER, -- 0/1
|
||||||
|
fully_feasible INTEGER, -- 0/1
|
||||||
|
|
||||||
|
-- Meta
|
||||||
|
status TEXT DEFAULT 'COMPLETE', -- COMPLETE, FAILED, PRUNED
|
||||||
|
error_message TEXT,
|
||||||
|
solve_time_s REAL,
|
||||||
|
iter_path TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Unique constraint: no duplicate (study, trial) pairs
|
||||||
|
UNIQUE(study_name, trial_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Constraint thresholds (from OPTIMIZATION_STRATEGY.md)
|
||||||
|
DISP_LIMIT_MM = 10.0
|
||||||
|
STRESS_LIMIT_MPA = 130.0
|
||||||
|
|
||||||
|
# CSV column order
|
||||||
|
CSV_COLUMNS = [
|
||||||
|
"study_name", "trial_id", "iteration", "timestamp",
|
||||||
|
"beam_half_core_thickness", "beam_face_thickness",
|
||||||
|
"holes_diameter", "hole_count",
|
||||||
|
"mass_kg", "tip_displacement_mm", "max_von_mises_mpa",
|
||||||
|
"disp_feasible", "stress_feasible", "geo_feasible", "fully_feasible",
|
||||||
|
"status", "error_message", "solve_time_s", "iter_path",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TrialHistory:
|
||||||
|
"""Append-only trial history database."""
|
||||||
|
|
||||||
|
def __init__(self, results_dir: Path | str):
|
||||||
|
self.results_dir = Path(results_dir)
|
||||||
|
self.results_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.db_path = self.results_dir / "history.db"
|
||||||
|
self.csv_path = self.results_dir / "history.csv"
|
||||||
|
|
||||||
|
self._conn = sqlite3.connect(str(self.db_path))
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute("PRAGMA journal_mode=WAL") # safe concurrent reads
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
count = self._conn.execute("SELECT COUNT(*) FROM trials").fetchone()[0]
|
||||||
|
logger.info("Trial history: %s (%d records)", self.db_path.name, count)
|
||||||
|
|
||||||
|
def _init_schema(self) -> None:
|
||||||
|
"""Create tables if they don't exist."""
|
||||||
|
self._conn.executescript(CREATE_TABLE)
|
||||||
|
|
||||||
|
# Check/set schema version
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO schema_version (version) VALUES (?)",
|
||||||
|
(SCHEMA_VERSION,),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def log_trial(
|
||||||
|
self,
|
||||||
|
study_name: str,
|
||||||
|
trial_id: int,
|
||||||
|
params: dict[str, float],
|
||||||
|
mass_kg: float = float("nan"),
|
||||||
|
tip_displacement_mm: float = float("nan"),
|
||||||
|
max_von_mises_mpa: float = float("nan"),
|
||||||
|
geo_feasible: bool = True,
|
||||||
|
status: str = "COMPLETE",
|
||||||
|
error_message: str | None = None,
|
||||||
|
solve_time_s: float = 0.0,
|
||||||
|
iter_path: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
iteration_number: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a single trial result.
|
||||||
|
|
||||||
|
Uses INSERT OR REPLACE so re-runs of the same trial update cleanly.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
disp_ok = (
|
||||||
|
not math.isnan(tip_displacement_mm)
|
||||||
|
and tip_displacement_mm <= DISP_LIMIT_MM
|
||||||
|
)
|
||||||
|
stress_ok = (
|
||||||
|
not math.isnan(max_von_mises_mpa)
|
||||||
|
and max_von_mises_mpa <= STRESS_LIMIT_MPA
|
||||||
|
)
|
||||||
|
|
||||||
|
iteration = f"iter{iteration_number:03d}" if iteration_number else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO trials (
|
||||||
|
study_name, trial_id, iteration, timestamp,
|
||||||
|
beam_half_core_thickness, beam_face_thickness,
|
||||||
|
holes_diameter, hole_count,
|
||||||
|
mass_kg, tip_displacement_mm, max_von_mises_mpa,
|
||||||
|
disp_feasible, stress_feasible, geo_feasible, fully_feasible,
|
||||||
|
status, error_message, solve_time_s, iter_path, notes
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
study_name,
|
||||||
|
trial_id,
|
||||||
|
iteration,
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
params.get("beam_half_core_thickness"),
|
||||||
|
params.get("beam_face_thickness"),
|
||||||
|
params.get("holes_diameter"),
|
||||||
|
params.get("hole_count"),
|
||||||
|
mass_kg,
|
||||||
|
tip_displacement_mm,
|
||||||
|
max_von_mises_mpa,
|
||||||
|
int(disp_ok),
|
||||||
|
int(stress_ok),
|
||||||
|
int(geo_feasible),
|
||||||
|
int(disp_ok and stress_ok and geo_feasible),
|
||||||
|
status,
|
||||||
|
error_message,
|
||||||
|
solve_time_s,
|
||||||
|
iter_path,
|
||||||
|
notes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error("Failed to log trial %d: %s", trial_id, e)
|
||||||
|
|
||||||
|
def export_csv(self) -> Path:
|
||||||
|
"""Export all trials to CSV (overwrite). Returns path."""
|
||||||
|
rows = self._conn.execute(
|
||||||
|
f"SELECT {', '.join(CSV_COLUMNS)} FROM trials ORDER BY study_name, trial_id"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
with open(self.csv_path, "w", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(CSV_COLUMNS)
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow([row[col] for col in CSV_COLUMNS])
|
||||||
|
|
||||||
|
logger.info("Exported %d trials to %s", len(rows), self.csv_path.name)
|
||||||
|
return self.csv_path
|
||||||
|
|
||||||
|
def query(self, sql: str, params: tuple = ()) -> list[dict]:
|
||||||
|
"""Run an arbitrary SELECT query. Returns list of dicts."""
|
||||||
|
rows = self._conn.execute(sql, params).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def get_study_summary(self, study_name: str) -> dict[str, Any]:
|
||||||
|
"""Get summary stats for a study."""
|
||||||
|
rows = self.query(
|
||||||
|
"SELECT * FROM trials WHERE study_name = ?", (study_name,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return {"study_name": study_name, "total": 0}
|
||||||
|
|
||||||
|
complete = [r for r in rows if r["status"] == "COMPLETE"]
|
||||||
|
feasible = [r for r in complete if r["fully_feasible"]]
|
||||||
|
masses = [r["mass_kg"] for r in feasible if r["mass_kg"] is not None]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"study_name": study_name,
|
||||||
|
"total": len(rows),
|
||||||
|
"complete": len(complete),
|
||||||
|
"failed": len(rows) - len(complete),
|
||||||
|
"feasible": len(feasible),
|
||||||
|
"best_mass_kg": min(masses) if masses else None,
|
||||||
|
"solve_rate": len(complete) / len(rows) * 100 if rows else 0,
|
||||||
|
"feasibility_rate": len(feasible) / len(complete) * 100 if complete else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Export CSV and close connection."""
|
||||||
|
self.export_csv()
|
||||||
|
self._conn.close()
|
||||||
@@ -41,6 +41,7 @@ from geometric_checks import (
|
|||||||
FeasibilityResult,
|
FeasibilityResult,
|
||||||
check_feasibility,
|
check_feasibility,
|
||||||
)
|
)
|
||||||
|
from history import TrialHistory
|
||||||
from nx_interface import TrialInput, TrialResult, create_solver
|
from nx_interface import TrialInput, TrialResult, create_solver
|
||||||
from sampling import DV_DEFINITIONS, generate_lhs_samples, points_to_dicts
|
from sampling import DV_DEFINITIONS, generate_lhs_samples, points_to_dicts
|
||||||
|
|
||||||
@@ -103,12 +104,16 @@ def constraints_func(trial: optuna.trial.FrozenTrial) -> list[float]:
|
|||||||
def evaluate_trial(
|
def evaluate_trial(
|
||||||
trial: optuna.Trial,
|
trial: optuna.Trial,
|
||||||
solver: Any,
|
solver: Any,
|
||||||
|
history: TrialHistory | None = None,
|
||||||
|
study_name: str = "",
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Evaluate a single trial: geometric check → NX solve → extract.
|
"""Evaluate a single trial: geometric check → NX solve → extract.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
trial: Optuna trial (with parameters already suggested/enqueued).
|
trial: Optuna trial (with parameters already suggested/enqueued).
|
||||||
solver: NX solver instance (stub or real).
|
solver: NX solver instance (stub or real).
|
||||||
|
history: Persistent trial history logger (append-only).
|
||||||
|
study_name: Study name for history logging.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Objective value (mass in kg). Returns INFEASIBLE_MASS for
|
Objective value (mass in kg). Returns INFEASIBLE_MASS for
|
||||||
@@ -152,6 +157,13 @@ def evaluate_trial(
|
|||||||
trial.set_user_attr("ligament", geo_result.ligament)
|
trial.set_user_attr("ligament", geo_result.ligament)
|
||||||
trial.set_user_attr("web_clearance", geo_result.web_clearance)
|
trial.set_user_attr("web_clearance", geo_result.web_clearance)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"beam_half_core_thickness": dv1,
|
||||||
|
"beam_face_thickness": dv2,
|
||||||
|
"holes_diameter": dv3,
|
||||||
|
"hole_count": dv4,
|
||||||
|
}
|
||||||
|
|
||||||
if not geo_result.feasible:
|
if not geo_result.feasible:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Trial %d: GEOMETRICALLY INFEASIBLE — %s",
|
"Trial %d: GEOMETRICALLY INFEASIBLE — %s",
|
||||||
@@ -162,6 +174,13 @@ def evaluate_trial(
|
|||||||
trial.set_user_attr("tip_displacement", INFEASIBLE_DISPLACEMENT)
|
trial.set_user_attr("tip_displacement", INFEASIBLE_DISPLACEMENT)
|
||||||
trial.set_user_attr("max_von_mises", INFEASIBLE_STRESS)
|
trial.set_user_attr("max_von_mises", INFEASIBLE_STRESS)
|
||||||
trial.set_user_attr("mass", INFEASIBLE_MASS)
|
trial.set_user_attr("mass", INFEASIBLE_MASS)
|
||||||
|
if history:
|
||||||
|
history.log_trial(
|
||||||
|
study_name=study_name, trial_id=trial_num, params=params,
|
||||||
|
geo_feasible=False, status="GEO_INFEASIBLE",
|
||||||
|
error_message=geo_result.reason,
|
||||||
|
iteration_number=trial_num + 1,
|
||||||
|
)
|
||||||
return INFEASIBLE_MASS
|
return INFEASIBLE_MASS
|
||||||
|
|
||||||
# NX evaluation
|
# NX evaluation
|
||||||
@@ -188,6 +207,14 @@ def evaluate_trial(
|
|||||||
trial.set_user_attr("tip_displacement", INFEASIBLE_DISPLACEMENT)
|
trial.set_user_attr("tip_displacement", INFEASIBLE_DISPLACEMENT)
|
||||||
trial.set_user_attr("max_von_mises", INFEASIBLE_STRESS)
|
trial.set_user_attr("max_von_mises", INFEASIBLE_STRESS)
|
||||||
trial.set_user_attr("mass", INFEASIBLE_MASS)
|
trial.set_user_attr("mass", INFEASIBLE_MASS)
|
||||||
|
if history:
|
||||||
|
history.log_trial(
|
||||||
|
study_name=study_name, trial_id=trial_num, params=params,
|
||||||
|
status="FAILED", error_message=nx_result.error_message,
|
||||||
|
solve_time_s=round(t_elapsed, 2),
|
||||||
|
iter_path=nx_result.iteration_dir,
|
||||||
|
iteration_number=trial_num + 1,
|
||||||
|
)
|
||||||
return INFEASIBLE_MASS
|
return INFEASIBLE_MASS
|
||||||
|
|
||||||
# Record successful results
|
# Record successful results
|
||||||
@@ -214,6 +241,20 @@ def evaluate_trial(
|
|||||||
t_elapsed,
|
t_elapsed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if history:
|
||||||
|
history.log_trial(
|
||||||
|
study_name=study_name, trial_id=trial_num, params=params,
|
||||||
|
mass_kg=nx_result.mass,
|
||||||
|
tip_displacement_mm=nx_result.tip_displacement,
|
||||||
|
max_von_mises_mpa=nx_result.max_von_mises,
|
||||||
|
geo_feasible=True,
|
||||||
|
status="COMPLETE",
|
||||||
|
solve_time_s=round(t_elapsed, 2),
|
||||||
|
iter_path=nx_result.iteration_dir,
|
||||||
|
iteration_number=trial_num + 1,
|
||||||
|
)
|
||||||
|
history.export_csv() # Live update CSV after each trial
|
||||||
|
|
||||||
return nx_result.mass
|
return nx_result.mass
|
||||||
|
|
||||||
|
|
||||||
@@ -419,7 +460,7 @@ def run_study(args: argparse.Namespace) -> None:
|
|||||||
storage = f"sqlite:///{db_path}"
|
storage = f"sqlite:///{db_path}"
|
||||||
|
|
||||||
if args.clean and db_path.exists():
|
if args.clean and db_path.exists():
|
||||||
logger.info("--clean flag: deleting existing DB at %s", db_path)
|
logger.info("--clean flag: deleting Optuna DB at %s (history.db preserved)", db_path)
|
||||||
db_path.unlink()
|
db_path.unlink()
|
||||||
|
|
||||||
if args.resume:
|
if args.resume:
|
||||||
@@ -449,13 +490,17 @@ def run_study(args: argparse.Namespace) -> None:
|
|||||||
logger.info("Enqueued %d trials (1 baseline + %d LHS)", n_trials, n_trials - 1)
|
logger.info("Enqueued %d trials (1 baseline + %d LHS)", n_trials, n_trials - 1)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# 3. Create solver
|
# 3. Create solver + history
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
solver = create_solver(
|
solver = create_solver(
|
||||||
backend=args.backend,
|
backend=args.backend,
|
||||||
model_dir=args.model_dir,
|
model_dir=args.model_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Persistent history — NEVER deleted by --clean
|
||||||
|
history = TrialHistory(results_dir)
|
||||||
|
study_name = args.study_name
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# 4. Run all trials
|
# 4. Run all trials
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
@@ -469,7 +514,7 @@ def run_study(args: argparse.Namespace) -> None:
|
|||||||
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||||
|
|
||||||
study.optimize(
|
study.optimize(
|
||||||
lambda trial: evaluate_trial(trial, solver),
|
lambda trial: evaluate_trial(trial, solver, history, study_name),
|
||||||
n_trials=n_trials,
|
n_trials=n_trials,
|
||||||
callbacks=[_progress_callback],
|
callbacks=[_progress_callback],
|
||||||
)
|
)
|
||||||
@@ -497,6 +542,11 @@ def run_study(args: argparse.Namespace) -> None:
|
|||||||
# Cleanup
|
# Cleanup
|
||||||
solver.close()
|
solver.close()
|
||||||
|
|
||||||
|
# Final history export + summary
|
||||||
|
history.close()
|
||||||
|
hist_summary = history.get_study_summary(study_name)
|
||||||
|
logger.info("History DB: %d total records across all studies", hist_summary["total"])
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial) -> None:
|
def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial) -> None:
|
||||||
"""Log progress after each trial."""
|
"""Log progress after each trial."""
|
||||||
|
|||||||
Reference in New Issue
Block a user