|
@@ -0,0 +1,249 @@
|
|
|
|
|
+"""Excel (.xlsx) scan-result report writer.
|
|
|
|
|
+
|
|
|
|
|
+Two-row header format (category group row + column header row), freeze panes
|
|
|
|
|
+and autofilter. Adapted from torqrippswap src/solver.py write_summary_xlsx so
|
|
|
|
|
+exported files match the format engineers already use.
|
|
|
|
|
+
|
|
|
|
|
+Consumers: web/backend (plan results export endpoint).
|
|
|
|
|
+
|
|
|
|
|
+All source is ASCII only (project rule: source files ASCII, user-facing
|
|
|
|
|
+Chinese text written as unicode escapes).
|
|
|
|
|
+"""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import Any, Dict, List, Sequence, Tuple
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Category names (row-1 groups)
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+CAT_SCAN_INFO = "\u626b\u63cf\u4fe1\u606f"
|
|
|
|
|
+CAT_EM = "\u7535\u78c1\u6027\u80fd"
|
|
|
|
|
+CAT_THERMAL = "\u70ed\u6027\u80fd"
|
|
|
|
|
+CAT_STRUCTURAL = "\u7ed3\u6784\u6027\u80fd"
|
|
|
|
|
+CAT_OTHER = "\u5176\u4ed6\u6307\u6807"
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Column registry: metric key -> (category, Chinese header with unit)
|
|
|
|
|
+#
|
|
|
|
|
+# Engineer-focused key metrics first (electromagnetic + thermal), in the
|
|
|
|
|
+# order engineers scan a result table: torque/ripple/efficiency, losses
|
|
|
|
|
+# breakdown, electrical, thermal.
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+FIXED_COLUMNS: List[Tuple[str, str, str]] = [
|
|
|
|
|
+ ("run_index", CAT_SCAN_INFO, "\u5e8f\u53f7"),
|
|
|
|
|
+ ("status", CAT_SCAN_INFO, "\u72b6\u6001"),
|
|
|
|
|
+ ("solve_time_s", CAT_SCAN_INFO, "\u8017\u65f6[s]"),
|
|
|
|
|
+ ("error", CAT_SCAN_INFO, "\u9519\u8bef\u4fe1\u606f"),
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+EM_COLUMNS: List[Tuple[str, str]] = [
|
|
|
|
|
+ ("tavg_nm", "\u5e73\u5747\u8f6c\u77e9[Nm]"),
|
|
|
|
|
+ ("ripple_pct", "\u8f6c\u77e9\u8109\u52a8[%]"),
|
|
|
|
|
+ ("ripple_nm", "\u8f6c\u77e9\u8109\u52a8[Nm]"),
|
|
|
|
|
+ ("tmax_nm", "\u6700\u5927\u8f6c\u77e9[Nm]"),
|
|
|
|
|
+ ("tmin_nm", "\u6700\u5c0f\u8f6c\u77e9[Nm]"),
|
|
|
|
|
+ ("shaft_torque_nm", "\u8f74\u8f6c\u77e9[Nm]"),
|
|
|
|
|
+ ("stall_torque_nm", "\u5835\u8f6c\u8f6c\u77e9[Nm]"),
|
|
|
|
|
+ ("torque_constant", "\u8f6c\u77e9\u5e38\u6570[Nm/A]"),
|
|
|
|
|
+ ("efficiency_pct", "\u6548\u7387[%]"),
|
|
|
|
|
+ ("output_power_w", "\u8f93\u51fa\u529f\u7387[W]"),
|
|
|
|
|
+ ("em_power_w", "\u7535\u78c1\u529f\u7387[W]"),
|
|
|
|
|
+ ("input_power_w", "\u8f93\u5165\u529f\u7387[W]"),
|
|
|
|
|
+ ("power_factor", "\u529f\u7387\u56e0\u6570"),
|
|
|
|
|
+ ("total_losses_w", "\u603b\u635f\u8017[W]"),
|
|
|
|
|
+ ("copper_loss_w", "\u94dc\u8017[W]"),
|
|
|
|
|
+ ("iron_loss_w", "\u94c1\u8017[W]"),
|
|
|
|
|
+ ("magnet_loss_w", "\u78c1\u94a2\u635f\u8017[W]"),
|
|
|
|
|
+ ("back_emf_v", "\u53cd\u7535\u52a8\u52bf\u7ebf\u7535\u538b[V]"),
|
|
|
|
|
+ ("back_emf_thd_pct", "\u53cd\u7535\u52a8\u52bf\u8c10\u6ce2[%]"),
|
|
|
|
|
+ ("line_current_rms_a", "\u7ebf\u7535\u6d41\u6709\u6548\u503c[A]"),
|
|
|
|
|
+ ("phase_current_peak_a", "\u76f8\u7535\u6d41\u5cf0\u503c[A]"),
|
|
|
|
|
+ ("shaft_speed_rpm", "\u8f6c\u901f[rpm]"),
|
|
|
|
|
+ ("no_load_speed_rpm", "\u7a7a\u8f7d\u8f6c\u901f[rpm]"),
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+THERMAL_COLUMNS: List[Tuple[str, str]] = [
|
|
|
|
|
+ ("winding_temp_c", "\u7ed5\u7ec4\u6e29\u5ea6[C]"),
|
|
|
|
|
+ ("winding_hotspot_temp_c", "\u7ed5\u7ec4\u70ed\u70b9\u6e29\u5ea6[C]"),
|
|
|
|
|
+ ("magnet_temp_c", "\u78c1\u94a2\u6e29\u5ea6[C]"),
|
|
|
|
|
+ ("stator_temp_c", "\u5b9a\u5b50\u6e29\u5ea6[C]"),
|
|
|
|
|
+ ("bearing_temp_c", "\u8f74\u627f\u6e29\u5ea6[C]"),
|
|
|
|
|
+ ("temp_rise_c", "\u6e29\u5347[C]"),
|
|
|
|
|
+ ("thermal_resistance_k_w", "\u70ed\u963b[K/W]"),
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+STRUCTURAL_COLUMNS: List[Tuple[str, str]] = [
|
|
|
|
|
+ ("axial_force_n", "\u8f74\u5411\u529b[N]"),
|
|
|
|
|
+ ("radial_force_n", "\u5f84\u5411\u529b[N]"),
|
|
|
|
|
+ ("max_stress_mpa", "\u6700\u5927\u5e94\u529b[MPa]"),
|
|
|
|
|
+ ("deformation_mm", "\u6700\u5927\u53d8\u5f62[mm]"),
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+_KNOWN_KEYS = frozenset(
|
|
|
|
|
+ k for k, _ in EM_COLUMNS + THERMAL_COLUMNS + STRUCTURAL_COLUMNS
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Column assembly
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def build_export_columns(
|
|
|
|
|
+ param_names: Sequence[str],
|
|
|
|
|
+ present_metric_keys: Sequence[str],
|
|
|
|
|
+ param_labels: Dict[str, str] | None = None,
|
|
|
|
|
+) -> Tuple[List[str], List[str], List[str]]:
|
|
|
|
|
+ """Assemble (col_keys, col_categories, col_headers) for the export.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ param_names: scan/fixed parameter names present in the result rows.
|
|
|
|
|
+ present_metric_keys: metric keys actually present in the data (union
|
|
|
|
|
+ across rows). Only columns with at least one value are exported.
|
|
|
|
|
+ param_labels: optional {param_name: display header} map (Chinese
|
|
|
|
|
+ names with units); falls back to the raw parameter name.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ (col_keys, col_categories, col_headers) for write_summary_xlsx().
|
|
|
|
|
+ """
|
|
|
|
|
+ present = set(present_metric_keys)
|
|
|
|
|
+ labels = param_labels or {}
|
|
|
|
|
+
|
|
|
|
|
+ col_keys: List[str] = []
|
|
|
|
|
+ col_categories: List[str] = []
|
|
|
|
|
+ col_headers: List[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ for key, cat, header in FIXED_COLUMNS:
|
|
|
|
|
+ col_keys.append(key)
|
|
|
|
|
+ col_categories.append(cat)
|
|
|
|
|
+ col_headers.append(header)
|
|
|
|
|
+
|
|
|
|
|
+ for name in param_names:
|
|
|
|
|
+ if name in col_keys:
|
|
|
|
|
+ continue
|
|
|
|
|
+ col_keys.append(name)
|
|
|
|
|
+ col_categories.append(CAT_SCAN_INFO)
|
|
|
|
|
+ col_headers.append(labels.get(name, name))
|
|
|
|
|
+
|
|
|
|
|
+ for key, header in EM_COLUMNS:
|
|
|
|
|
+ if key in present:
|
|
|
|
|
+ col_keys.append(key)
|
|
|
|
|
+ col_categories.append(CAT_EM)
|
|
|
|
|
+ col_headers.append(header)
|
|
|
|
|
+
|
|
|
|
|
+ for key, header in THERMAL_COLUMNS:
|
|
|
|
|
+ if key in present:
|
|
|
|
|
+ col_keys.append(key)
|
|
|
|
|
+ col_categories.append(CAT_THERMAL)
|
|
|
|
|
+ col_headers.append(header)
|
|
|
|
|
+
|
|
|
|
|
+ for key, header in STRUCTURAL_COLUMNS:
|
|
|
|
|
+ if key in present:
|
|
|
|
|
+ col_keys.append(key)
|
|
|
|
|
+ col_categories.append(CAT_STRUCTURAL)
|
|
|
|
|
+ col_headers.append(header)
|
|
|
|
|
+
|
|
|
|
|
+ # Any remaining metric the data carries but the registry does not know:
|
|
|
|
|
+ # keep it under CAT_OTHER so the export is complete, not lossy.
|
|
|
|
|
+ for key in present:
|
|
|
|
|
+ if key in _KNOWN_KEYS or key in col_keys:
|
|
|
|
|
+ continue
|
|
|
|
|
+ col_keys.append(key)
|
|
|
|
|
+ col_categories.append(CAT_OTHER)
|
|
|
|
|
+ col_headers.append(key)
|
|
|
|
|
+
|
|
|
|
|
+ return col_keys, col_categories, col_headers
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Writer (adapted from torqrippswap src/solver.py write_summary_xlsx)
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def write_summary_xlsx(
|
|
|
|
|
+ xlsx_path: str | Path,
|
|
|
|
|
+ col_keys: List[str],
|
|
|
|
|
+ col_categories: List[str],
|
|
|
|
|
+ col_headers: List[str],
|
|
|
|
|
+ data_rows: List[Dict[str, Any]],
|
|
|
|
|
+ sheet_title: str = "scan_results",
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Write scan results as .xlsx with a two-row header.
|
|
|
|
|
+
|
|
|
|
|
+ Row 1 = merged category groups, row 2 = column headers, data from row 3.
|
|
|
|
|
+ Panes frozen at row 3; autofilter on the header row.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ xlsx_path: output file path.
|
|
|
|
|
+ col_keys: dict keys used to look up values in each data row.
|
|
|
|
|
+ col_categories: category (row-1 group) name per column.
|
|
|
|
|
+ col_headers: header text (row 2) per column.
|
|
|
|
|
+ data_rows: list of dicts keyed by col_keys.
|
|
|
|
|
+ sheet_title: worksheet name.
|
|
|
|
|
+ """
|
|
|
|
|
+ from openpyxl import Workbook
|
|
|
|
|
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
|
|
|
+ from openpyxl.utils import get_column_letter
|
|
|
|
|
+
|
|
|
|
|
+ wb = Workbook()
|
|
|
|
|
+ ws = wb.active
|
|
|
|
|
+ ws.title = sheet_title
|
|
|
|
|
+
|
|
|
|
|
+ n_cols = len(col_keys)
|
|
|
|
|
+ header_font = Font(bold=True, size=10)
|
|
|
|
|
+ header_fill = PatternFill(
|
|
|
|
|
+ start_color="F1F5F9", end_color="F1F5F9", fill_type="solid"
|
|
|
|
|
+ )
|
|
|
|
|
+ cat_fill = PatternFill(
|
|
|
|
|
+ start_color="E2E8F0", end_color="E2E8F0", fill_type="solid"
|
|
|
|
|
+ )
|
|
|
|
|
+ thin = Side(style="thin", color="94A3B8")
|
|
|
|
|
+ thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
|
|
|
+ center = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
|
|
|
+
|
|
|
|
|
+ # Row 2: individual column headers.
|
|
|
|
|
+ for ci in range(n_cols):
|
|
|
|
|
+ cell = ws.cell(row=2, column=ci + 1, value=col_headers[ci])
|
|
|
|
|
+ cell.font = header_font
|
|
|
|
|
+ cell.fill = header_fill
|
|
|
|
|
+ cell.alignment = center
|
|
|
|
|
+ cell.border = thin_border
|
|
|
|
|
+
|
|
|
|
|
+ # Row 1: category groups (merge consecutive same-category columns).
|
|
|
|
|
+ ci = 0
|
|
|
|
|
+ while ci < n_cols:
|
|
|
|
|
+ cat = col_categories[ci]
|
|
|
|
|
+ start = ci
|
|
|
|
|
+ while ci < n_cols and col_categories[ci] == cat:
|
|
|
|
|
+ ci += 1
|
|
|
|
|
+ end = ci - 1
|
|
|
|
|
+ if start == end:
|
|
|
|
|
+ cell = ws.cell(row=1, column=start + 1, value=cat)
|
|
|
|
|
+ else:
|
|
|
|
|
+ ws.merge_cells(
|
|
|
|
|
+ start_row=1, start_column=start + 1,
|
|
|
|
|
+ end_row=1, end_column=end + 1,
|
|
|
|
|
+ )
|
|
|
|
|
+ cell = ws.cell(row=1, column=start + 1, value=cat)
|
|
|
|
|
+ cell.font = Font(bold=True, size=10, color="1E293B")
|
|
|
|
|
+ cell.fill = cat_fill
|
|
|
|
|
+ cell.alignment = center
|
|
|
|
|
+ cell.border = thin_border
|
|
|
|
|
+
|
|
|
|
|
+ # Data rows (row 3+).
|
|
|
|
|
+ for ri, row_data in enumerate(data_rows):
|
|
|
|
|
+ for ci in range(n_cols):
|
|
|
|
|
+ val = row_data.get(col_keys[ci], "")
|
|
|
|
|
+ cell = ws.cell(row=ri + 3, column=ci + 1, value=val)
|
|
|
|
|
+ cell.border = thin_border
|
|
|
|
|
+ cell.alignment = Alignment(vertical="center")
|
|
|
|
|
+
|
|
|
|
|
+ # Freeze panes below the two header rows and after the index column.
|
|
|
|
|
+ ws.freeze_panes = "B3"
|
|
|
|
|
+
|
|
|
|
|
+ # Auto-filter on the header row (any column count).
|
|
|
|
|
+ last_col = get_column_letter(n_cols)
|
|
|
|
|
+ ws.auto_filter.ref = f"A2:{last_col}{len(data_rows) + 2}"
|
|
|
|
|
+
|
|
|
|
|
+ wb.save(str(xlsx_path))
|