"""Metrics single source of truth + normalized result parser. This module is THE authoritative definition of simulation output metrics for the whole platform. Consumers MUST import from here instead of maintaining their own copies: - src/solver_core.py (legacy local solver) - scripts/robust_motorcad.py (robust solver / executor) - web/backend/app/metrics_constants.py (Web backend) Design: - METRIC_DEFINITIONS: complete list of {key, label, aliases, unit, direction, required} - Normalized matching: full-width -> half-width brackets, strip ALL whitespace (incl. full-width space U+3000), lowercase. This fixes the historical bug where "Average torque (virtual work)" / "\\u5e73\\u5747\\u8f6c\\u77e9 (virtual work)" could not be matched due to invisible full-width chars. - parse_export(): section-aware semicolon CSV parser with tolerant numeric scan. - pick_metric()/extract_all_metrics(): section-priority exact match -> all-section exact match -> prefix fuzzy match. All source is ASCII only. """ from __future__ import annotations import os from pathlib import Path from typing import Dict, List, Optional, Tuple, Any # --------------------------------------------------------------------------- # Single source of truth: metric definitions # --------------------------------------------------------------------------- # unit: SI display unit. direction: higher/lower/neutral (for optimization). # required: True means a valid result MUST contain this metric. METRIC_DEFINITIONS: List[Dict[str, Any]] = [ # --- Torque --- { "key": "tavg_nm", "label": "Average Torque [Nm]", "unit": "Nm", "direction": "higher", "required": True, "aliases": [ "Average torque (virtual work)", "Average Torque (Virtual Work)", "Average torque (DQ)", "Average torque (loop torque)", "Torque (average)", "Average Torque", "\u5e73\u5747\u8f6c\u77e9 (virtual work)", "\u5e73\u5747\u8f6c\u77e9(virtual work)", "\u5e73\u5747\u8f6c\u77e9 (DQ)", "\u5e73\u5747\u8f6c\u77e9(DQ)", "\u5e73\u5747\u8f6c\u77e9 (loop torque)", "\u5e73\u5747\u8f6c\u77e9(loop torque)", "\u5e73\u5747\u8f6c\u77e9", ], }, { "key": "tmax_nm", "label": "Max Torque [Nm]", "unit": "Nm", "direction": "higher", "required": False, "aliases": [ "Maximum torque (virtual work)", "Max Torque", "\u6700\u5927\u8f6c\u77e9 (virtual work)", "\u6700\u5927\u8f6c\u77e9(virtual work)", "\u6700\u5927\u8f6c\u77e9", ], }, { "key": "tmin_nm", "label": "Min Torque [Nm]", "unit": "Nm", "direction": "higher", "required": False, "aliases": [ "Minimum torque (virtual work)", "Min Torque", "\u6700\u5c0f\u8f6c\u77e9 (virtual work)", "\u6700\u5c0f\u8f6c\u77e9(virtual work)", "\u6700\u5c0f\u8f6c\u77e9", ], }, { "key": "ripple_pct", "label": "Torque Ripple [%]", "unit": "%", "direction": "lower", "required": True, "aliases": [ "Torque Ripple (VW) [%]", "Torque Ripple (VW)[%]", "Torque Ripple (VW) %", "Torque Ripple (VW) (%)", "Torque Ripple [%]", "Torque Ripple (%)", "Torque Ripple %", "\u8f6c\u77e9\u8109\u52a8 (VW) [%]", "\u8f6c\u77e9\u8109\u52a8(VW)[%]", "\u8f6c\u77e9\u8109\u52a8 [%]", "\u8f6c\u77e9\u8109\u52a8[%]", "\u8f6c\u77e9\u8109\u52a8", ], }, { "key": "ripple_nm", "label": "Torque Ripple [Nm]", "unit": "Nm", "direction": "lower", "required": False, "aliases": [ "Torque Ripple (VW)", "Torque Ripple", "\u8f6c\u77e9\u8109\u52a8 (VW)", "\u8f6c\u77e9\u8109\u52a8(VW)", ], }, { "key": "ripple_abs_nm", "label": "Torque Ripple (abs) [Nm]", "unit": "Nm", "direction": "lower", "required": False, "aliases": [ "Torque Ripple (VW) (abs)", "\u8f6c\u77e9\u8109\u52a8\u7edd\u5bf9\u503c", ], }, { "key": "shaft_torque_nm", "label": "Shaft Torque [Nm]", "unit": "Nm", "direction": "higher", "required": False, "aliases": [ "Shaft Torque", "Shaft Torque (Nm)", "\u8f74\u8f6c\u77e9", "\u8f74\u8f6c\u77e9(Nm)", ], }, { "key": "stall_torque_nm", "label": "Stall Torque [Nm]", "unit": "Nm", "direction": "higher", "required": False, "aliases": [ "Stall Torque", "Stall Torque (Nm)", "\u5835\u8f6c\u8f6c\u77e9", "\u5835\u8f6c\u8f6c\u77e9(Nm)", ], }, { "key": "torque_constant", "label": "Torque Constant [Nm/A]", "unit": "Nm/A", "direction": "higher", "required": False, "aliases": [ "Torque Constant (Kt)", "Torque Constant", "Torque Constant [Nm/A]", "\u8f6c\u77e9\u5e38\u6570(Kt)", "\u8f6c\u77e9\u5e38\u6570\uff08Kt\uff09", ], }, # --- Efficiency / power --- { "key": "efficiency_pct", "label": "Efficiency [%]", "unit": "%", "direction": "higher", "required": True, "aliases": [ "System Efficiency", "Efficiency", "\u7cfb\u7edf\u6548\u7387", "\u6548\u7387", ], }, { "key": "input_power_w", "label": "Input Power [W]", "unit": "W", "direction": "neutral", "required": False, "aliases": [ "Input Power", "\u8f93\u5165\u529f\u7387", ], }, { "key": "output_power_w", "label": "Output Power [W]", "unit": "W", "direction": "higher", "required": False, "aliases": [ "Output Power", "\u8f93\u51fa\u529f\u7387", "\u8f93\u51fa\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9", ], }, { "key": "em_power_w", "label": "EM Power [W]", "unit": "W", "direction": "higher", "required": False, "aliases": [ "Electromagnetic Power", "EM Power", "\u7535\u78c1\u529f\u7387", "\u7535\u78c1\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9", ], }, { "key": "power_factor", "label": "Power Factor", "unit": "", "direction": "higher", "required": False, "aliases": [ "Power Factor", "\u529f\u7387\u56e0\u6570", ], }, # --- Losses --- { "key": "total_losses_w", "label": "Total Losses [W]", "unit": "W", "direction": "lower", "required": True, "aliases": [ "Total Losses (on load)", "Total Losses", "\u603b\u635f\u8017(\u989d\u5b9a)", "\u603b\u635f\u8017 (\u989d\u5b9a)", "\u603b\u635f\u8017(\u7a7a\u8f7d)", "\u603b\u635f\u8017", ], }, { "key": "copper_loss_w", "label": "DC Copper Loss [W]", "unit": "W", "direction": "lower", "required": False, "aliases": [ "Armature DC Copper Loss (on load)", "Armature Copper Loss (on load)", "DC Copper Loss (on load)", "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)", "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 (\u5e26\u8f7d)", "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u7a7a\u8f7d)", "\u7535\u67a2\u76f4\u6d41\u94dc\u8017", "\u94dc\u8017", ], }, { "key": "iron_loss_w", "label": "Stator Iron Loss [W]", "unit": "W", "direction": "lower", "required": False, "aliases": [ "Stator iron Loss [total] (on load)", "Stator Iron Loss [total] (on load)", "Stator Iron Loss", "Iron Loss", "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)", "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)", "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u7a7a\u8f7d)", "\u5b9a\u5b50\u94c1\u8017", "\u94c1\u635f", "\u94c1\u8017", ], }, { "key": "magnet_loss_w", "label": "Magnet Loss [W]", "unit": "W", "direction": "lower", "required": False, "aliases": [ "Magnet Loss (on load)", "Magnet Loss", "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)", "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)", "\u6c38\u78c1\u4f53\u635f\u8017(\u7a7a\u8f7d)", "\u78c1\u94a2\u635f\u8017", ], }, # --- Electrical --- { "key": "back_emf_v", "label": "Back EMF LL rms [V]", "unit": "V", "direction": "neutral", "required": False, "aliases": [ "Back EMF Line-Line Voltage (rms)", "Back EMF Line-Line Voltage", "Back EMF Line-Line Voltage (peak)", "Back EMF (rms)", "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c", "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u5e45\u503c", "\u53cd\u7535\u52a8\u52bf", ], }, { "key": "back_emf_thd_pct", "label": "Back EMF THD [%]", "unit": "%", "direction": "lower", "required": False, "aliases": [ "Harmonic Distortion Back EMF Line-Line Voltage", "Back EMF THD", "\u7ebf\u53cd\u5411\u7535\u52a8\u52bf\u8c10\u6ce2", "\u7ebf\u7535\u538b\u8c10\u6ce2", ], }, { "key": "phase_current_peak_a", "label": "Phase Current (peak) [A]", "unit": "A", "direction": "neutral", "required": False, "aliases": [ "Peak Phase Current", "Phase Current (peak)", "\u76f8\u7535\u6d41\u5cf0\u503c", ], }, { "key": "line_current_rms_a", "label": "Line Current (rms) [A]", "unit": "A", "direction": "neutral", "required": False, "aliases": [ "Line Current (rms)", "Line Current", "\u7ebf\u7535\u6d41 (\u6709\u6548\u503c)", "\u7ebf\u7535\u6d41(\u6709\u6548\u503c)", "\u76f8\u7535\u6d41\u6709\u6548\u503c", ], }, # --- Speed --- { "key": "shaft_speed_rpm", "label": "Shaft Speed [rpm]", "unit": "rpm", "direction": "neutral", "required": False, "aliases": [ "Shaft Speed", "Speed", "\u8f6c\u901f[RPM]", "\u8f6c\u901f [RPM]", "\u8f6c\u901f{RPM}", "\u8f6c\u901f", ], }, { "key": "no_load_speed_rpm", "label": "No-load Speed [rpm]", "unit": "rpm", "direction": "neutral", "required": False, "aliases": [ "No load speed", "No-Load Speed", "\u7a7a\u8f7d\u8f6c\u901f", ], }, # --- Thermal --- { "key": "winding_temp_c", "label": "Winding Temp [C]", "unit": "C", "direction": "lower", "required": False, "aliases": [ "Winding Temperature", "\u7ed5\u7ec4\u6e29\u5ea6", "T[\u7ed5\u7ec4\u5e73\u5747]", "T [Winding (A) Average]", ], }, { "key": "winding_hotspot_temp_c", "label": "Winding Hotspot Temp [C]", "unit": "C", "direction": "lower", "required": False, "domain": "thermal", "aliases": [ "Winding Hotspot Temperature", "Winding Hotspot Temp", "Hotspot Temperature", "\u7ed5\u7ec4\u70ed\u70b9\u6e29\u5ea6", "\u70ed\u70b9\u6e29\u5ea6", "T[\u7ed5\u7ec4\u6700\u9ad8]", "T [Winding (A) Maximum]", "T [EWdg (Outer) Maximum]", ], }, { "key": "magnet_temp_c", "label": "Magnet Temp [C]", "unit": "C", "direction": "lower", "required": False, "domain": "thermal", "aliases": [ "Magnet Temperature", "Magnet Temp", "Permanent Magnet Temperature", "\u6c38\u78c1\u4f53\u6e29\u5ea6", "\u78c1\u94a2\u6e29\u5ea6", "T [Magnet Average]", "T [Magnet Active]", "T [Magnet Maximum]", ], }, { "key": "stator_temp_c", "label": "Stator Temp [C]", "unit": "C", "direction": "lower", "required": False, "domain": "thermal", "aliases": [ "Stator Temperature", "Stator Temp", "Stator Winding Temperature", "\u5b9a\u5b50\u6e29\u5ea6", "T[\u5b9a\u5b50\u5916\u8868\u9762]", "T[\u5b9a\u5b50\u8f6d]", ], }, { "key": "bearing_temp_c", "label": "Bearing Temp [C]", "unit": "C", "direction": "lower", "required": False, "domain": "thermal", "aliases": [ "Bearing Temperature", "Bearing Temp", "\u8f74\u627f\u6e29\u5ea6", "T[\u540e\u8f74\u627f]", ], }, { "key": "temp_rise_c", "label": "Temperature Rise [C]", "unit": "C", "direction": "lower", "required": False, "domain": "thermal", "aliases": [ "Temperature Rise", "Temp Rise", "Winding Temperature Rise", "\u6e29\u5347", "\u7ed5\u7ec4\u6e29\u5347", "dT [Winding (Maximum) - Ambient]", "dT [Winding (Average) - Ambient]", ], }, { "key": "thermal_resistance_k_w", "label": "Thermal Resistance [K/W]", "unit": "K/W", "direction": "lower", "required": False, "domain": "thermal", "aliases": [ "Thermal Resistance", "Thermal Resistance (total)", "\u70ed\u963b", "\u603b\u70ed\u963b", "Rt [Winding (Maximum) - Ambient]", "Rt [Winding (Average) - Ambient]", ], }, # --- Structural / mechanical --- { "key": "axial_force_n", "label": "Axial Force [N]", "unit": "N", "direction": "lower", "required": False, "domain": "structural", "aliases": [ "Axial Force", "Axial Magnetic Force", "Axial Pull Force", "\u8f74\u5411\u529b", "\u8f74\u5411\u78c1\u5438\u529b", ], }, { "key": "radial_force_n", "label": "Radial Force [N]", "unit": "N", "direction": "lower", "required": False, "domain": "structural", "aliases": [ "Radial Force", "Radial Magnetic Force", "\u5f84\u5411\u529b", "\u5f84\u5411\u78c1\u5438\u529b", ], }, { "key": "max_stress_mpa", "label": "Max Stress [MPa]", "unit": "MPa", "direction": "lower", "required": False, "domain": "structural", "aliases": [ "Maximum Stress", "Max Stress", "Von Mises Stress (max)", "\u6700\u5927\u5e94\u529b", "\u5bc6\u5e94\u529b\u6700\u5927\u503c", ], }, { "key": "deformation_mm", "label": "Max Deformation [mm]", "unit": "mm", "direction": "lower", "required": False, "domain": "structural", "aliases": [ "Maximum Deformation", "Max Deformation", "Total Deformation (max)", "\u6700\u5927\u53d8\u5f62", "\u603b\u53d8\u5f62\u6700\u5927\u503c", ], }, ] # --------------------------------------------------------------------------- # Derived lookup structures # --------------------------------------------------------------------------- METRIC_KEYS: frozenset = frozenset(m["key"] for m in METRIC_DEFINITIONS) METRIC_LABELS: Dict[str, str] = {m["key"]: m["label"] for m in METRIC_DEFINITIONS} METRIC_UNITS: Dict[str, str] = {m["key"]: m["unit"] for m in METRIC_DEFINITIONS} METRIC_DIRECTIONS: Dict[str, str] = {m["key"]: m["direction"] for m in METRIC_DEFINITIONS} REQUIRED_METRICS: frozenset = frozenset(m["key"] for m in METRIC_DEFINITIONS if m["required"]) # --------------------------------------------------------------------------- # Name normalization (the historical tavg/ripple parsing fix) # --------------------------------------------------------------------------- # Full-width -> half-width char map _FULL_TO_HALF = { "\uff08": "(", # full-width ( "\uff09": ")", # full-width ) "\uff0c": ",", # full-width , "\uff1b": ";", # full-width ; "\uff1a": ":", # full-width : "\uff3b": "[", # full-width [ "\uff3d": "]", # full-width ] "\u3000": " ", # full-width space (ideographic space) } def normalize_name(name: str) -> str: """Normalize a Motor-CAD field name for matching. - Full-width brackets/punct -> half-width - Strip ALL whitespace (incl. full-width space U+3000) - Lowercase Example: "\\u5e73\\u5747\\u8f6c\\u77e9 (virtual work)" and "Average torque (virtual work)" both normalize to the same token space. """ s = name or "" for full, half in _FULL_TO_HALF.items(): s = s.replace(full, half) s = "".join(s.split()) return s.lower() # Pre-normalized alias map: normalized_alias -> metric_key _METRIC_ALIAS_MAP: Dict[str, str] = {} for _m in METRIC_DEFINITIONS: for _alias in _m["aliases"]: _METRIC_ALIAS_MAP[normalize_name(_alias)] = _m["key"] # Section name priority for exact matching (English + Chinese). SECTION_PRIORITY = [ "E-Magnetics", "\u7535\u78c1", "Drive", "\u9a71\u52a8", "Losses", "\u635f\u8017", "Materials", "\u6750\u6599", "Miscellaneous", "\u6742\u9879", ] # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- def parse_export(path: str | Path) -> Dict[str, Dict[str, float]]: """Parse a Motor-CAD semicolon-delimited export file into {section_name: {field_name: value}}. - Section header = a line WITHOUT ';'. - Data line = "field;value[;...]" -> field is parts[0], value is the first numeric token in parts[1:] (tolerates thousands separators). - Multi-encoding fallback: utf-8-sig, utf-8, gbk, cp1252, latin-1. """ text: Optional[str] = None for encoding in ("utf-8-sig", "utf-8", "gbk", "cp1252", "latin-1"): try: text = Path(path).read_text(encoding=encoding) break except (UnicodeDecodeError, OSError): continue if text is None: return {} result: Dict[str, Dict[str, float]] = {} section = "(root)" result.setdefault(section, {}) for raw in text.splitlines(): line = raw.strip() if not line: continue if ";" not in line: section = line.strip() result.setdefault(section, {}) continue parts = line.split(";") field = parts[0].strip().strip('"') if not field: continue value: Optional[float] = None for part in parts[1:]: part = part.strip() if not part: continue try: value = float(part.replace(",", ".")) break except ValueError: continue if value is None: continue result.setdefault(section, {})[field] = value return result def _exact_match_in_sections( parsed: Dict[str, Dict[str, float]], wanted: set, section_order: Optional[List[str]] = None, ) -> Optional[float]: """Exact normalized match. If section_order given, scan sections in that order first; then fall back to all sections.""" if section_order: for section_name in section_order: section = parsed.get(section_name) if not section: continue for field, value in section.items(): if normalize_name(field) in wanted: return value for section in parsed.values(): for field, value in section.items(): if normalize_name(field) in wanted: return value return None def pick_metric(parsed: Dict[str, Dict[str, float]], metric_key: str) -> Optional[float]: """Extract a single metric from parsed export results. Strategy (reference: KNOWLEDGE_BASE section 4.3): 1. Exact normalized match, E-Magnetics section priority. 2. Exact normalized match across all sections. 3. Prefix fuzzy match across all sections (len > 3 to avoid noise). """ wanted = { norm for alias, key in _METRIC_ALIAS_MAP.items() if key == metric_key for norm in [alias] } if not wanted: return None value = _exact_match_in_sections(parsed, wanted, SECTION_PRIORITY) if value is not None: return value # Phase 3: prefix fuzzy match - ONLY when the exported field name is # LONGER than / starts with a known alias. The reverse direction # (alias longer than field) is intentionally dropped because it caused # false positives, e.g. alias "Torque Ripple (VW) (abs)" matched the # plain "Torque Ripple (VW)" field and polluted ripple_abs_nm. for alias_norm in wanted: if len(alias_norm) <= 3: continue for section in parsed.values(): for field, value in section.items(): field_norm = normalize_name(field) if len(field_norm) <= 3: continue if not field_norm.startswith(alias_norm): continue # Guard: a "%" field (e.g. "Torque Ripple (VW) [%]") must # never satisfy a non-percent alias (e.g. "Torque Ripple (VW)") # or it would pollute the Nm-valued metric with a % value. if "%" in field_norm and "%" not in alias_norm: continue return value return None def extract_all_metrics(parsed: Dict[str, Dict[str, float]]) -> Dict[str, float]: """Extract every defined metric from parsed results.""" out: Dict[str, float] = {} for m in METRIC_DEFINITIONS: val = pick_metric(parsed, m["key"]) if val is not None: out[m["key"]] = val return out def check_required_metrics(metrics: Dict[str, float]) -> Tuple[bool, List[str]]: """Check that all required metrics are present.""" missing = [k for k in REQUIRED_METRICS if k not in metrics] return (len(missing) == 0, missing) def extract_metrics_from_file(path: str | Path) -> Dict[str, float]: """One-shot: parse an export file and extract all metrics.""" return extract_all_metrics(parse_export(path))