Balanced Electric 6x6 - A Harmonic Improvement to Electrical Wiring

Key Verbiage: Fault Tolerance

#!/usr/bin/env python3
"""
===============================================================================
BALANCED-ELECTRIC-5 — FULL SIX-PHASE MACHINE / LOAD / FAULT TEST
===============================================================================

Purpose
-------
A self-contained numerical A/B test between:

    1) conventional balanced 3-phase
    2) a six-phase machine with six independent phase branches

The test is deliberately conservative:

    * same machine envelope
    * same active copper volume
    * same total copper resistance budget
    * same DC/source voltage
    * same conductor current limit
    * same thermal limit
    * same target mechanical output
    * explicit electrical -> mechanical power accounting
    * no voltage/current multiplication by simply adding waveforms
    * no hidden free energy
    * deterministic fault injection

The six-phase machine is NOT forced through the earlier A+F/B+E/C+D
pair-sum topology. That topology is retained only as an optional phasor
diagnostic. The main machine comparison preserves all six phases.

MODEL
-----
This is an engineering screening model, not an FEA package and not a
replacement for a manufactured-machine validation.

Each phase is represented by a sinusoidal winding coupled to an idealized
rotating rotor. The electromagnetic torque is obtained from instantaneous
phase current and the derivative of phase flux linkage with rotor angle:

    e_k = - d(lambda_k)/dt
    T_k = i_k * d(lambda_k)/d(theta)

The electrical model is:

    V_k = R_k i_k + L_k di_k/dt + e_k

The mechanical model is:

    J domega/dt = T_em - T_load - B omega

Thermal model:

    Cth dT/dt = P_Cu - (T - Tambient)/Rth

The controller is an ideal current-regulated sinusoidal drive with a
voltage/current limiter. The same controller law and limits are used for
both machines.

For the six-phase machine, the six phase axes are:

    0, 60, 120, 180, 240, 300 degrees

For the conventional machine:

    0, 120, 240 degrees

A phase-fault is represented by opening a branch and commanding zero current.
The controller attempts to continue producing the requested torque with the
remaining healthy phases, subject to the same current/voltage/thermal limits.

The script reports both:
    A) nominal equal-output operation
    B) fault survival / derating

It also evaluates the earlier proposed pairing:

    A+F, B+E, C+D

as a phasor-only diagnostic. It is NOT used as the six-phase machine's main
power path because simple voltage/current addition is not a physically valid
power-conserving machine connection by itself.

Dependencies
------------
Python 3.9+
numpy
matplotlib

Usage
-----
    py balanced-electric5.py

Optional:
    py balanced-electric5.py --seconds 0.60 --dt 2e-5
    py balanced-electric5.py --seconds 1.00 --dt 1e-5 --fault-duration 0.20
    py balanced-electric5.py --no-plots
    py balanced-electric5.py --save-dir results

Outputs
-------
    summary.txt
    nominal.csv
    faults.csv
    harmonics.csv
    energy_balance.csv
    topology_pairing_diagnostic.csv
    nominal_waveforms.png
    torque_comparison.png
    thermal_comparison.png
    fault_survival.png
    harmonic_spectrum.png

IMPORTANT
---------
Results are model results. Real motor performance requires electromagnetic
FEA, winding-factor/slotting data, inverter losses, iron-loss models,
mechanical measurements, insulation/protection design, and hardware tests.
===============================================================================
"""

from __future__ import annotations

import argparse
import csv
import math
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Tuple

import numpy as np

try:
    import matplotlib.pyplot as plt
except Exception:
    plt = None


# =============================================================================
# CONFIGURATION
# =============================================================================

@dataclass
class Config:
    # Simulation
    seconds: float = 0.60
    dt: float = 2.0e-5
    sample_plot_every: int = 10

    # Electrical source
    f_e: float = 60.0
    v_phase_rms: float = 120.0
    current_limit: float = 18.0
    voltage_limit: float = 240.0

    # Machine target
    pole_pairs: int = 2
    target_speed_rpm: float = 1800.0
    target_power_w: float = 5000.0

    # Per-conductor / copper fairness
    total_copper_volume: float = 1.0
    copper_resistance_total_3ph: float = 0.90
    copper_resistance_total_6ph: float = 0.90

    # Winding parameters.
    # These are screening values, scaled so the two systems have the same
    # total copper budget.
    inductance_phase_3ph: float = 0.012
    inductance_phase_6ph: float = 0.006
    flux_linkage_peak: float = 0.62

    # Mechanical
    inertia: float = 0.035
    viscous_b: float = 0.003

    # Thermal
    ambient_c: float = 25.0
    thermal_limit_c: float = 120.0
    thermal_Rth_C_per_W: float = 0.045
    thermal_Cth_J_per_C: float = 900.0

    # Controller
    current_loop_hz: float = 900.0
    max_current_slew_a_per_s: float = 18000.0
    speed_loop_hz: float = 25.0
    speed_kp_torque: float = 0.045
    speed_ki_torque: float = 0.65

    # Fault testing
    fault_duration_s: float = 0.20
    fault_settle_s: float = 0.08

    # Analysis
    fft_harmonics_max: int = 30
    rms_tail_fraction: float = 0.35


# =============================================================================
# BASIC UTILITIES
# =============================================================================

def rms(x: np.ndarray) -> float:
    return float(np.sqrt(np.mean(np.square(x))))


def pct(x: float) -> float:
    return 100.0 * x


def safe_div(a: float, b: float, default: float = 0.0) -> float:
    if abs(b) < 1e-15:
        return default
    return a / b


def fmt(x: float, digits: int = 6) -> str:
    if not math.isfinite(x):
        return "nan"
    return f"{x:.{digits}f}"


def phase_wrap_rad(x: float) -> float:
    return (x + math.pi) % (2.0 * math.pi) - math.pi


def phasor_from_samples(x: np.ndarray, theta: np.ndarray) -> complex:
    """Fundamental coefficient against electrical angle theta."""
    c = (2.0 / len(x)) * np.sum(x * np.exp(-1j * theta))
    return complex(c)


def positive_sequence(ph: np.ndarray) -> complex:
    """abc positive sequence, x_a + a*x_b + a^2*x_c convention."""
    a = np.exp(1j * 2.0 * math.pi / 3.0)
    return (ph[0] + a * ph[1] + a * a * ph[2]) / 3.0


def negative_sequence(ph: np.ndarray) -> complex:
    a = np.exp(1j * 2.0 * math.pi / 3.0)
    return (ph[0] + a * a * ph[1] + a * ph[2]) / 3.0


def zero_sequence(ph: np.ndarray) -> complex:
    return complex(np.mean(ph))


# =============================================================================
# MACHINE DEFINITION
# =============================================================================

@dataclass
class Machine:
    name: str
    axes: np.ndarray
    resistance: np.ndarray
    inductance: np.ndarray
    flux_peak: float
    current_limit: float
    voltage_limit: float
    pole_pairs: int

    @property
    def nph(self) -> int:
        return len(self.axes)

    @property
    def copper_resistance_sum(self) -> float:
        return float(np.sum(self.resistance))

    @property
    def copper_budget_relative(self) -> float:
        return self.copper_resistance_sum


def make_machine(cfg: Config, nph: int) -> Machine:
    axes = 2.0 * math.pi * np.arange(nph) / nph

    # Equal total copper resistance budget.
    # More phase branches => lower resistance per branch.
    if nph == 3:
        r = cfg.copper_resistance_total_3ph / nph
        L = cfg.inductance_phase_3ph
    else:
        r = cfg.copper_resistance_total_6ph / nph
        L = cfg.inductance_phase_6ph

    return Machine(
        name=f"{nph}-phase",
        axes=axes,
        resistance=np.full(nph, r, dtype=float),
        inductance=np.full(nph, L, dtype=float),
        flux_peak=cfg.flux_linkage_peak,
        current_limit=cfg.current_limit,
        voltage_limit=cfg.voltage_limit,
        pole_pairs=cfg.pole_pairs,
    )


# =============================================================================
# ELECTROMAGNETIC MODEL
# =============================================================================

def flux_linkage(machine: Machine, theta_m: float) -> np.ndarray:
    """
    Sinusoidal phase flux linkage.

    theta_m is mechanical rotor angle.
    Electrical angle = pole_pairs * theta_m.
    """
    theta_e = machine.pole_pairs * theta_m
    return machine.flux_peak * np.cos(theta_e - machine.axes)


def dflux_dtheta(machine: Machine, theta_m: float) -> np.ndarray:
    """
    d(lambda_k)/d(theta_m).
    """
    theta_e = machine.pole_pairs * theta_m
    return (
        machine.flux_peak
        * machine.pole_pairs
        * (-np.sin(theta_e - machine.axes))
    )


def back_emf(machine: Machine, theta_m: float, omega_m: float) -> np.ndarray:
    # Terminal-voltage convention: v = R i + L di/dt + d(lambda)/dt.
    # Therefore the source-side flux term is +d(lambda)/dt.  The negative
    # sign belongs to the separately defined induced/back-EMF quantity.
    return dflux_dtheta(machine, theta_m) * omega_m


def electromagnetic_torque(machine: Machine, theta_m: float, current: np.ndarray) -> float:
    return float(np.dot(current, dflux_dtheta(machine, theta_m)))


# =============================================================================
# CONTROL
# =============================================================================

