소스 검색

feat(P2-M2): boundary conditions input + visual plan editor + rule engine

javen.ye 1 주 전
부모
커밋
a95c0db

+ 2 - 1
web/backend/app/main.py

@@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
 
 from .config import APP_NAME, APP_VERSION, APP_DESCRIPTION, CORS_ORIGINS
 from .database import init_db
-from .routers import projects, plans, experience
+from .routers import projects, plans, experience, generation
 
 app = FastAPI(
     title=APP_NAME,
@@ -25,6 +25,7 @@ app.add_middleware(
 app.include_router(projects.router)
 app.include_router(plans.router)
 app.include_router(experience.router)
+app.include_router(generation.router)
 
 
 @app.on_event("startup")

+ 103 - 0
web/backend/app/routers/generation.py

@@ -0,0 +1,103 @@
+"""Plan generation API router (rule engine + parameter registry)."""
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..models.project import Project
+from ..schemas.generation import (
+    ParameterRegistryResponse,
+    RangeRecommendRequest, RangeRecommendation,
+    PlanGenerateRequest, PlanGenerateResponse,
+)
+from ..services.rule_engine import (
+    get_parameter_registry, get_parameter,
+    recommend_range, generate_plan, BoundaryConditions,
+)
+
+router = APIRouter(prefix="/api", tags=["generation"])
+
+
+# ---------------------------------------------------------------------------
+# Parameter registry
+# ---------------------------------------------------------------------------
+
+@router.get("/scan-parameters", response_model=ParameterRegistryResponse)
+def list_scan_parameters(category: str | None = None):
+    """List all scannable Motor-CAD parameters with their metadata.
+
+    Used by the frontend plan editor to populate variable dropdowns.
+    """
+    params = get_parameter_registry()
+    if category:
+        params = [p for p in params if p["category"].lower() == category.lower()]
+    return ParameterRegistryResponse(total=len(params), parameters=params)
+
+
+# ---------------------------------------------------------------------------
+# Range recommendation
+# ---------------------------------------------------------------------------
+
+@router.post("/recommend-range", response_model=RangeRecommendation)
+def recommend_scan_range(request: RangeRecommendRequest):
+    """Recommend a scan range for a parameter based on boundary conditions."""
+    p = get_parameter(request.parameter_name)
+    if p is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"Unknown parameter: {request.parameter_name}",
+        )
+    bc = BoundaryConditions.from_dict(request.boundary_conditions or {})
+    rng = recommend_range(request.parameter_name, bc)
+    return RangeRecommendation(**rng)
+
+
+# ---------------------------------------------------------------------------
+# Plan generation
+# ---------------------------------------------------------------------------
+
+@router.post("/generate-plan", response_model=PlanGenerateResponse)
+def generate_plan_from_bc(request: PlanGenerateRequest):
+    """Generate a recommended simulation plan from boundary conditions.
+
+    Uses the rule engine to recommend scan ranges for each parameter and
+    returns a plan draft that can be edited and then saved via the plans API.
+    """
+    bc = BoundaryConditions.from_dict(request.boundary_conditions or {})
+    plan = generate_plan(
+        bc=bc,
+        param_names=request.parameter_names,
+        model_path=request.model_path,
+    )
+    return PlanGenerateResponse(**plan)
+
+
+@router.post("/projects/{project_id}/generate-plan", response_model=PlanGenerateResponse)
+def generate_plan_for_project(
+    project_id: int,
+    request: PlanGenerateRequest,
+    db: Session = Depends(get_db),
+):
+    """Generate a plan for an existing project using its boundary conditions.
+
+    The project's stored boundary conditions are used as the base; any
+    boundary_conditions in the request override them.
+    """
+    project = db.query(Project).filter(Project.id == project_id).first()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Merge: project BC as base, request BC overrides
+    merged_bc = dict(project.get_boundary_conditions())
+    if request.boundary_conditions:
+        merged_bc.update(request.boundary_conditions)
+    # Ensure topology from project
+    merged_bc.setdefault("topology", project.topology or "SSSR")
+
+    bc = BoundaryConditions.from_dict(merged_bc)
+    model_path = request.model_path or project.model_path or ""
+    plan = generate_plan(
+        bc=bc,
+        param_names=request.parameter_names,
+        model_path=model_path,
+    )
+    return PlanGenerateResponse(**plan)

+ 8 - 0
web/backend/app/schemas/__init__.py

@@ -5,9 +5,17 @@ from .simulation_plan import (
     PlanDownloadResponse,
 )
 from .simulation_result import ResultCreate, ResultResponse, ResultListResponse
+from .generation import (
+    ParameterInfo, ParameterRegistryResponse,
+    RangeRecommendation, RangeRecommendRequest,
+    PlanGenerateRequest, PlanGenerateResponse,
+)
 
 __all__ = [
     "ProjectCreate", "ProjectUpdate", "ProjectResponse", "ProjectListResponse",
     "PlanCreate", "PlanUpdate", "PlanResponse", "PlanListResponse", "PlanDownloadResponse",
     "ResultCreate", "ResultResponse", "ResultListResponse",
+    "ParameterInfo", "ParameterRegistryResponse",
+    "RangeRecommendation", "RangeRecommendRequest",
+    "PlanGenerateRequest", "PlanGenerateResponse",
 ]

+ 57 - 0
web/backend/app/schemas/generation.py

@@ -0,0 +1,57 @@
+"""Schemas for plan generation and rule engine."""
+from typing import Optional, Any
+from pydantic import BaseModel, Field
+
+
+class ParameterInfo(BaseModel):
+    """Information about a scannable parameter."""
+    name: str
+    display_name: str
+    unit: str
+    category: str
+    default_start: float
+    default_stop: float
+    default_step: float
+    min_allowed: float
+    max_allowed: float
+    description: str = ""
+    topology_supported: list[str] = Field(default_factory=list)
+
+
+class ParameterRegistryResponse(BaseModel):
+    """Response containing all scannable parameters."""
+    total: int
+    parameters: list[ParameterInfo]
+
+
+class RangeRecommendation(BaseModel):
+    """Recommended scan range for a parameter."""
+    start: float
+    stop: float
+    step: float
+    notes: str = ""
+
+
+class RangeRecommendRequest(BaseModel):
+    """Request for range recommendation."""
+    parameter_name: str
+    boundary_conditions: dict[str, Any] = Field(default_factory=dict)
+
+
+class PlanGenerateRequest(BaseModel):
+    """Request to generate a plan from boundary conditions."""
+    boundary_conditions: dict[str, Any] = Field(default_factory=dict)
+    parameter_names: Optional[list[str]] = None
+    model_path: str = ""
+
+
+class PlanGenerateResponse(BaseModel):
+    """Response containing a generated plan draft."""
+    name: str
+    model_path: str
+    topology: str
+    variables: list[dict[str, Any]]
+    cases: list[dict[str, Any]] = Field(default_factory=list)
+    estimated_points: int
+    estimated_time_min: float
+    generation_summary: dict[str, Any] = Field(default_factory=dict)

+ 1 - 0
web/backend/app/services/__init__.py

@@ -0,0 +1 @@
+"""Service layer for business logic."""

+ 439 - 0
web/backend/app/services/rule_engine.py

@@ -0,0 +1,439 @@
+"""Rule engine for AFM motor simulation plan generation.
+
+Provides:
+- Scan parameter registry (Motor-CAD variable names, units, physical ranges)
+- Boundary-condition-based range recommendation
+- Initial plan generation from project boundary conditions
+
+All source is ASCII. Chinese labels use Unicode escapes.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, field
+from typing import Any
+
+
+# ---------------------------------------------------------------------------
+# Scan parameter registry
+# ---------------------------------------------------------------------------
+
+@dataclass
+class ScanParameter:
+    """Definition of a scannable Motor-CAD parameter."""
+    name: str                    # Motor-CAD variable name (exact)
+    display_name: str            # Human-readable label
+    unit: str                    # Unit string
+    category: str                # Geometry / Electrical / Thermal / Mesh
+    default_start: float         # Recommended range start
+    default_stop: float          # Recommended range stop
+    default_step: float          # Recommended step
+    min_allowed: float           # Hard physical minimum
+    max_allowed: float           # Hard physical maximum
+    description: str = ""
+    topology_supported: list[str] = field(default_factory=lambda: ["SSSR", "DRSS", "SDSR"])
+
+
+# Registry of scannable parameters for AFM motors.
+# Ranges based on MARS-12S10P reference model and AFM design literature.
+SCAN_PARAMETERS: dict[str, ScanParameter] = {
+    # --- Geometry ---
+    "Airgap": ScanParameter(
+        name="Airgap",
+        display_name="Airgap Length",
+        unit="mm",
+        category="Geometry",
+        default_start=0.6, default_stop=1.5, default_step=0.3,
+        min_allowed=0.2, max_allowed=5.0,
+        description="Mechanical airgap between stator and rotor.",
+    ),
+    "Magnet_Length": ScanParameter(
+        name="Magnet_Length",
+        display_name="Magnet Axial Thickness",
+        unit="mm",
+        category="Geometry",
+        default_start=2.0, default_stop=5.0, default_step=1.0,
+        min_allowed=0.5, max_allowed=15.0,
+        description="Magnet axial thickness (NOT radial depth).",
+    ),
+    "Magnet_Thickness": ScanParameter(
+        name="Magnet_Thickness",
+        display_name="Magnet Radial Depth",
+        unit="mm",
+        category="Geometry",
+        default_start=8.0, default_stop=16.0, default_step=2.0,
+        min_allowed=2.0, max_allowed=40.0,
+        description="Magnet ring radial depth = (D_out - D_in) / 2.",
+    ),
+    "Magnet_Arc_[ED]": ScanParameter(
+        name="Magnet_Arc_[ED]",
+        display_name="Magnet Pole Arc",
+        unit="deg",
+        category="Geometry",
+        default_start=100.0, default_stop=140.0, default_step=10.0,
+        min_allowed=30.0, max_allowed=180.0,
+        description="Magnet pole arc in electrical degrees.",
+    ),
+    # --- Electrical ---
+    "RMSCurrent": ScanParameter(
+        name="RMSCurrent",
+        display_name="RMS Phase Current",
+        unit="A",
+        category="Electrical",
+        default_start=10.0, default_stop=30.0, default_step=5.0,
+        min_allowed=0.5, max_allowed=200.0,
+        description="RMS phase current (CurrentDefinition=1).",
+    ),
+    "Shaft_Speed": ScanParameter(
+        name="Shaft_Speed",
+        display_name="Shaft Speed",
+        unit="rpm",
+        category="Electrical",
+        default_start=3000.0, default_stop=8000.0, default_step=1000.0,
+        min_allowed=100.0, max_allowed=50000.0,
+        description="Rotational shaft speed.",
+    ),
+    # --- Thermal ---
+    "Magnet_Temperature": ScanParameter(
+        name="Magnet_Temperature",
+        display_name="Magnet Temperature",
+        unit="C",
+        category="Thermal",
+        default_start=60.0, default_stop=120.0, default_step=20.0,
+        min_allowed=-40.0, max_allowed=200.0,
+        description="Magnet operating temperature (default 100C hot).",
+    ),
+    # --- Mesh ---
+    "TorquePointsPerCycle": ScanParameter(
+        name="TorquePointsPerCycle",
+        display_name="Torque Points / Cycle",
+        unit="pts",
+        category="Mesh",
+        default_start=60.0, default_stop=180.0, default_step=60.0,
+        min_allowed=12.0, max_allowed=720.0,
+        description="Torque sampling points per electrical cycle.",
+    ),
+}
+
+
+def get_parameter_registry() -> list[dict[str, Any]]:
+    """Return the scan parameter registry as a list of dicts (for API)."""
+    return [
+        {
+            "name": p.name,
+            "display_name": p.display_name,
+            "unit": p.unit,
+            "category": p.category,
+            "default_start": p.default_start,
+            "default_stop": p.default_stop,
+            "default_step": p.default_step,
+            "min_allowed": p.min_allowed,
+            "max_allowed": p.max_allowed,
+            "description": p.description,
+            "topology_supported": p.topology_supported,
+        }
+        for p in SCAN_PARAMETERS.values()
+    ]
+
+
+def get_parameter(name: str) -> ScanParameter | None:
+    """Get a parameter definition by name."""
+    return SCAN_PARAMETERS.get(name)
+
+
+# ---------------------------------------------------------------------------
+# Boundary condition based range recommendation
+# ---------------------------------------------------------------------------
+
+@dataclass
+class BoundaryConditions:
+    """Parsed project boundary conditions."""
+    topology: str = "SSSR"
+    outer_diameter_mm: float | None = None
+    inner_diameter_mm: float | None = None
+    speed_rpm: float | None = None
+    current_a: float | None = None
+    magnet_temp_c: float | None = None
+    target_torque_nm: float | None = None
+    target_efficiency_pct: float | None = None
+    max_losses_w: float | None = None
+    raw: dict[str, Any] = field(default_factory=dict)
+
+    @classmethod
+    def from_dict(cls, data: dict[str, Any]) -> "BoundaryConditions":
+        """Parse boundary conditions from a project's JSON dict."""
+        if not data:
+            return cls()
+        return cls(
+            topology=data.get("topology", "SSSR"),
+            outer_diameter_mm=_to_float(data.get("outer_diameter_mm")),
+            inner_diameter_mm=_to_float(data.get("inner_diameter_mm")),
+            speed_rpm=_to_float(data.get("speed_rpm")),
+            current_a=_to_float(data.get("current_a")),
+            magnet_temp_c=_to_float(data.get("magnet_temp_c")),
+            target_torque_nm=_to_float(data.get("target_torque_nm")),
+            target_efficiency_pct=_to_float(data.get("target_efficiency_pct")),
+            max_losses_w=_to_float(data.get("max_losses_w")),
+            raw=data,
+        )
+
+
+def _to_float(v: Any) -> float | None:
+    """Safely convert a value to float."""
+    if v is None:
+        return None
+    try:
+        return float(v)
+    except (ValueError, TypeError):
+        return None
+
+
+def recommend_range(
+    param_name: str,
+    bc: BoundaryConditions,
+) -> dict[str, float]:
+    """Recommend a scan range for a parameter based on boundary conditions.
+
+    Returns dict with start, stop, step, and a confidence note.
+    Adjustments are heuristic and based on AFM design scaling rules.
+    """
+    p = get_parameter(param_name)
+    if p is None:
+        return {"start": 0.0, "stop": 1.0, "step": 0.5}
+
+    start = p.default_start
+    stop = p.default_stop
+    step = p.default_step
+    notes: list[str] = []
+
+    # --- Airgap: scale with outer diameter ---
+    if param_name == "Airgap":
+        if bc.outer_diameter_mm:
+            # Smaller machines tend to use smaller airgaps.
+            # Airgap ~ 0.5-2% of outer diameter for AFM.
+            d = bc.outer_diameter_mm
+            start = max(p.min_allowed, round(d * 0.008, 2))   # ~0.8% D
+            stop = min(p.max_allowed, round(d * 0.02, 2))     # ~2% D
+            step = round((stop - start) / 3, 2)
+            notes.append(f"scaled from D={d}mm")
+        if bc.target_torque_nm and bc.target_torque_nm > 5:
+            # Higher torque targets benefit from smaller airgap exploration.
+            start = max(p.min_allowed, start * 0.8)
+            notes.append("torque target > 5Nm: shifted lower")
+
+    # --- Magnet axial thickness: scale with diameter and speed ---
+    elif param_name == "Magnet_Length":
+        if bc.outer_diameter_mm:
+            d = bc.outer_diameter_mm
+            start = max(p.min_allowed, round(d * 0.03, 1))   # ~3% D
+            stop = min(p.max_allowed, round(d * 0.07, 1))    # ~7% D
+            step = round((stop - start) / 3, 1)
+            notes.append(f"scaled from D={d}mm")
+        if bc.speed_rpm and bc.speed_rpm > 10000:
+            # High speed: thinner magnets reduce eddy current loss.
+            stop = min(stop, p.default_stop)
+            notes.append("high speed: capped upper range")
+
+    # --- Magnet radial depth: scale with diameter ratio ---
+    elif param_name == "Magnet_Thickness":
+        if bc.outer_diameter_mm and bc.inner_diameter_mm:
+            radial_depth = (bc.outer_diameter_mm - bc.inner_diameter_mm) / 2
+            start = max(p.min_allowed, round(radial_depth * 0.6, 1))
+            stop = min(p.max_allowed, round(radial_depth * 1.0, 1))
+            step = round((stop - start) / 3, 1)
+            notes.append(f"scaled from radial depth={radial_depth}mm")
+        elif bc.outer_diameter_mm:
+            d = bc.outer_diameter_mm
+            start = max(p.min_allowed, round(d * 0.1, 1))
+            stop = min(p.max_allowed, round(d * 0.25, 1))
+            step = round((stop - start) / 3, 1)
+            notes.append(f"scaled from D={d}mm")
+
+    # --- Pole arc: standard range, adjust for torque/ripple targets ---
+    elif param_name == "Magnet_Arc_[ED]":
+        if bc.target_torque_nm:
+            # Wider arc -> more torque but more ripple.
+            start = 110.0
+            stop = 150.0
+            notes.append("torque target: widened upper range")
+        else:
+            start = 100.0
+            stop = 140.0
+
+    # --- RMS current: center around specified current ---
+    elif param_name == "RMSCurrent":
+        if bc.current_a:
+            i = bc.current_a
+            start = max(p.min_allowed, round(i * 0.5, 1))
+            stop = min(p.max_allowed, round(i * 1.5, 1))
+            step = round((stop - start) / 4, 1)
+            notes.append(f"centered on I={i}A")
+        if bc.max_losses_w:
+            # Loss-limited: don't go too high on current.
+            stop = min(stop, p.default_stop * 1.2)
+            notes.append("loss constraint: capped upper")
+
+    # --- Shaft speed: center around specified speed ---
+    elif param_name == "Shaft_Speed":
+        if bc.speed_rpm:
+            s = bc.speed_rpm
+            start = max(p.min_allowed, round(s * 0.6, -2))
+            stop = min(p.max_allowed, round(s * 1.4, -2))
+            step = round((stop - start) / 4, -2)
+            notes.append(f"centered on N={s}rpm")
+
+    # --- Magnet temperature: hot/cold sweep ---
+    elif param_name == "Magnet_Temperature":
+        if bc.magnet_temp_c:
+            t = bc.magnet_temp_c
+            start = max(p.min_allowed, t - 40)
+            stop = min(p.max_allowed, t + 40)
+            step = 20.0
+            notes.append(f"centered on T={t}C")
+        else:
+            start = 20.0   # cold
+            stop = 120.0   # hot
+            step = 20.0
+            notes.append("default cold-to-hot sweep")
+
+    # --- Torque points: based on speed/accuracy needs ---
+    elif param_name == "TorquePointsPerCycle":
+        start = 60.0
+        stop = 180.0
+        step = 60.0
+        if bc.target_efficiency_pct and bc.target_efficiency_pct > 90:
+            stop = 240.0
+            notes.append("high efficiency target: extended upper range")
+
+    # Clamp to physical limits
+    start = max(p.min_allowed, min(p.max_allowed, start))
+    stop = max(p.min_allowed, min(p.max_allowed, stop))
+    if start >= stop:
+        start = p.default_start
+        stop = p.default_stop
+    if step <= 0:
+        step = p.default_step
+
+    return {
+        "start": round(start, 4),
+        "stop": round(stop, 4),
+        "step": round(step, 4),
+        "notes": "; ".join(notes) if notes else "default range",
+    }
+
+
+# ---------------------------------------------------------------------------
+# Plan generation
+# ---------------------------------------------------------------------------
+
+def generate_values(start: float, stop: float, step: float) -> list[float]:
+    """Generate evenly spaced values from start to stop inclusive.
+
+    Uses round to avoid floating point artifacts.
+    """
+    if step <= 0:
+        return [start]
+    count = int(math.floor((stop - start) / step + 1e-9)) + 1
+    values = [round(start + i * step, 6) for i in range(count)]
+    # Ensure stop is included if rounding pushed it slightly off
+    if values and abs(values[-1] - stop) > 1e-9:
+        values.append(round(stop, 6))
+    return values
+
+
+def estimate_point_count(variables: list[dict[str, Any]]) -> int:
+    """Estimate total scan points (Cartesian product)."""
+    total = 1
+    for v in variables:
+        if v.get("values"):
+            total *= len(v["values"])
+        elif v.get("start") is not None and v.get("stop") is not None and v.get("step"):
+            total *= len(generate_values(v["start"], v["stop"], v["step"]))
+        else:
+            total *= 1
+    return total
+
+
+def generate_plan(
+    bc: BoundaryConditions,
+    param_names: list[str] | None = None,
+    model_path: str = "",
+) -> dict[str, Any]:
+    """Generate a recommended simulation plan from boundary conditions.
+
+    Args:
+        bc: Parsed boundary conditions.
+        param_names: List of parameter names to include. If None, uses a
+            default set (Airgap, Magnet_Length, RMSCurrent).
+        model_path: Path to the baseline .mot model.
+
+    Returns:
+        Plan dict compatible with src/plan_schema.py and the plans API.
+    """
+    if param_names is None:
+        # Default scan set: geometry + electrical
+        param_names = ["Airgap", "Magnet_Length", "RMSCurrent"]
+
+    variables = []
+    for name in param_names:
+        p = get_parameter(name)
+        if p is None:
+            continue
+        if bc.topology not in p.topology_supported:
+            continue
+        rng = recommend_range(name, bc)
+        values = generate_values(rng["start"], rng["stop"], rng["step"])
+        variables.append({
+            "name": name,
+            "display_name": p.display_name,
+            "unit": p.unit,
+            "start": rng["start"],
+            "stop": rng["stop"],
+            "step": rng["step"],
+            "values": values,
+            "recommendation_notes": rng["notes"],
+        })
+
+    total_points = estimate_point_count(variables)
+    plan_name = _generate_plan_name(bc, param_names)
+
+    return {
+        "name": plan_name,
+        "model_path": model_path,
+        "topology": bc.topology,
+        "variables": variables,
+        "cases": [],
+        "estimated_points": total_points,
+        "estimated_time_min": total_points * 3,  # ~3 min per point
+        "generation_summary": {
+            "boundary_conditions_used": {
+                k: v for k, v in bc.raw.items() if v is not None
+            },
+            "parameters_scanned": [v["name"] for v in variables],
+            "total_points": total_points,
+            "rule_engine_version": "1.0",
+        },
+    }
+
+
+def _generate_plan_name(bc: BoundaryConditions, param_names: list[str]) -> str:
+    """Generate a human-readable plan name."""
+    short = {
+        "Airgap": "Ag",
+        "Magnet_Length": "Lm",
+        "Magnet_Thickness": "Tm",
+        "Magnet_Arc_[ED]": "Arc",
+        "RMSCurrent": "I",
+        "Shaft_Speed": "N",
+        "Magnet_Temperature": "Tmag",
+        "TorquePointsPerCycle": "Pts",
+    }
+    tags = [short.get(n, n) for n in param_names]
+    parts = ["Auto"]
+    if bc.topology:
+        parts.append(bc.topology)
+    parts.append("-".join(tags))
+    if bc.outer_diameter_mm:
+        parts.append(f"D{bc.outer_diameter_mm:g}")
+    return "_".join(parts)

+ 12 - 0
web/frontend/src/api/index.ts

@@ -45,4 +45,16 @@ export const experienceApi = {
   delete: (id: number) => api.delete(`/experience/${id}`)
 }
 
+// Generation / Rule Engine APIs
+export const generationApi = {
+  listScanParameters: (category?: string) =>
+    api.get('/scan-parameters', { params: category ? { category } : {} }),
+  recommendRange: (parameterName: string, boundaryConditions: Record<string, any>) =>
+    api.post('/recommend-range', { parameter_name: parameterName, boundary_conditions: boundaryConditions }),
+  generatePlan: (data: { boundary_conditions: Record<string, any>; parameter_names?: string[]; model_path?: string }) =>
+    api.post('/generate-plan', data),
+  generatePlanForProject: (projectId: number, data: { boundary_conditions?: Record<string, any>; parameter_names?: string[]; model_path?: string }) =>
+    api.post(`/projects/${projectId}/generate-plan`, data)
+}
+
 export default api

+ 338 - 32
web/frontend/src/views/ProjectDetail.vue

@@ -66,46 +66,318 @@
       </el-table>
     </div>
 
-    <!-- Create Plan Dialog -->
-    <el-dialog v-model="showCreatePlan" title="Create New Plan" width="600px">
-      <el-form :model="newPlan" label-width="120px">
-        <el-form-item label="Plan Name">
-          <el-input v-model="newPlan.name" placeholder="e.g. Airgap Scan 0.6-1.5mm" />
-        </el-form-item>
-        <el-form-item label="Model Path">
-          <el-input v-model="newPlan.model_path" />
-        </el-form-item>
-        <el-form-item label="Variables (JSON)">
-          <el-input v-model="newPlan.variables_json" type="textarea" :rows="6"
-            placeholder='[{"name":"Airgap","display_name":"Airgap","unit":"mm","values":[0.6,1.0,1.5]}]' />
-        </el-form-item>
+    <!-- Create Plan Dialog with Visual Variable Editor -->
+    <el-dialog v-model="showCreatePlan" title="Create New Simulation Plan" width="900px" top="5vh">
+      <el-form :model="planForm" label-width="100px">
+        <el-row :gutter="12">
+          <el-col :span="14">
+            <el-form-item label="Plan Name">
+              <el-input v-model="planForm.name" placeholder="e.g. Airgap Scan 0.6-1.5mm" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="10">
+            <el-form-item label="Model Path">
+              <el-input v-model="planForm.model_path" :placeholder="project?.model_path || 'models/xxx.mot'" />
+            </el-form-item>
+          </el-col>
+        </el-row>
         <el-form-item label="Notes">
-          <el-input v-model="newPlan.notes" type="textarea" :rows="2" />
+          <el-input v-model="planForm.notes" type="textarea" :rows="1" />
         </el-form-item>
       </el-form>
+
+      <el-divider content-position="left">Scan Variables</el-divider>
+
+      <div class="plan-editor-toolbar">
+        <el-button type="primary" size="small" @click="addVariable">
+          <el-icon><Plus /></el-icon> Add Variable
+        </el-button>
+        <el-button type="success" size="small" @click="generateFromRules" :loading="generating">
+          <el-icon><MagicStick /></el-icon> Generate from Rules
+        </el-button>
+        <el-button size="small" @click="clearVariables">Clear All</el-button>
+        <div class="plan-estimate">
+          <el-tag type="info">Total Points: <b>{{ estimatedPoints }}</b></el-tag>
+          <el-tag type="warning" style="margin-left: 8px">Est. Time: <b>{{ estimatedTimeMin }} min</b></el-tag>
+        </div>
+      </div>
+
+      <el-table :data="planVariables" size="small" border class="variable-table" max-height="320">
+        <el-table-column label="#" type="index" width="40" align="center" />
+        <el-table-column label="Parameter" width="200">
+          <template #default="{ row }">
+            <el-select v-model="row.name" size="small" filterable @change="onParamChange(row)" style="width: 100%">
+              <el-option
+                v-for="p in availableParams(row.name)"
+                :key="p.name"
+                :label="`${p.display_name} (${p.unit})`"
+                :value="p.name"
+              >
+                <span style="float: left">{{ p.display_name }}</span>
+                <span style="float: right; color: #8492a6; font-size: 12px">{{ p.category }} / {{ p.unit }}</span>
+              </el-option>
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="Mode" width="90" align="center">
+          <template #default="{ row }">
+            <el-radio-group v-model="row.mode" size="small" @change="onModeChange(row)">
+              <el-radio-button label="range">Range</el-radio-button>
+              <el-radio-button label="values">Values</el-radio-button>
+            </el-radio-group>
+          </template>
+        </el-table-column>
+        <el-table-column label="Start" width="90">
+          <template #default="{ row }">
+            <el-input-number v-if="row.mode === 'range'" v-model="row.start" :step="row.step || 0.1" :precision="4" controls-position="right" size="small" style="width: 100%" @change="updateValues(row)" />
+            <span v-else style="color: #c0c4cc">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="Stop" width="90">
+          <template #default="{ row }">
+            <el-input-number v-if="row.mode === 'range'" v-model="row.stop" :step="row.step || 0.1" :precision="4" controls-position="right" size="small" style="width: 100%" @change="updateValues(row)" />
+            <span v-else style="color: #c0c4cc">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="Step" width="80">
+          <template #default="{ row }">
+            <el-input-number v-if="row.mode === 'range'" v-model="row.step" :min="0.001" :precision="4" controls-position="right" size="small" style="width: 100%" @change="updateValues(row)" />
+            <span v-else style="color: #c0c4cc">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="Explicit Values">
+          <template #default="{ row }">
+            <el-input
+              v-if="row.mode === 'values'"
+              v-model="row.values_text"
+              size="small"
+              placeholder="e.g. 0.6, 1.0, 1.5"
+              @change="parseValues(row)"
+            />
+            <span v-else style="font-size: 12px; color: #606266">{{ row.values?.join(', ') || '' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="Count" width="60" align="center">
+          <template #default="{ row }">
+            <el-tag size="small" :type="row.values?.length > 0 ? 'success' : 'info'">{{ row.values?.length || 0 }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="Actions" width="60" align="center" fixed="right">
+          <template #default="{ $index }">
+            <el-button size="small" text type="danger" @click="removeVariable($index)">
+              <el-icon><Delete /></el-icon>
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div v-if="planVariables.length === 0" style="text-align: center; padding: 24px; color: #909399">
+        No scan variables. Click "Add Variable" or "Generate from Rules" to start.
+      </div>
+
       <template #footer>
         <el-button @click="showCreatePlan = false">Cancel</el-button>
-        <el-button type="primary" @click="createPlan" :loading="creating">Create</el-button>
+        <el-button type="primary" @click="createPlan" :loading="creating" :disabled="planVariables.length === 0">
+          Create Plan ({{ estimatedPoints }} points)
+        </el-button>
       </template>
     </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import { ArrowLeft, Plus } from '@element-plus/icons-vue'
-import { projectApi, planApi } from '@/api'
+import { ArrowLeft, Plus, Delete, MagicStick } from '@element-plus/icons-vue'
+import { projectApi, planApi, generationApi } from '@/api'
 
 const route = useRoute()
 const router = useRouter()
 const loading = ref(false)
 const creating = ref(false)
+const generating = ref(false)
 const project = ref<any>(null)
 const plans = ref<any[]>([])
 const showCreatePlan = ref(false)
-const newPlan = ref({ name: '', model_path: '', variables_json: '', notes: '' })
+
+// Plan form
+const planForm = ref({ name: '', model_path: '', notes: '' })
+
+// Scan parameter registry (loaded from API)
+const scanParams = ref<any[]>([])
+
+// Plan variables (editable table rows)
+interface PlanVariable {
+  name: string
+  display_name: string
+  unit: string
+  mode: 'range' | 'values'
+  start: number
+  stop: number
+  step: number
+  values: number[]
+  values_text: string
+}
+const planVariables = ref<PlanVariable[]>([])
+
+// Load scan parameter registry
+async function loadScanParams() {
+  try {
+    const res = await generationApi.listScanParameters()
+    scanParams.value = res.data.parameters
+  } catch (e: any) {
+    ElMessage.error('Failed to load scan parameters: ' + e.message)
+  }
+}
+
+// Filter available params (exclude already used, except current row)
+function availableParams(currentName: string) {
+  const used = new Set(planVariables.value.filter(v => v.name !== currentName).map(v => v.name))
+  return scanParams.value.filter(p => !used.has(p.name))
+}
+
+// Add a new variable row
+function addVariable() {
+  if (scanParams.value.length === 0) {
+    ElMessage.warning('Scan parameters not loaded yet')
+    return
+  }
+  const available = availableParams('')
+  if (available.length === 0) {
+    ElMessage.warning('All parameters already added')
+    return
+  }
+  const p = available[0]
+  planVariables.value.push({
+    name: p.name,
+    display_name: p.display_name,
+    unit: p.unit,
+    mode: 'range',
+    start: p.default_start,
+    stop: p.default_stop,
+    step: p.default_step,
+    values: generateValues(p.default_start, p.default_stop, p.default_step),
+    values_text: '',
+  })
+}
+
+// Remove a variable
+function removeVariable(index: number) {
+  planVariables.value.splice(index, 1)
+}
+
+// Clear all variables
+function clearVariables() {
+  planVariables.value = []
+}
+
+// On parameter change: update metadata and defaults
+function onParamChange(row: PlanVariable) {
+  const p = scanParams.value.find(sp => sp.name === row.name)
+  if (p) {
+    row.display_name = p.display_name
+    row.unit = p.unit
+    row.start = p.default_start
+    row.stop = p.default_stop
+    row.step = p.default_step
+    if (row.mode === 'range') {
+      row.values = generateValues(row.start, row.stop, row.step)
+    }
+  }
+}
+
+// On mode change
+function onModeChange(row: PlanVariable) {
+  if (row.mode === 'range') {
+    row.values = generateValues(row.start, row.stop, row.step)
+    row.values_text = ''
+  } else {
+    row.values_text = row.values.join(', ')
+  }
+}
+
+// Update values from range inputs
+function updateValues(row: PlanVariable) {
+  if (row.mode === 'range') {
+    row.values = generateValues(row.start, row.stop, row.step)
+  }
+}
+
+// Parse explicit values from text
+function parseValues(row: PlanVariable) {
+  if (row.mode !== 'values') return
+  const parts = row.values_text.split(/[,;\s]+/).filter(s => s.trim() !== '')
+  const vals: number[] = []
+  for (const p of parts) {
+    const n = parseFloat(p)
+    if (!isNaN(n)) vals.push(n)
+  }
+  row.values = vals
+}
+
+// Generate evenly spaced values
+function generateValues(start: number, stop: number, step: number): number[] {
+  if (!step || step <= 0 || start === undefined || stop === undefined) return []
+  const count = Math.floor((stop - start) / step + 1e-9) + 1
+  if (count <= 0 || count > 1000) return []
+  const vals = []
+  for (let i = 0; i < count; i++) {
+    vals.push(parseFloat((start + i * step).toFixed(6)))
+  }
+  // Ensure stop is included
+  if (vals.length > 0 && Math.abs(vals[vals.length - 1] - stop) > 1e-9) {
+    vals.push(parseFloat(stop.toFixed(6)))
+  }
+  return vals
+}
+
+// Estimate total points (Cartesian product)
+const estimatedPoints = computed(() => {
+  let total = 1
+  for (const v of planVariables.value) {
+    const count = v.values?.length || 0
+    total *= count > 0 ? count : 1
+  }
+  return planVariables.value.length > 0 ? total : 0
+})
+
+const estimatedTimeMin = computed(() => estimatedPoints.value * 3)
+
+// Generate plan from rules (backend rule engine)
+async function generateFromRules() {
+  if (!project.value) {
+    ElMessage.warning('Project not loaded')
+    return
+  }
+  generating.value = true
+  try {
+    const res = await generationApi.generatePlanForProject(
+      Number(route.params.id),
+      { model_path: project.value.model_path || '' }
+    )
+    const generated = res.data
+    planForm.value.name = generated.name
+    planForm.value.model_path = generated.model_path || project.value.model_path || ''
+    // Convert generated variables to editor format
+    planVariables.value = (generated.variables || []).map((v: any) => ({
+      name: v.name,
+      display_name: v.display_name || v.name,
+      unit: v.unit || '',
+      mode: 'range',
+      start: v.start ?? v.values?.[0] ?? 0,
+      stop: v.stop ?? v.values?.[v.values.length - 1] ?? 0,
+      step: v.step ?? 1,
+      values: v.values || [],
+      values_text: '',
+    }))
+    ElMessage.success(`Generated plan with ${generated.estimated_points} points`)
+  } catch (e: any) {
+    ElMessage.error('Failed to generate plan: ' + e.message)
+  } finally {
+    generating.value = false
+  }
+}
 
 async function loadProject() {
   loading.value = true
@@ -123,34 +395,45 @@ async function loadProject() {
 }
 
 async function createPlan() {
-  if (!newPlan.value.name.trim()) {
+  if (!planForm.value.name.trim()) {
     ElMessage.warning('Please enter a plan name')
     return
   }
-  let variables = []
-  try {
-    variables = JSON.parse(newPlan.value.variables_json || '[]')
-  } catch {
-    ElMessage.error('Invalid variables JSON')
+  if (planVariables.value.length === 0) {
+    ElMessage.warning('Please add at least one scan variable')
     return
   }
+  // Validate all variables have values
+  for (const v of planVariables.value) {
+    if (!v.values || v.values.length === 0) {
+      ElMessage.warning(`Variable "${v.name}" has no valid values`)
+      return
+    }
+  }
   creating.value = true
   try {
+    // Build plan_data compatible with src/plan_schema.py
+    const variables = planVariables.value.map(v => ({
+      name: v.name,
+      display_name: v.display_name,
+      unit: v.unit,
+      values: v.values,
+    }))
     const planData = {
-      model_path: newPlan.value.model_path || project.value?.model_path || '',
+      model_path: planForm.value.model_path || project.value?.model_path || '',
       topology: project.value?.topology || 'SSSR',
       variables,
-      cases: []
+      cases: [],
     }
     await planApi.create({
       project_id: Number(route.params.id),
-      name: newPlan.value.name,
+      name: planForm.value.name,
       plan_data: planData,
-      notes: newPlan.value.notes
+      notes: planForm.value.notes,
     })
-    ElMessage.success('Plan created')
+    ElMessage.success(`Plan created with ${estimatedPoints.value} points`)
     showCreatePlan.value = false
-    newPlan.value = { name: '', model_path: '', variables_json: '', notes: '' }
+    resetPlanForm()
     await loadProject()
   } catch (e: any) {
     ElMessage.error('Failed to create plan: ' + e.message)
@@ -159,6 +442,11 @@ async function createPlan() {
   }
 }
 
+function resetPlanForm() {
+  planForm.value = { name: '', model_path: '', notes: '' }
+  planVariables.value = []
+}
+
 function goToPlan(row: any) {
   router.push(`/plans/${row.id}`)
 }
@@ -172,5 +460,23 @@ function formatDate(d: string) {
   return new Date(d).toLocaleString()
 }
 
-onMounted(loadProject)
+onMounted(() => {
+  loadProject()
+  loadScanParams()
+})
 </script>
+
+<style scoped>
+.plan-editor-toolbar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+  gap: 8px;
+}
+.plan-estimate {
+  margin-left: auto;
+}
+.variable-table {
+  margin-bottom: 12px;
+}
+</style>

+ 95 - 4
web/frontend/src/views/ProjectList.vue

@@ -66,15 +66,16 @@
     </div>
 
     <!-- Create Project Dialog -->
-    <el-dialog v-model="showCreateDialog" title="Create New Project" width="500px">
-      <el-form :model="newProject" label-width="120px">
+    <el-dialog v-model="showCreateDialog" title="Create New Project" width="640px">
+      <el-form :model="newProject" label-width="130px">
         <el-form-item label="Project Name">
           <el-input v-model="newProject.name" placeholder="e.g. MARS Airgap Optimization" />
         </el-form-item>
         <el-form-item label="Topology">
-          <el-select v-model="newProject.topology">
+          <el-select v-model="newProject.topology" style="width: 100%">
             <el-option label="SSSR (Single Stator Single Rotor)" value="SSSR" />
             <el-option label="DRSS (Double Rotor Single Stator)" value="DRSS" />
+            <el-option label="SDSR (Single Stator Double Rotor)" value="SDSR" />
           </el-select>
         </el-form-item>
         <el-form-item label="Model Path">
@@ -83,6 +84,68 @@
         <el-form-item label="Description">
           <el-input v-model="newProject.description" type="textarea" :rows="2" />
         </el-form-item>
+
+        <el-divider content-position="left">Boundary Conditions</el-divider>
+
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="Outer Diameter">
+              <el-input-number v-model="bc.outer_diameter_mm" :min="10" :max="500" :step="1" :precision="1" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">mm</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="Inner Diameter">
+              <el-input-number v-model="bc.inner_diameter_mm" :min="0" :max="400" :step="1" :precision="1" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">mm</span>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="Shaft Speed">
+              <el-input-number v-model="bc.speed_rpm" :min="100" :max="50000" :step="500" :precision="0" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">rpm</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="RMS Current">
+              <el-input-number v-model="bc.current_a" :min="0.5" :max="200" :step="1" :precision="1" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">A</span>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="Magnet Temp">
+              <el-input-number v-model="bc.magnet_temp_c" :min="-40" :max="200" :step="10" :precision="0" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">C</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="Target Torque">
+              <el-input-number v-model="bc.target_torque_nm" :min="0" :max="1000" :step="0.1" :precision="2" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">Nm</span>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="Target Efficiency">
+              <el-input-number v-model="bc.target_efficiency_pct" :min="0" :max="100" :step="1" :precision="1" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">%</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="Max Losses">
+              <el-input-number v-model="bc.max_losses_w" :min="0" :max="10000" :step="10" :precision="0" controls-position="right" style="width: 100%" />
+              <span class="unit-suffix">W</span>
+            </el-form-item>
+          </el-col>
+        </el-row>
       </el-form>
       <template #footer>
         <el-button @click="showCreateDialog = false">Cancel</el-button>
@@ -106,6 +169,16 @@ const projects = ref<any[]>([])
 const total = ref(0)
 const showCreateDialog = ref(false)
 const newProject = ref({ name: '', topology: 'SSSR', model_path: '', description: '' })
+const bc = ref<Record<string, number | null>>({
+  outer_diameter_mm: null,
+  inner_diameter_mm: null,
+  speed_rpm: null,
+  current_a: null,
+  magnet_temp_c: null,
+  target_torque_nm: null,
+  target_efficiency_pct: null,
+  max_losses_w: null,
+})
 
 const sssrCount = computed(() => projects.value.filter(p => p.topology === 'SSSR').length)
 const activeCount = computed(() => projects.value.filter(p => p.status === 'active').length)
@@ -131,10 +204,23 @@ async function createProject() {
   }
   creating.value = true
   try {
-    await projectApi.create(newProject.value)
+    // Build boundary_conditions from non-null bc values + topology
+    const boundaryConditions: Record<string, any> = { topology: newProject.value.topology }
+    for (const [key, val] of Object.entries(bc.value)) {
+      if (val !== null && val !== undefined) {
+        boundaryConditions[key] = val
+      }
+    }
+    const payload = { ...newProject.value, boundary_conditions: boundaryConditions }
+    await projectApi.create(payload)
     ElMessage.success('Project created')
     showCreateDialog.value = false
     newProject.value = { name: '', topology: 'SSSR', model_path: '', description: '' }
+    bc.value = {
+      outer_diameter_mm: null, inner_diameter_mm: null, speed_rpm: null,
+      current_a: null, magnet_temp_c: null, target_torque_nm: null,
+      target_efficiency_pct: null, max_losses_w: null,
+    }
     await loadProjects()
   } catch (e: any) {
     ElMessage.error('Failed to create project: ' + e.message)
@@ -174,4 +260,9 @@ onMounted(loadProjects)
 .stats-row {
   margin-bottom: 16px;
 }
+.unit-suffix {
+  margin-left: 8px;
+  color: #909399;
+  font-size: 13px;
+}
 </style>