@dataclass
class ControllerState:
    speed_integral: float = 0.0


def desired_current_vector(
    machine: Machine,
    theta_m: float,
    omega_m: float,
    torque_cmd: float,
) -> np.ndarray:
    """
    Minimum-norm current vector producing the requested instantaneous torque
    under the sinusoidal flux model.

    Since:
        T = i dot d(lambda)/d(theta)

    the minimum-norm solution is proportional to d(lambda)/d(theta).
    """
    g = dflux_dtheta(machine, theta_m)
    gg = float(np.dot(g, g))

    if gg < 1e-20 or torque_cmd == 0.0:
        return np.zeros(machine.nph)

    i = (torque_cmd / gg) * g

    # Enforce phase current limit.
    peak = float(np.max(np.abs(i)))
    if peak > machine.current_limit:
        i *= machine.current_limit / peak

    return i


def speed_controller(
    cfg: Config,
    controller: ControllerState,
    omega_m: float,
    target_omega: float,
    base_torque: float,
    dt: float,
    torque_cap: float,
) -> float:
    """Speed regulator around the torque required by the commanded load.

    The previous draft used only a speed-error torque command.  That meant a
    machine starting very near the target speed could initially receive almost
    no torque while a 5 kW load was already applied.  This version explicitly
    supplies the steady-state load torque and uses the speed loop only as the
    correction term.
    """
    error = target_omega - omega_m

    controller.speed_integral += error * dt
    controller.speed_integral = float(
        np.clip(controller.speed_integral, -torque_cap, torque_cap)
    )

    correction = (
        cfg.speed_kp_torque * error
        + cfg.speed_ki_torque * controller.speed_integral
    )

    torque = base_torque + correction
    return float(np.clip(torque, 0.0, torque_cap))


def load_torque_for_target_power(
    cfg: Config,
    omega_m: float,
) -> float:
    if abs(omega_m) < 1e-9:
        return 0.0
    return cfg.target_power_w / abs(omega_m)


# =============================================================================
# PHYSICAL VOLTAGE LIMITING
# =============================================================================

def voltage_limited_current_command(
    machine: Machine,
    theta_m: float,
    omega_m: float,
    current_actual: np.ndarray,
    current_target: np.ndarray,
    dt: float,
) -> Tuple[np.ndarray, np.ndarray]:
    """Current controller with an explicit, power-consistent voltage limit.

    The branch equation is evaluated as:

        v = R*i_new + L*di/dt + d(lambda)/dt

    with i_new = i + di*dt.  The allowable di is solved directly from the
    voltage inequality rather than applying a final voltage scaling that
    would otherwise break the electrical energy balance.
    """
    e = back_emf(machine, theta_m, omega_m)

    tau = 1.0 / (2.0 * math.pi * 900.0)
    raw_di = (current_target - current_actual) / max(tau, dt)

    # v = R*(i + di*dt) + L*di + e
    #   = (R*i + e) + (L + R*dt)*di
    base_v = machine.resistance * current_actual + e
    coeff = machine.inductance + machine.resistance * dt

    # Compute allowable derivative interval for each phase.
    lo = (-machine.voltage_limit - base_v) / np.maximum(coeff, 1e-15)
    hi = ( machine.voltage_limit - base_v) / np.maximum(coeff, 1e-15)
    di = np.clip(raw_di, lo, hi)

    i_new = current_actual + di * dt
    i_new = np.clip(i_new, -machine.current_limit, machine.current_limit)

    # Recompute di after the current hard limit so the reported voltage is
    # exactly the voltage implied by the actual current transition.
    di_actual = (i_new - current_actual) / max(dt, 1e-15)
    v = machine.resistance * i_new + machine.inductance * di_actual + e

    return i_new, v


# =============================================================================
# SIMULATION RESULT
# =============================================================================

@dataclass
class SimResult:
    name: str
    t: np.ndarray
    theta: np.ndarray
    omega: np.ndarray
    speed_rpm: np.ndarray
    current: np.ndarray
    voltage: np.ndarray
    torque: np.ndarray
    torque_load: np.ndarray
    copper_loss: np.ndarray
    electrical_power: np.ndarray
    mechanical_power: np.ndarray
    thermal_c: np.ndarray
    healthy: np.ndarray
    fault_name: str


# =============================================================================
# MAIN SIMULATION
# =============================================================================

def simulate(
    cfg: Config,
    machine: Machine,
    fault_phase: int | None = None,
    fault_name: str = "none",
    duration: float | None = None,
) -> SimResult:

    if duration is None:
        duration = cfg.seconds

    n = int(math.ceil(duration / cfg.dt)) + 1
    t = np.arange(n, dtype=float) * cfg.dt

    theta = np.zeros(n)
    omega = np.zeros(n)
    speed_rpm = np.zeros(n)

    current = np.zeros((n, machine.nph))
    voltage = np.zeros((n, machine.nph))
    torque = np.zeros(n)
    torque_load = np.zeros(n)
    copper_loss = np.zeros(n)
    electrical_power = np.zeros(n)
    mechanical_power = np.zeros(n)
    thermal_c = np.zeros(n)
    healthy = np.ones((n, machine.nph), dtype=bool)

    target_omega = 2.0 * math.pi * cfg.target_speed_rpm / 60.0

    # Start close to synchronous speed to make this an electrical-machine
    # operating-point comparison rather than a startup contest.
    omega[0] = target_omega * 0.985
    thermal_c[0] = cfg.ambient_c

    if fault_phase is not None:
        healthy[:, fault_phase] = False

    controller = ControllerState()

    # Torque cap deliberately generous but finite.
    torque_cap = max(
        cfg.target_power_w / max(target_omega, 1e-9) * 2.5,
        10.0,
    )

    for k in range(n - 1):
        th = theta[k]
        om = omega[k]
        i_now = current[k]

        # Load selected to represent target mechanical output at target speed.
        # Add the modeled viscous-friction torque so the commanded torque
        # corresponds to target shaft output rather than merely shaft+loss.
        tl = load_torque_for_target_power(cfg, target_omega)
        viscous_torque = cfg.viscous_b * target_omega
        base_torque = tl + viscous_torque
        torque_load[k] = tl

        # Speed loop.  The baseline is the load torque; the loop supplies only
        # the correction needed to hold target speed.
        t_cmd = speed_controller(
            cfg,
            controller,
            om,
            target_omega,
            base_torque,
            cfg.dt,
            torque_cap,
        )

        # Fault: remove phase completely.
        if fault_phase is not None:
            # We do not simply delete the phase from the machine. Its branch
            # remains electrically present but is opened, carrying zero current.
            t_cmd = max(t_cmd, 0.0)

        i_target = desired_current_vector(
            machine,
            th,
            om,
            t_cmd,
        )

        if fault_phase is not None:
            i_target[fault_phase] = 0.0

        i_new, v = voltage_limited_current_command(
            machine,
            th,
            om,
            i_now,
            i_target,
            cfg.dt,
        )

        if fault_phase is not None:
            i_new[fault_phase] = 0.0
            v[fault_phase] = 0.0

        current[k + 1] = i_new
        voltage[k] = v

        # Recompute actual electromagnetic torque from actual current.
        te = electromagnetic_torque(machine, th, i_new)

        # If a branch is open, the controller cannot produce arbitrary torque.
        # This is naturally reflected by the current vector.
        torque[k] = te

        # Mechanical dynamics.
        accel = (
            te
            - tl
            - cfg.viscous_b * om
        ) / cfg.inertia

        omega[k + 1] = max(0.0, om + accel * cfg.dt)
        theta[k + 1] = th + omega[k + 1] * cfg.dt

        speed_rpm[k] = omega[k] * 60.0 / (2.0 * math.pi)

        # Instantaneous electrical input.
        pe = float(np.dot(v, i_new))
        pc = float(np.sum(machine.resistance * np.square(i_new)))
        pm = te * om

        electrical_power[k] = pe
        copper_loss[k] = pc
        mechanical_power[k] = pm

        # Lumped thermal model.
        dT = (
            pc
            - (thermal_c[k] - cfg.ambient_c)
            / cfg.thermal_Rth_C_per_W
        ) / cfg.thermal_Cth_J_per_C

        thermal_c[k + 1] = thermal_c[k] + dT * cfg.dt

    speed_rpm[-1] = omega[-1] * 60.0 / (2.0 * math.pi)
    torque_load[-1] = torque_load[-2]
    torque[-1] = torque[-2]
    voltage[-1] = voltage[-2]
    copper_loss[-1] = copper_loss[-2]
    electrical_power[-1] = electrical_power[-2]
    mechanical_power[-1] = mechanical_power[-2]

    return SimResult(
        name=machine.name,
        t=t,
        theta=theta,
        omega=omega,
        speed_rpm=speed_rpm,
        current=current,
        voltage=voltage,
        torque=torque,
        torque_load=torque_load,
        copper_loss=copper_loss,
        electrical_power=electrical_power,
        mechanical_power=mechanical_power,
        thermal_c=thermal_c,
        healthy=healthy,
        fault_name=fault_name,
    )


# =============================================================================
# STEADY-STATE METRICS
# =============================================================================

@dataclass
class Metrics:
    name: str
    speed_rpm: float
    current_rms_total: float
    current_rms_max: float
    voltage_rms_total: float
    voltage_peak_max: float
    pin_w: float
    pout_w: float
    pcu_w: float
    efficiency: float
    pf: float
    torque_mean_nm: float
    torque_ripple_pct: float
    thermal_c: float
    thermal_margin_c: float
    current_utilization_pct: float
    voltage_utilization_pct: float
    power_ripple_pct: float
    energy_residual_pct: float
    fault_name: str


def tail(x: np.ndarray, frac: float) -> np.ndarray:
    start = int(len(x) * (1.0 - frac))
    return x[max(start, 0):]


def calculate_metrics(
    cfg: Config,
    machine: Machine,
    result: SimResult,
) -> Metrics:

    sl = slice(int(len(result.t) * (1.0 - cfg.rms_tail_fraction)), None)

    i_tail = result.current[sl]
    v_tail = result.voltage[sl]

    i_rms_each = np.sqrt(np.mean(i_tail * i_tail, axis=0))
    v_rms_each = np.sqrt(np.mean(v_tail * v_tail, axis=0))

    current_rms_total = float(np.sqrt(np.sum(i_rms_each ** 2)))
    voltage_rms_total = float(np.sqrt(np.sum(v_rms_each ** 2)))

    pin = float(np.mean(result.electrical_power[sl]))
    pcu = float(np.mean(result.copper_loss[sl]))
    pout = float(np.mean(result.mechanical_power[sl]))

    apparent = voltage_rms_total * current_rms_total
    pf = safe_div(pin, apparent)

    tq = result.torque[sl]
    tq_mean = float(np.mean(tq))
    tq_pp = float(np.max(tq) - np.min(tq))
    tq_ripple = safe_div(tq_pp, abs(tq_mean)) * 100.0

    p = result.electrical_power[sl]
    p_ripple = safe_div(
        float(np.max(p) - np.min(p)),
        max(abs(float(np.mean(p))), 1e-12),
    ) * 100.0

    # Approximate conservation residual:
    # Pin should approximately equal Pout + copper + mechanical viscous loss.
    visc = float(np.mean(cfg.viscous_b * result.omega[sl] ** 2))
    residual = pin - (pout + pcu + visc)
    residual_pct = safe_div(abs(residual), max(abs(pin), 1.0)) * 100.0

    thermal = float(np.max(result.thermal_c[sl]))
    current_util = (
        float(np.max(np.abs(i_tail))) / machine.current_limit * 100.0
    )
    voltage_util = (
        float(np.max(np.abs(v_tail))) / machine.voltage_limit * 100.0
    )

    return Metrics(
        name=machine.name,
        speed_rpm=float(np.mean(result.speed_rpm[sl])),
        current_rms_total=current_rms_total,
        current_rms_max=float(np.max(i_rms_each)),
        voltage_rms_total=voltage_rms_total,
        voltage_peak_max=float(np.max(np.abs(v_tail))),
        pin_w=pin,
        pout_w=pout,
        pcu_w=pcu,
        efficiency=safe_div(pout, pin),
        pf=pf,
        torque_mean_nm=tq_mean,
        torque_ripple_pct=tq_ripple,
        thermal_c=thermal,
        thermal_margin_c=cfg.thermal_limit_c - thermal,
        current_utilization_pct=current_util,
        voltage_utilization_pct=voltage_util,
        power_ripple_pct=p_ripple,
        energy_residual_pct=residual_pct,
        fault_name=result.fault_name,
    )


# =============================================================================
# HARMONIC ANALYSIS
# =============================================================================

def harmonic_amplitudes(
    x: np.ndarray,
    sample_rate: float,
    fundamental_hz: float,
    max_h: int,
) -> Dict[int, float]:
    n = len(x)
    window = np.hanning(n)
    y = (x - np.mean(x)) * window
    spec = np.fft.rfft(y)
    freqs = np.fft.rfftfreq(n, 1.0 / sample_rate)

    out = {}
    for h in range(1, max_h + 1):
        target = h * fundamental_hz
        idx = int(np.argmin(np.abs(freqs - target)))
        amp = 2.0 * abs(spec[idx]) / max(np.sum(window), 1e-12)
        out[h] = float(amp)
    return out


def harmonic_metrics(
    cfg: Config,
    result: SimResult,
) -> List[Tuple[str, int, float]]:
    dt = cfg.dt
    fs = 1.0 / dt

    sl = slice(int(len(result.t) * (1.0 - cfg.rms_tail_fraction)), None)
    i = result.current[sl]

    rows = []
    for phase in range(i.shape[1]):
        amps = harmonic_amplitudes(
            i[:, phase],
            fs,
            cfg.f_e,
            cfg.fft_harmonics_max,
        )
        for h, amp in amps.items():
            rows.append((result.name, phase, h, amp))

    return rows


# =============================================================================
# SIX-PHASE / EARLIER PAIRING DIAGNOSTIC
# =============================================================================

def pairing_diagnostic() -> List[Dict[str, float | str]]:
    """
    Reproduce the earlier geometric pairing without pretending that pairwise
    voltage/current addition is a power-conserving machine connection.

    Six source phases:
        A=0 B=30 C=120 D=150 E=240 F=270

    Proposed:
        A+F, B+E, C+D

    We calculate phasor amplitudes and sequence content only.
    """
    names = ["A", "B", "C", "D", "E", "F"]
    deg = np.array([0.0, 30.0, 120.0, 150.0, 240.0, 270.0])
    z = np.exp(1j * np.deg2rad(deg))

    pairs = [(0, 5), (1, 4), (2, 3)]
    out = []

    ph = np.array([z[a] + z[b] for a, b in pairs], dtype=complex)

    # Normalize pair sum by sqrt(2), matching the earlier diagnostic.
    ph /= math.sqrt(2.0)

    p = positive_sequence(ph)
    n = negative_sequence(ph)
    z0 = zero_sequence(ph)

    for idx, (a, b) in enumerate(pairs):
        out.append({
            "pair": f"{names[a]}+{names[b]}",
            "amplitude": abs(ph[idx]),
            "phase_deg": math.degrees(math.atan2(ph[idx].imag, ph[idx].real)) % 360.0,
            "positive_sequence": abs(p),
            "negative_sequence": abs(n),
            "zero_sequence": abs(z0),
            "negative_positive_pct": safe_div(abs(n), abs(p)) * 100.0,
        })

    return out


# =============================================================================
# FAULT TESTS
# =============================================================================

@dataclass
class FaultSummary:
    architecture: str
    fault: str
    phase_index: int
    nominal_pout_w: float
    fault_pout_w: float
    retained_output_pct: float
    speed_rpm: float
    torque_ripple_pct: float
    max_current_util_pct: float
    max_thermal_c: float
    thermal_margin_c: float
    protection_flag: str


def run_faults(
    cfg: Config,
    machine: Machine,
    nominal_metrics: Metrics,
) -> List[FaultSummary]:

    rows = []

    for phase in range(machine.nph):
        fault_name = f"{machine.name}_phase_{phase+1}_OPEN"

        r = simulate(
            cfg,
            machine,
            fault_phase=phase,
            fault_name=fault_name,
            duration=cfg.fault_duration_s,
        )
        m = calculate_metrics(cfg, machine, r)

        retained = safe_div(m.pout_w, max(nominal_metrics.pout_w, 1e-9)) * 100.0

        # Protection flag is descriptive, not a pass/fail claim about a real
        # protection device.
        if m.thermal_c >= cfg.thermal_limit_c:
            protection = "THERMAL_LIMIT_REACHED"
        elif m.current_utilization_pct >= 99.9:
            protection = "CURRENT_LIMIT_ACTIVE"
        elif m.voltage_utilization_pct >= 99.9:
            protection = "VOLTAGE_LIMIT_ACTIVE"
        else:
            protection = "WITHIN_MODEL_LIMITS"

        rows.append(
            FaultSummary(
                architecture=machine.name,
                fault=fault_name,
                phase_index=phase + 1,
                nominal_pout_w=nominal_metrics.pout_w,
                fault_pout_w=m.pout_w,
                retained_output_pct=retained,
                speed_rpm=m.speed_rpm,
                torque_ripple_pct=m.torque_ripple_pct,
                max_current_util_pct=m.current_utilization_pct,
                max_thermal_c=m.thermal_c,
                thermal_margin_c=m.thermal_margin_c,
                protection_flag=protection,
            )
        )

    return rows


# =============================================================================
# CSV OUTPUT
# =============================================================================

def write_csv(path: Path, rows: List[Dict | Tuple], headers: List[str] | None = None):
    with path.open("w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)

        if headers:
            w.writerow(headers)

        for row in rows:
            if isinstance(row, dict):
                w.writerow([row.get(h, "") for h in headers or row.keys()])
            else:
                w.writerow(list(row))


def write_nominal_csv(
    path: Path,
    results: List[SimResult],
    every: int = 20,
):
    rows = []
    for r in results:
        for k in range(0, len(r.t), every):
            rows.append(
                [
                    r.name,
                    r.t[k],
                    r.speed_rpm[k],
                    r.torque[k],
                    r.electrical_power[k],
                    r.mechanical_power[k],
                    r.copper_loss[k],
                    r.thermal_c[k],
                    *r.current[k].tolist(),
                    *r.voltage[k].tolist(),
                ]
            )

    max_ph = max(r.current.shape[1] for r in results)

    headers = [
        "architecture",
        "time_s",
        "speed_rpm",
        "torque_Nm",
        "electrical_power_W",
        "mechanical_power_W",
        "copper_loss_W",
        "temperature_C",
    ]

    for p in range(max_ph):
        headers.append(f"I_phase_{p+1}_A")
    for p in range(max_ph):
        headers.append(f"V_phase_{p+1}_V")

    # Pad shorter 3-phase rows.
    padded = []
    for row in rows:
        base = row[:8]
        vals = row[8:]
        # row contains n current + n voltage.
        # infer n from architecture.
        nph = 3 if "3-phase" in row[0] else 6
        ii = vals[:nph]
        vv = vals[nph:]
        padded.append(
            base
            + ii + [""] * (max_ph - nph)
            + vv + [""] * (max_ph - nph)
        )

    with path.open("w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(headers)
        w.writerows(padded)


# =============================================================================
# REPORTING
# =============================================================================

def metrics_dict(m: Metrics) -> Dict[str, float | str]:
    return {
        "architecture": m.name,
        "fault": m.fault_name,
        "speed_rpm": m.speed_rpm,
        "total_current_rms_A": m.current_rms_total,
        "max_phase_current_rms_A": m.current_rms_max,
        "total_voltage_rms_V": m.voltage_rms_total,
        "max_voltage_V": m.voltage_peak_max,
        "input_power_W": m.pin_w,
        "output_power_W": m.pout_w,
        "copper_loss_W": m.pcu_w,
        "efficiency": m.efficiency,
        "power_factor_model": m.pf,
        "mean_torque_Nm": m.torque_mean_nm,
        "torque_ripple_pct": m.torque_ripple_pct,
        "thermal_C": m.thermal_c,
        "thermal_margin_C": m.thermal_margin_c,
        "current_utilization_pct": m.current_utilization_pct,
        "voltage_utilization_pct": m.voltage_utilization_pct,
        "power_ripple_pct": m.power_ripple_pct,
        "energy_residual_pct": m.energy_residual_pct,
    }


def print_metrics(m: Metrics):
    print(f"\n[{m.name}] {m.fault_name}")
    print(f"  speed                  {m.speed_rpm:12.3f} rpm")
    print(f"  total RMS current      {m.current_rms_total:12.4f} A")
    print(f"  max phase RMS current  {m.current_rms_max:12.4f} A")
    print(f"  total RMS voltage      {m.voltage_rms_total:12.4f} V")
    print(f"  max voltage            {m.voltage_peak_max:12.4f} V")
    print(f"  input power            {m.pin_w:12.4f} W")
    print(f"  mechanical output      {m.pout_w:12.4f} W")
    print(f"  copper loss            {m.pcu_w:12.4f} W")
    print(f"  efficiency             {100*m.efficiency:12.4f} %")
    print(f"  model PF               {m.pf:12.6f}")
    print(f"  mean torque            {m.torque_mean_nm:12.4f} Nm")
    print(f"  torque ripple          {m.torque_ripple_pct:12.4f} %")
    print(f"  max temperature        {m.thermal_c:12.4f} C")
    print(f"  thermal margin         {m.thermal_margin_c:12.4f} C")
    print(f"  current utilization    {m.current_utilization_pct:12.4f} %")
    print(f"  voltage utilization    {m.voltage_utilization_pct:12.4f} %")
    print(f"  input-power ripple     {m.power_ripple_pct:12.4f} %")
    print(f"  energy residual        {m.energy_residual_pct:12.6f} %")


def write_summary(
    path: Path,
    cfg: Config,
    m3: Metrics,
    m6: Metrics,
    faults: List[FaultSummary],
    pairing: List[Dict],
):
    lines = []

    lines.append("=" * 88)
    lines.append("BALANCED-ELECTRIC-5 — FULL SIX-PHASE MACHINE / LOAD / FAULT TEST")
    lines.append("=" * 88)
    lines.append("")
    lines.append("MODEL STATUS")
    lines.append("This is a physically constrained screening model, not FEA or hardware validation.")
    lines.append("The comparison preserves six independent phases rather than pair-summing them.")
    lines.append("Voltage/current addition from the earlier A+F/B+E/C+D topology is reported separately.")
    lines.append("")
    lines.append("FAIRNESS CONSTRAINTS")
    lines.append(f"Target mechanical power:       {cfg.target_power_w:.3f} W")
    lines.append(f"Target speed:                  {cfg.target_speed_rpm:.3f} rpm")
    lines.append(f"Source phase RMS voltage:      {cfg.v_phase_rms:.3f} V")
    lines.append(f"Per-conductor current limit:  {cfg.current_limit:.3f} A")
    lines.append(f"Voltage limit:                 {cfg.voltage_limit:.3f} V")
    lines.append(f"3-phase total copper R:        {cfg.copper_resistance_total_3ph:.6f} ohm")
    lines.append(f"6-phase total copper R:        {cfg.copper_resistance_total_6ph:.6f} ohm")
    lines.append(f"Thermal limit:                 {cfg.thermal_limit_c:.3f} C")
    lines.append("")
    lines.append("NOMINAL RESULTS")
    lines.append("")
    lines.append("Metric                         3-phase             6-phase")
    lines.append("-" * 70)

    def line(label, a, b, suffix=""):
        lines.append(
            f"{label:<30} {a:>14.5f}{suffix:<8} {b:>14.5f}{suffix}"
        )

    line("Speed rpm", m3.speed_rpm, m6.speed_rpm)
    line("Output W", m3.pout_w, m6.pout_w)
    line("Input W", m3.pin_w, m6.pin_w)
    line("Efficiency", 100*m3.efficiency, 100*m6.efficiency, "%")
    line("Copper loss W", m3.pcu_w, m6.pcu_w)
    line("Total RMS current A", m3.current_rms_total, m6.current_rms_total)
    line("Max phase RMS current A", m3.current_rms_max, m6.current_rms_max)
    line("Torque ripple", m3.torque_ripple_pct, m6.torque_ripple_pct, "%")
    line("Power ripple", m3.power_ripple_pct, m6.power_ripple_pct, "%")
    line("Max temperature C", m3.thermal_c, m6.thermal_c)
    line("Thermal margin C", m3.thermal_margin_c, m6.thermal_margin_c)
    line("Current utilization", m3.current_utilization_pct, m6.current_utilization_pct, "%")
    line("Voltage utilization", m3.voltage_utilization_pct, m6.voltage_utilization_pct, "%")
    line("Energy residual", m3.energy_residual_pct, m6.energy_residual_pct, "%")

    lines.append("")
    lines.append("FAULT SURVIVAL")
    lines.append("-" * 88)

    for f in faults:
        lines.append(
            f"{f.architecture:<10} {f.fault:<24} "
            f"retained={f.retained_output_pct:8.3f}% "
            f"speed={f.speed_rpm:8.2f} rpm "
            f"T_ripple={f.torque_ripple_pct:9.3f}% "
            f"Tmax={f.max_thermal_c:8.3f} C "
            f"{f.protection_flag}"
        )

    lines.append("")
    lines.append("EARLIER A+F / B+E / C+D PAIRING — PHASOR-ONLY DIAGNOSTIC")
    lines.append("-" * 88)

    for p in pairing:
        lines.append(
            f"{p['pair']:<8} amp={p['amplitude']:.9f} "
            f"phase={p['phase_deg']:.6f} deg "
            f"positive={p['positive_sequence']:.9f} "
            f"negative={p['negative_sequence']:.9f} "
            f"neg/pos={p['negative_positive_pct']:.3f}%"
        )

    lines.append("")
    lines.append("INTERPRETATION RULES")
    lines.append("-" * 88)
    lines.append("1. No result here proves superiority of one machine technology.")
    lines.append("2. Lower power ripple is not the same thing as higher efficiency.")
    lines.append("3. A phase-fault result is meaningful only inside this model's limits.")
    lines.append("4. The earlier pair-sum topology is not treated as a power-conserving machine connection.")
    lines.append("5. Real claims require FEA, inverter-loss modeling, thermal validation and hardware tests.")
    lines.append("6. The most useful comparison is equal output + equal copper + equal voltage/current/thermal limits.")

    path.write_text("\n".join(lines), encoding="utf-8")


# =============================================================================
# PLOTS
# =============================================================================

def plot_nominal(
    out: Path,
    r3: SimResult,
    r6: SimResult,
):
    if plt is None:
        return

    sl3 = slice(int(len(r3.t) * 0.65), None)
    sl6 = slice(int(len(r6.t) * 0.65), None)

    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(r3.t[sl3], r3.torque[sl3], label="3-phase torque")
    ax.plot(r6.t[sl6], r6.torque[sl6], label="6-phase torque")
    ax.set_xlabel("Time (s)")
    ax.set_ylabel("Electromagnetic torque (Nm)")
    ax.set_title("Nominal Electromagnetic Torque")
    ax.grid(True, alpha=0.25)
    ax.legend()
    fig.tight_layout()
    fig.savefig(out / "torque_comparison.png", dpi=160)
    plt.close(fig)

    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(r3.t[sl3], r3.speed_rpm[sl3], label="3-phase speed")
    ax.plot(r6.t[sl6], r6.speed_rpm[sl6], label="6-phase speed")
    ax.set_xlabel("Time (s)")
    ax.set_ylabel("Speed (rpm)")
    ax.set_title("Nominal Speed")
    ax.grid(True, alpha=0.25)
    ax.legend()
    fig.tight_layout()
    fig.savefig(out / "nominal_speed.png", dpi=160)
    plt.close(fig)

    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(r3.t[sl3], r3.thermal_c[sl3], label="3-phase temperature")
    ax.plot(r6.t[sl6], r6.thermal_c[sl6], label="6-phase temperature")
    ax.set_xlabel("Time (s)")
    ax.set_ylabel("Winding temperature (C)")
    ax.set_title("Nominal Thermal Response")
    ax.grid(True, alpha=0.25)
    ax.legend()
    fig.tight_layout()
    fig.savefig(out / "thermal_comparison.png", dpi=160)
    plt.close(fig)

    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(r3.t[sl3], r3.electrical_power[sl3], label="3-phase input power")
    ax.plot(r6.t[sl6], r6.electrical_power[sl6], label="6-phase input power")
    ax.plot(r3.t[sl3], r3.mechanical_power[sl3], "--", label="3-phase mechanical")
    ax.plot(r6.t[sl6], r6.mechanical_power[sl6], "--", label="6-phase mechanical")
    ax.set_xlabel("Time (s)")
    ax.set_ylabel("Power (W)")
    ax.set_title("Electrical Input / Mechanical Output")
    ax.grid(True, alpha=0.25)
    ax.legend()
    fig.tight_layout()
    fig.savefig(out / "power_comparison.png", dpi=160)
    plt.close(fig)


def plot_faults(
    out: Path,
    fault_rows: List[FaultSummary],
):
    if plt is None or not fault_rows:
        return

    names = []
    vals = []
    groups = []

    for r in fault_rows:
        names.append(r.fault.replace(r.architecture + "_", ""))
        vals.append(r.retained_output_pct)
        groups.append(r.architecture)

    x = np.arange(len(names))

    fig, ax = plt.subplots(figsize=(13, 6))
    for arch in sorted(set(groups)):
        xx = [i for i, g in enumerate(groups) if g == arch]
        yy = [vals[i] for i in xx]
        ax.plot(xx, yy, "o-", label=arch)

    ax.set_xticks(x)
    ax.set_xticklabels(names, rotation=45, ha="right")
    ax.set_ylabel("Retained output (%)")
    ax.set_title("Single-Phase Open Fault Survival")
    ax.grid(True, alpha=0.25)
    ax.legend()
    fig.tight_layout()
    fig.savefig(out / "fault_survival.png", dpi=160)
    plt.close(fig)


def plot_harmonics(
    out: Path,
    cfg: Config,
    r3: SimResult,
    r6: SimResult,
):
    if plt is None:
        return

    fs = 1.0 / cfg.dt

    fig, ax = plt.subplots(figsize=(12, 6))

    for r in [r3, r6]:
        sl = slice(int(len(r.t) * (1.0 - cfg.rms_tail_fraction)), None)
        i = r.current[sl]

        vals = []
        for h in range(1, cfg.fft_harmonics_max + 1):
            amps = []
            for p in range(i.shape[1]):
                a = harmonic_amplitudes(
                    i[:, p],
                    fs,
                    cfg.f_e,
                    cfg.fft_harmonics_max,
                )
                amps.append(a[h])
            vals.append(np.sqrt(np.sum(np.square(amps))))

        ax.stem(
            np.arange(1, cfg.fft_harmonics_max + 1),
            vals,
            markerfmt=".",
            basefmt=" ",
            label=r.name,
        )

    ax.set_xlabel("Harmonic order")
    ax.set_ylabel("Aggregate current harmonic amplitude")
    ax.set_title("Current Harmonic Spectrum")
    ax.grid(True, alpha=0.25)
    ax.legend()
    fig.tight_layout()
    fig.savefig(out / "harmonic_spectrum.png", dpi=160)
    plt.close(fig)


# =============================================================================
# MAIN
# =============================================================================

def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description="Full six-phase vs conventional 3-phase machine/load/fault test."
    )
    p.add_argument("--seconds", type=float, default=0.60)
    p.add_argument("--dt", type=float, default=2.0e-5)
    p.add_argument("--fault-duration", type=float, default=0.20)
    p.add_argument("--save-dir", type=str, default="balanced-electric5-results")
    p.add_argument("--no-plots", action="store_true")
    return p.parse_args()


def main():
    args = parse_args()

    cfg = Config(
        seconds=args.seconds,
        dt=args.dt,
        fault_duration_s=args.fault_duration,
    )

    out = Path(args.save_dir)
    out.mkdir(parents=True, exist_ok=True)

    print("=" * 100)
    print("BALANCED-ELECTRIC-5 — FULL SIX-PHASE MACHINE / LOAD / FAULT TEST")
    print("=" * 100)
    print()
    print("This is a constrained engineering screening model.")
    print("It preserves six independent phases; it does NOT pair-sum them in the")
    print("main machine model. The earlier A+F/B+E/C+D connection is diagnostic only.")
    print()

    m3_machine = make_machine(cfg, 3)
    m6_machine = make_machine(cfg, 6)

    print("MACHINE CONFIGURATION")
    print(f"  3-phase axes: {np.rad2deg(m3_machine.axes)}")
    print(f"  6-phase axes: {np.rad2deg(m6_machine.axes)}")
    print(f"  total copper R, 3φ: {m3_machine.copper_resistance_sum:.6f} ohm")
    print(f"  total copper R, 6φ: {m6_machine.copper_resistance_sum:.6f} ohm")
    print(f"  target: {cfg.target_power_w:.1f} W @ {cfg.target_speed_rpm:.1f} rpm")
    print()

    print("RUNNING NOMINAL 3-PHASE...")
    r3 = simulate(cfg, m3_machine)
    m3 = calculate_metrics(cfg, m3_machine, r3)

    print("RUNNING NOMINAL 6-PHASE...")
    r6 = simulate(cfg, m6_machine)
    m6 = calculate_metrics(cfg, m6_machine, r6)

    print_metrics(m3)
    print_metrics(m6)

    print()
    print("=" * 100)
    print("DIRECT NOMINAL COMPARISON")
    print("=" * 100)

    def compare(label: str, a: float, b: float, unit: str = ""):
        delta = b - a
        rel = safe_div(delta, abs(a)) * 100.0
        print(
            f"{label:<28} 3φ={a:>12.5f}{unit:<5} "
            f"6φ={b:>12.5f}{unit:<5} "
            f"delta={delta:>12.5f}{unit:<5} ({rel:+.3f}%)"
        )

    compare("Output power", m3.pout_w, m6.pout_w, "W")
    compare("Input power", m3.pin_w, m6.pin_w, "W")
    compare("Efficiency", 100*m3.efficiency, 100*m6.efficiency, "%")
    compare("Copper loss", m3.pcu_w, m6.pcu_w, "W")
    compare("Total RMS current", m3.current_rms_total, m6.current_rms_total, "A")
    compare("Max phase RMS current", m3.current_rms_max, m6.current_rms_max, "A")
    compare("Torque ripple", m3.torque_ripple_pct, m6.torque_ripple_pct, "%")
    compare("Power ripple", m3.power_ripple_pct, m6.power_ripple_pct, "%")
    compare("Maximum temperature", m3.thermal_c, m6.thermal_c, "C")
    compare("Current utilization", m3.current_utilization_pct, m6.current_utilization_pct, "%")

    print()
    print("=" * 100)
    print("RUNNING SINGLE-PHASE OPEN FAULTS")
    print("=" * 100)

    fault_rows: List[FaultSummary] = []

    fault_rows.extend(run_faults(cfg, m3_machine, m3))
    fault_rows.extend(run_faults(cfg, m6_machine, m6))

    for f in fault_rows:
        print(
            f"{f.architecture:>8} | {f.fault:<20} | "
            f"retained={f.retained_output_pct:8.3f}% | "
            f"speed={f.speed_rpm:8.2f} rpm | "
            f"torque ripple={f.torque_ripple_pct:9.3f}% | "
            f"Tmax={f.max_thermal_c:8.3f} C | "
            f"{f.protection_flag}"
        )

    print()
    print("=" * 100)
    print("EARLIER A+F / B+E / C+D PAIRING DIAGNOSTIC")
    print("=" * 100)

    pairing = pairing_diagnostic()
    for p in pairing:
        print(
            f"{p['pair']:<8} "
            f"amp={p['amplitude']:.9f} "
            f"phase={p['phase_deg']:.6f}° "
            f"positive={p['positive_sequence']:.9f} "
            f"negative={p['negative_sequence']:.9f} "
            f"zero={p['zero_sequence']:.9f} "
            f"neg/pos={p['negative_positive_pct']:.3f}%"
        )

    # CSVs
    write_nominal_csv(out / "nominal.csv", [r3, r6], every=max(1, int(0.0002 / cfg.dt)))

    metrics_rows = [
        metrics_dict(m3),
        metrics_dict(m6),
    ]
    write_csv(
        out / "nominal_metrics.csv",
        metrics_rows,
        list(metrics_rows[0].keys()),
    )

    fault_dict_rows = [vars(x) for x in fault_rows]
    write_csv(
        out / "faults.csv",
        fault_dict_rows,
        list(fault_dict_rows[0].keys()) if fault_dict_rows else None,
    )

    harm_rows = harmonic_metrics(cfg, r3) + harmonic_metrics(cfg, r6)
    write_csv(
        out / "harmonics.csv",
        harm_rows,
        ["architecture", "phase", "harmonic", "amplitude"],
    )

    # Energy balance trace.
    eb_rows = []
    for r in [r3, r6]:
        every = max(1, int(0.0002 / cfg.dt))
        for k in range(0, len(r.t), every):
            visc = cfg.viscous_b * r.omega[k] ** 2
            residual = (
                r.electrical_power[k]
                - r.mechanical_power[k]
                - r.copper_loss[k]
                - visc
            )
            eb_rows.append([
                r.name,
                r.t[k],
                r.electrical_power[k],
                r.mechanical_power[k],
                r.copper_loss[k],
                visc,
                residual,
            ])

    write_csv(
        out / "energy_balance.csv",
        eb_rows,
        [
            "architecture",
            "time_s",
            "electrical_input_W",
            "mechanical_output_W",
            "copper_loss_W",
            "viscous_loss_W",
            "residual_W",
        ],
    )

    write_csv(
        out / "topology_pairing_diagnostic.csv",
        pairing,
        [
            "pair",
            "amplitude",
            "phase_deg",
            "positive_sequence",
            "negative_sequence",
            "zero_sequence",
            "negative_positive_pct",
        ],
    )

    write_summary(
        out / "summary.txt",
        cfg,
        m3,
        m6,
        fault_rows,
        pairing,
    )

    if not args.no_plots:
        if plt is None:
            print("\nmatplotlib unavailable: plots skipped.")
        else:
            plot_nominal(out, r3, r6)
            plot_faults(out, fault_rows)
            plot_harmonics(out, cfg, r3, r6)

    print()
    print("=" * 100)
    print("FILES WRITTEN")
    print("=" * 100)
    for p in sorted(out.iterdir()):
        print(f"  {p}")

    print()
    print("=" * 100)
    print("IMPORTANT INTERPRETATION")
    print("=" * 100)
    print("This test answers a much stronger question than the earlier waveform test:")
    print("whether a six-phase machine can preserve useful output under matched")
    print("copper/electrical/thermal constraints.")
    print()
    print("It does NOT establish commercial superiority. A real design still needs:")
    print("  * electromagnetic FEA")
    print("  * slot/winding-factor optimization")
    print("  * iron-loss and saturation modeling")
    print("  * inverter switching/conduction losses")
    print("  * detailed thermal network")
    print("  * insulation and fault-protection engineering")
    print("  * acoustic/vibration testing")
    print("  * hardware validation")
    print()
    print(f"Results directory: {out.resolve()}")


if __name__ == "__main__":
    main()
#!/usr/bin/env python3
"""
BALANCED-ELECTRIC-6 — EXACT USER TOPOLOGY, POWER-CONSERVING REDUCED MODEL
===============================================================================
Compares conventional 3-phase against the exact proposed topology:
    A=0°, B=30°, C=120°, D=150°, E=240°, F=270°
    U=A+F, V=B+E, W=C+D

The six physical windings are retained. The pair connection is represented by
an incidence matrix C and reduced terminal variables:
    i_branch = C i_pair
    v_pair   = C.T v_branch
This guarantees v_branch.T i_branch = v_pair.T i_pair.

Unlike the previous broken delivery, this file is ONLY the runnable program;
it never tries to write itself to /mnt/data. It creates output files next to
itself in balanced-electric6-output/.

This is an engineering screening model, not FEA or hardware certification.
It includes sinusoidal flux linkage, mutual inductance, copper loss, iron loss,
inverter loss, mechanical loss, thermal state, energy accounting, harmonics,
common-mode voltage, nominal operation, and open-pair/open-phase faults.

Requires: Python 3.10+, numpy. matplotlib is optional.
"""
from __future__ import annotations
import argparse, csv, math
from dataclasses import dataclass
from pathlib import Path
import numpy as np

try:
    import matplotlib.pyplot as plt
except Exception:
    plt = None

DEG=math.pi/180.0
TARGET_POWER=5000.0
TARGET_RPM=1800.0
OMEGA_TARGET=TARGET_RPM*2*math.pi/60
TOTAL_COPPER_R=0.90
CURRENT_LIMIT_RMS=18.0
TERMINAL_VOLTAGE_LIMIT_RMS=240.0
AMBIENT=25.0
THERMAL_LIMIT=120.0
THERMAL_RISE_PER_W=0.075
THERMAL_TAU=18.0
INERTIA=0.060
POLE_PAIRS=1
FLUX_PEAK=1.20
L3_SELF=0.012
L6_SELF=0.006
M6_NEAR=0.0012
M6_OPP=0.0005
IRON_BASE=38.0
IRON_SPEED=0.004
INV_FRAC=0.010
MECH_BASE=18.0
MECH_SPEED=0.0025
CONTROL_TAU=0.0008
DT_DEFAULT=4e-5
LOAD_TORQUE=TARGET_POWER/OMEGA_TARGET

AX3_DEG=np.array([0.,120.,240.])
AX6_DEG=np.array([0.,30.,120.,150.,240.,270.])
AX3=AX3_DEG*DEG
AX6=AX6_DEG*DEG
# [A B C D E F] = C [U V W]
C6=np.array([[1,0,0],[0,1,0],[0,0,1],[0,0,1],[0,1,0],[1,0,0]],float)

@dataclass
class System:
    name:str
    axes:np.ndarray
    C:np.ndarray
    R_branch:np.ndarray
    L_branch:np.ndarray
    R_term:np.ndarray
    L_term:np.ndarray
    flux_term_axes:np.ndarray
    flux_term_coeff:np.ndarray

@dataclass
class Result:
    system:System
    fault:str
    t:np.ndarray
    speed:np.ndarray
    theta:np.ndarray
    i_branch:np.ndarray
    v_branch:np.ndarray
    i_term:np.ndarray
    v_term:np.ndarray
    torque:np.ndarray
    pin:np.ndarray
    pout:np.ndarray
    pcu:np.ndarray
    piron:np.ndarray
    pinv:np.ndarray
    pmechloss:np.ndarray
    temp:np.ndarray
    cmv:np.ndarray
    summary:dict


def make_L(n,self_l,near,opp=0):
    M=np.zeros((n,n))
    for i in range(n):
        for j in range(n):
            if i==j: M[i,j]=self_l
            else:
                d=min((i-j)%n,(j-i)%n)
                if d==1: M[i,j]=near
                elif n==6 and d==3: M[i,j]=opp
                else: M[i,j]=near*0.35
    e=np.linalg.eigvalsh(M)
    if e.min()<=1e-9: M+=np.eye(n)*(1e-9-e.min()+1e-10)
    return M


def systems():
    r3=TOTAL_COPPER_R/3
    r6=TOTAL_COPPER_R/6
    L3=make_L(3,L3_SELF,0.0015)
    L6=make_L(6,L6_SELF,M6_NEAR,M6_OPP)
    s3=System('3-phase',AX3,np.eye(3),np.full(3,r3),L3,np.eye(3)*r3,L3,AX3,np.ones(3))
    # The pair terminal flux is C.T lambda_branch. It is generally not a
    # single sinusoid; keeping the six components is important.
    s6=System('USER-6',AX6,C6,np.full(6,r6),L6,C6.T@np.diag(np.full(6,r6))@C6,C6.T@L6@C6,AX6,np.ones(6))
    return s3,s6


def branch_flux(s,theta):
    return FLUX_PEAK*np.cos(POLE_PAIRS*theta-s.axes)

def branch_dflux(s,theta):
    return -FLUX_PEAK*POLE_PAIRS*np.sin(POLE_PAIRS*theta-s.axes)

def branch_emf(s,theta,omega):
    return branch_dflux(s,theta)*omega

def torque(s,theta,ib):
    return float(np.dot(ib,branch_dflux(s,theta)))


def pair_current_reference(s,theta,torque_cmd):
    g=s.C.T@branch_dflux(s,theta)
    gg=float(g@g)
    if gg<1e-14 or torque_cmd<=0: return np.zeros(3)
    ip=(torque_cmd/gg)*g
    # branch RMS current limit, because physical conductors carry pair current.
    ib=s.C@ip
    mx=float(np.max(np.abs(ib)))
    # Instantaneous peak limit corresponding to RMS conductor limit.
    lim=CURRENT_LIMIT_RMS*math.sqrt(2)
    if mx>lim: ip*=lim/mx
    return ip


def fft_thd(x,dt,f0):
    x=np.asarray(x,float); n=len(x)
    if n<128 or f0<=0:return 0.0
    y=x-np.mean(x); w=np.hanning(n); sp=np.fft.rfft(y*w); fr=np.fft.rfftfreq(n,dt)
    k=int(np.argmin(np.abs(fr-f0)))
    if k<1 or abs(sp[k])<1e-14:return 0.0
    bins=np.arange(2*k,len(sp),k); bins=bins[bins<len(sp)]
    return float(np.sqrt(np.sum(np.abs(sp[bins])**2))/abs(sp[k]))


def simulate(s,seconds,dt,fault_pair=None,fault_start=None,fault_duration=0.0):
    n=int(round(seconds/dt))+1; t=np.arange(n)*dt
    speed=np.zeros(n); theta=np.zeros(n)
    ib=np.zeros((n,6 if s.name=='USER-6' else 3)); vb=np.zeros_like(ib)
    it=np.zeros((n,3)); vt=np.zeros((n,3)); te=np.zeros(n)
    pin=np.zeros(n); pout=np.zeros(n); pcu=np.zeros(n); piron=np.zeros(n); pinv=np.zeros(n); pmech_loss_arr=np.zeros(n)
    temp=np.full(n,AMBIENT); cm=np.zeros(n)
    ip=np.zeros(3); omega=OMEGA_TARGET*0.985; th=0.0; T=AMBIENT
    if fault_start is None:fault_start=seconds*0.45
    fend=fault_start+fault_duration
    target=OMEGA_TARGET
    for k,tk in enumerate(t[:-1]):
        active=fault_pair is not None and fault_start<=tk<fend
        tl=LOAD_TORQUE
        # PI-like correction, with explicit load-torque feed-forward.
        corr=0.10*(target-omega)
        torque_cmd=float(np.clip(tl+corr,0,2*tl))
        ip_ref=pair_current_reference(s,th,torque_cmd)
        if active: ip_ref[fault_pair]=0.0
        eb=branch_emf(s,th,omega)
        et=s.C.T@eb
        # Reduced KVL: vt = Rterm*i + Lterm*di/dt + et.
        raw=(ip_ref-ip)/CONTROL_TAU
        base=s.R_term@ip+et
        coeff=np.diag(s.L_term)+s.R_term*dt
        # Diagonal approximation is used only for voltage limiter; actual
        # coupled L is retained in the voltage calculation below.
        # Conservative scalar scaling keeps the full vector within terminal limit.
        di=raw.copy()
        for _ in range(4):
            trial=ip+di*dt
            dtrial=(trial-ip)/dt
            vtrial=s.R_term@trial+s.L_term@dtrial+et
            mx=float(np.max(np.abs(vtrial)))
            lim=TERMINAL_VOLTAGE_LIMIT_RMS*math.sqrt(2)
            if mx>lim: di*=lim/mx
            else: break
        inew=ip+di*dt
        # Terminal current is also conductor current; instantaneous limit.
        limi=CURRENT_LIMIT_RMS*math.sqrt(2)
        ibtrial=s.C@inew; imax=float(np.max(np.abs(ibtrial)))
        if imax>limi:inew*=limi/imax
        if active:inew[fault_pair]=0.0
        di=(inew-ip)/dt
        vt_now=s.R_term@inew+s.L_term@di+et
        if active:vt_now[fault_pair]=0.0
        ib_now=s.C@inew; vb_now=np.linalg.lstsq(s.C.T,vt_now,rcond=None)[0] if s.name=='USER-6' else vt_now.copy()
        # For the series connection, branch voltages are not arbitrary: use the
        # physical branch KVL. Pair power equals branch power exactly by C.
        vb_phys=s.R_branch*ib_now+s.L_branch@ (s.C@di)+eb
        if s.name=='USER-6':
            # vb_phys sums to vt_now; numerical KVL defines physical branch volts.
            vb_now=vb_phys
        te_now=torque(s,th,ib_now)
        p_e=float(np.dot(vt_now,inew))
        p_c=float(np.sum(s.R_branch*ib_now**2))
        pir=IRON_BASE+IRON_SPEED*omega**2
        pml=MECH_BASE+MECH_SPEED*omega**2
        piv=INV_FRAC*abs(p_e)
        alpha=(te_now-tl-pml/max(omega,10))/INERTIA
        omega=max(0,omega+alpha*dt); th+=omega*dt
        heat=p_c+pir+piv
        Ttarget=AMBIENT+THERMAL_RISE_PER_W*heat
        T+=(Ttarget-T)*dt/THERMAL_TAU
        speed[k]=omega*60/(2*math.pi);theta[k]=th;ib[k+1]=ib_now;vb[k]=vb_now
        it[k+1]=inew;vt[k]=vt_now;te[k]=te_now;pin[k]=p_e;pout[k]=max(0,te_now*omega)
        pcu[k]=p_c;piron[k]=pir;pinv[k]=piv;pmech_loss_arr[k]=pml;temp[k]=T;cm[k]=np.mean(vb_now);ip=inew
    speed[-1]=omega*60/(2*math.pi);theta[-1]=th;te[-1]=te[-2];pin[-1]=pin[-2];pout[-1]=pout[-2];pcu[-1]=pcu[-2];piron[-1]=piron[-2];pinv[-1]=pinv[-2];pmech_loss_arr[-1]=pmech_loss_arr[-2];temp[-1]=temp[-2];vb[-1]=vb[-2];vt[-1]=vt[-2];ib[-1]=ib[-2];it[-1]=it[-2];cm[-1]=cm[-2]
    cut=max(1,n//2); sl=slice(cut,None)
    ir=np.sqrt(np.mean(ib[sl]**2,axis=0)); vr=np.sqrt(np.mean(vb[sl]**2,axis=0));
    I=rms_vec(np.sqrt(np.sum(ib[sl]**2,axis=1))); V=rms_vec(np.sqrt(np.sum(vb[sl]**2,axis=1)))
    Pin=float(np.mean(pin[sl])); Pout=float(np.mean(pout[sl])); tq=float(np.mean(te[sl]))
    Ein=float(np.trapezoid(pin[sl],t[sl])); Eout=float(np.trapezoid(pout[sl],t[sl])); Ec=float(np.trapezoid(pcu[sl],t[sl])); Ei=float(np.trapezoid(piron[sl],t[sl])); Ev=float(np.trapezoid(pinv[sl],t[sl])); Em=float(np.trapezoid(pmech_loss_arr[sl],t[sl]))
    w0=speed[cut]*2*math.pi/60;w1=speed[-1]*2*math.pi/60;dEk=.5*INERTIA*(w1*w1-w0*w0)
    dEm=.5*float(it[-1]@s.L_term@it[-1])-0.5*float(it[cut]@s.L_term@it[cut])
    # Electromagnetic energy closure: terminal electrical input must equal
    # copper dissipation + electromagnetic mechanical conversion + change in
    # magnetic stored energy. Iron/inverter/mechanical losses are modeled as
    # separate system-level losses and are therefore not double-counted here.
    Eem=float(np.trapezoid(pout[sl],t[sl]))
    accounted=Eout+Ec+dEm
    residual=100*(Ein-accounted)/max(abs(Ein),1e-12)
    f0=max(speed[-1],1)/60
    cmr=rms_vec(cm[sl])
    summ={'speed_rpm':float(speed[-1]),'total_rms_current_A':I,'max_phase_rms_current_A':float(np.max(ir)),
          'total_rms_voltage_V':V,'max_phase_rms_voltage_V':float(np.max(vr)),'input_power_W':Pin,'mechanical_output_W':Pout,
          'copper_loss_W':float(np.mean(pcu[sl])),'iron_loss_W':float(np.mean(piron[sl])),'inverter_loss_W':float(np.mean(pinv[sl])),
          'mechanical_loss_W':float(np.mean(pmech_loss_arr[sl])),'efficiency_pct':100*Pout/max(Pin,1e-12),
          'model_pf':Pin/max(I*V,1e-12),'mean_torque_Nm':tq,'torque_ripple_pct':100*float(np.std(te[sl]))/max(abs(tq),1e-12),
          'max_temperature_C':float(np.max(temp[sl])),'thermal_margin_C':THERMAL_LIMIT-float(np.max(temp[sl])),
          'current_utilization_pct':100*float(np.max(ir))/CURRENT_LIMIT_RMS,'voltage_utilization_pct':100*float(np.max(vr))/TERMINAL_VOLTAGE_LIMIT_RMS,
          'input_power_ripple_pct':100*float(np.std(pin[sl]))/max(abs(Pin),1e-12),
          'current_thd_pct':100*float(np.mean([fft_thd(ib[sl,j],dt,f0) for j in range(ib.shape[1])])),
          'voltage_thd_pct':100*float(np.mean([fft_thd(vb[sl,j],dt,f0) for j in range(vb.shape[1])])),
          'common_mode_rms_V':cmr,'energy_residual_pct':residual,'electromagnetic_conversion_J':Eem,'magnetic_energy_delta_J':dEm}
    return Result(s,'none' if fault_pair is None else f'{s.name}_pair_{fault_pair+1}_OPEN',t,speed,theta,ib,vb,it,vt,te,pin,pout,pcu,piron,pinv,pmech_loss_arr,temp,cm,summ)

def rms_vec(x):return float(np.sqrt(np.mean(np.square(x))))

def status(s):
    if s['thermal_margin_C']<0:return 'THERMAL_LIMIT'
    if s['max_phase_rms_current_A']>CURRENT_LIMIT_RMS*1.001:return 'CURRENT_LIMIT'
    if s['max_phase_rms_voltage_V']>TERMINAL_VOLTAGE_LIMIT_RMS*1.001:return 'VOLTAGE_LIMIT'
    return 'WITHIN_MODEL_LIMITS'

def print_summary(r):
    s=r.summary;print(f'\n[{r.system.name}] {r.fault}')
    rows=[('speed','speed_rpm','rpm'),('total RMS current','total_rms_current_A','A'),('max phase RMS current','max_phase_rms_current_A','A'),('total RMS voltage','total_rms_voltage_V','V'),('max phase RMS voltage','max_phase_rms_voltage_V','V'),('input power','input_power_W','W'),('mechanical output','mechanical_output_W','W'),('copper loss','copper_loss_W','W'),('iron loss','iron_loss_W','W'),('inverter loss','inverter_loss_W','W'),('mechanical loss','mechanical_loss_W','W'),('efficiency','efficiency_pct','%'),('model PF','model_pf',''),('mean torque','mean_torque_Nm','Nm'),('torque ripple','torque_ripple_pct','%'),('max temperature','max_temperature_C','C'),('thermal margin','thermal_margin_C','C'),('current utilization','current_utilization_pct','%'),('voltage utilization','voltage_utilization_pct','%'),('input-power ripple','input_power_ripple_pct','%'),('current THD','current_thd_pct','%'),('voltage THD','voltage_thd_pct','%'),('common-mode RMS','common_mode_rms_V','V'),('energy residual','energy_residual_pct','%')]
    for a,b,c in rows:print(f'  {a:24s} {s[b]:12.5f} {c}')

def compare(a,b):
    print('\n'+'='*100+'\nDIRECT NOMINAL COMPARISON\n'+'='*100)
    keys=[('Output power','mechanical_output_W','W'),('Input power','input_power_W','W'),('Efficiency','efficiency_pct','%'),('Copper loss','copper_loss_W','W'),('Iron loss','iron_loss_W','W'),('Inverter loss','inverter_loss_W','W'),('Total RMS current','total_rms_current_A','A'),('Max phase RMS current','max_phase_rms_current_A','A'),('Torque ripple','torque_ripple_pct','%'),('Power ripple','input_power_ripple_pct','%'),('Current THD','current_thd_pct','%'),('Voltage THD','voltage_thd_pct','%'),('Max temperature','max_temperature_C','C'),('Current utilization','current_utilization_pct','%'),('Voltage utilization','voltage_utilization_pct','%'),('Common-mode RMS','common_mode_rms_V','V'),('Energy residual','energy_residual_pct','%')]
    for lab,k,u in keys:
        x=a.summary[k];y=b.summary[k];d=y-x;p=100*d/max(abs(x),1e-12);print(f'{lab:26s}  3φ={x:12.5f}{u:2s}  USER-6={y:12.5f}{u:2s}  delta={d:12.5f}{u:2s}  ({p:+8.3f}%)')

def diagnostic():
    z=np.exp(1j*AX6);alpha=np.exp(1j*120*DEG);names=['A','B','C','D','E','F'];pairs=[(0,5),(1,4),(2,3)];out=[]
    for name,(p,q) in zip(['A+F','B+E','C+D'],pairs):
        x=np.zeros(3,dtype=complex);x[['A+F','B+E','C+D'].index(name)]=z[p]+z[q]
        pos=abs((x[0]+alpha*x[1]+alpha**2*x[2])/3);neg=abs((x[0]+alpha**2*x[1]+alpha*x[2])/3);zero=abs(np.sum(x)/3)
        out.append((name,abs(z[p]+z[q]),math.degrees(np.angle(z[p]+z[q]))%360,pos,neg,zero,100*neg/max(pos,1e-15)))
    return out

def save_csv(r,out):
    p=out/f'{r.system.name}_{r.fault}.csv'
    with p.open('w',newline='',encoding='utf-8') as f:
        w=csv.writer(f);w.writerow(['t_s','speed_rpm','theta_rad','torque_Nm','p_in_W','p_out_W','p_cu_W','p_iron_W','p_inv_W','p_mech_loss_W','temperature_C','common_mode_V'])
        for k in range(len(r.t)):w.writerow([r.t[k],r.speed[k],r.theta[k],r.torque[k],r.pin[k],r.pout[k],r.pcu[k],r.piron[k],r.pinv[k],r.pmechloss[k],r.temp[k],r.cmv[k]])

def make_plots(a,b,out):
    if plt is None:return
    plots=[('speed.png','Speed (rpm)',[(a.speed,'3-phase'),(b.speed,'USER-6')]),('torque.png','Torque (N·m)',[(a.torque,'3-phase'),(b.torque,'USER-6')]),('power.png','Power (W)',[(a.pin,'3-phase input'),(b.pin,'USER-6 input'),(a.pout,'3-phase shaft'),(b.pout,'USER-6 shaft')]),('temperature.png','Temperature (°C)',[(a.temp,'3-phase'),(b.temp,'USER-6')])]
    for fn,yl,series in plots:
        fig,ax=plt.subplots(figsize=(11,6))
        for y,l in series:ax.plot(a.t if len(y)==len(a.t) else b.t,y,label=l)
        ax.set_xlabel('Time (s)');ax.set_ylabel(yl);ax.set_title('Balanced-Electric-6');ax.grid(True,alpha=.25);ax.legend();fig.tight_layout();fig.savefig(out/fn,dpi=160);plt.close(fig)

def main():
    ap=argparse.ArgumentParser(description='Exact Balanced-Electric-6 topology screening model')
    ap.add_argument('--seconds',type=float,default=.60);ap.add_argument('--dt',type=float,default=DT_DEFAULT);ap.add_argument('--fault-duration',type=float,default=.20);ap.add_argument('--fault-start',type=float,default=None);ap.add_argument('--no-plots',action='store_true')
    a=ap.parse_args()
    if a.seconds<=0 or a.dt<=0 or a.fault_duration<0:raise SystemExit('seconds/dt must be positive; fault-duration must be nonnegative')
    out=Path(__file__).resolve().parent/'balanced-electric6-output';out.mkdir(parents=True,exist_ok=True)
    s3,s6=systems()
    print('='*100);print('BALANCED-ELECTRIC-6 — EXACT USER TOPOLOGY / POWER-CONSERVING MODEL');print('='*100)
    print('\nEXACT USER TOPOLOGY');print('  A=0°, B=30°, C=120°, D=150°, E=240°, F=270°');print('  U=A+F, V=B+E, W=C+D')
    print('\nPOWER-CONSERVING CONNECTION');print('  i_branch = C i_pair');print('  v_pair   = C^T v_branch');print('  v_branch^T i_branch = v_pair^T i_pair')
    print(f'\nFAIRNESS: total copper R = {TOTAL_COPPER_R:.6f} ohm in both systems');print(f'  terminal voltage limit = {TERMINAL_VOLTAGE_LIMIT_RMS:.1f} V RMS');print(f'  conductor current limit = {CURRENT_LIMIT_RMS:.1f} A RMS');print(f'  target = {TARGET_POWER:.1f} W @ {TARGET_RPM:.1f} rpm')
    print('\nRUNNING NOMINAL 3-PHASE...');n3=simulate(s3,a.seconds,a.dt)
    print('RUNNING NOMINAL EXACT USER-6...');n6=simulate(s6,a.seconds,a.dt)
    print_summary(n3);print_summary(n6);compare(n3,n6)
    print('\n'+'='*100+'\nRUNNING SINGLE-PAIR / SINGLE-PHYSICAL-PHASE OPEN FAULTS\n'+'='*100)
    faults=[]
    for s,nom in [(s3,n3),(s6,n6)]:
        for j in range(3):
            r=simulate(s,a.seconds,a.dt,j,a.fault_start,a.fault_duration);faults.append(r)
            retained=100*r.summary['mechanical_output_W']/max(nom.summary['mechanical_output_W'],1e-12)
            if s.name=='USER-6':desc=f'pair {j+1} (two physical series branches)'
            else:desc=f'phase {j+1}'
            print(f'{s.name:8s} | {desc:31s} | retained={retained:8.3f}% | speed={r.summary["speed_rpm"]:8.2f} rpm | torque ripple={r.summary["torque_ripple_pct"]:8.3f}% | Tmax={r.summary["max_temperature_C"]:8.3f} C | {status(r.summary)}')
    print('\n'+'='*100+'\nEARLIER A+F / B+E / C+D PHASOR DIAGNOSTIC\n'+'='*100)
    for x in diagnostic():print(f'{x[0]:8s} amp={x[1]:.9f} phase={x[2]:.6f}° positive={x[3]:.9f} negative={x[4]:.9f} zero={x[5]:.9f} neg/pos={x[6]:.3f}%')
    save_csv(n3,out);save_csv(n6,out)
    for r in faults:save_csv(r,out)
    report=out/'balanced-electric6-report.txt'
    with report.open('w',encoding='utf-8') as f:
        f.write('BALANCED-ELECTRIC-6 SCREENING REPORT\n'+'='*80+'\n')
        f.write('Exact topology: A=0°, B=30°, C=120°, D=150°, E=240°, F=270°; U=A+F, V=B+E, W=C+D\n\n')
        for name,r in [('3-phase',n3),('USER-6',n6)]:
            f.write(name+'\n');[f.write(f'  {k}: {v:.9f}\n') for k,v in r.summary.items()]
        f.write('\nFAULTS\n')
        for r in faults:f.write(f'{r.system.name:8s} {r.fault:24s} output={r.summary["mechanical_output_W"]:.3f}W speed={r.summary["speed_rpm"]:.2f}rpm ripple={r.summary["torque_ripple_pct"]:.3f}% {status(r.summary)}\n')
        f.write('\nLIMITATIONS: lumped sinusoidal windings; no FEA, saturation, slotting, detailed winding factors, switching-device physics, insulation, arc/breaker physics, or certification.\n')
    if not a.no_plots and plt is not None:make_plots(n3,n6,out);print(f'\nPlots written to: {out}')
    print(f'Report written to: {report}');print(f'CSV/output directory: {out}')
    print('\nThis is the standalone repaired program. It does not generate or overwrite itself.')

if __name__=='__main__':main()

balanced-6x6.zip (47.4 KB)