File size: 8,751 Bytes
ebe0477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
"""Standalone CPU inference for the converted MagNet steady-state Transformers.

Run from an unpacked model repository. This example only loads ONNX/JSON/NPY
files and never unpickles the original PyTorch checkpoints.
"""

from __future__ import annotations

import argparse
import json
import math
from pathlib import Path

import numpy as np
import onnxruntime as ort

ROOT = Path(__file__).resolve().parent
MATERIALS = ("3C90", "3C94", "3E6", "3F4", "77", "78", "N27", "N30", "N49", "N87")


def _finite(value: float, name: str) -> float:
    number = float(value)
    if not math.isfinite(number):
        raise ValueError(f"{name} must be finite")
    return number


def make_waveform(kind: str, flux_t: float, duty: float = 0.5) -> np.ndarray:
    """Return 128 B samples in T, following the historical MagNet convention."""
    flux_t = _finite(flux_t, "flux_t")
    if flux_t <= 0:
        raise ValueError("flux_t must be a positive peak AC flux density in T")
    if kind == "sine":
        return (flux_t * np.sin(np.linspace(0, 2 * np.pi, 129)))[:-1]
    if kind != "triangle":
        raise ValueError("waveform must be sine or triangle")
    duty = _finite(duty, "duty")
    if not 0 < duty < 1:
        raise ValueError("triangle duty ratio must lie strictly between 0 and 1")
    b = np.interp(np.linspace(0, 1, 129), [0, duty, 1], [-flux_t, flux_t, -flux_t])[:-1]
    phase = 0.5 - duty / 2 if duty <= 0.5 else -duty / 2
    return np.roll(b, int(phase * 128))


def load_waveform_json(path: Path) -> np.ndarray:
    values = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(values, list) or len(values) != 128:
        raise ValueError("--b-json must contain a JSON array of exactly 128 B values in T")
    if any(isinstance(value, bool) or not isinstance(value, (float, int)) for value in values):
        raise ValueError("B values must all be JSON numbers")
    return np.asarray(values, dtype=np.float64)


def _normal(value: float, center: np.float32, scale: np.float32) -> np.float32:
    # Preserve the float32 arithmetic of the legacy PyTorch inference.
    return np.float32((np.float32(value) - center) / scale)


def measured_hull(
    material: str,
    frequency_hz: float,
    flux_t: float,
    bias_a_per_m: float,
    temperature_c: float,
    duty: float,
) -> bool:
    equations = np.load(ROOT / "domain" / f"hull_{material}.npy", allow_pickle=False)
    if (
        equations.ndim != 2
        or equations.shape[1] != 6
        or not np.issubdtype(equations.dtype, np.number)
    ):
        raise ValueError(f"invalid numeric measured-domain hull for {material}")
    point = np.array([frequency_hz, flux_t, bias_a_per_m, temperature_c, duty], dtype=np.float64)
    return bool(np.all(equations[:, :5] @ point + equations[:, 5] <= 1e-10))


def predict(
    material: str,
    b_waveform_t: np.ndarray,
    frequency_hz: float,
    temperature_c: float,
    bias_a_per_m: float,
    nominal_flux_t: float | None = None,
    duty: float | None = None,
) -> dict:
    """Predict the 128-point H waveform and signed loss density.

    nominal_flux_t and duty enable an ideal-waveform hull check. Omit both for
    an arbitrary B waveform, for which this historical hull is not applicable.
    """
    if material not in MATERIALS:
        raise ValueError(f"unknown material {material!r}; choose from {', '.join(MATERIALS)}")
    b = np.asarray(b_waveform_t, dtype=np.float64)
    if b.shape != (128,) or not np.isfinite(b).all() or np.max(b) == np.min(b):
        raise ValueError("B waveform must have 128 finite, varying samples in T")
    frequency_hz = _finite(frequency_hz, "frequency_hz")
    temperature_c = _finite(temperature_c, "temperature_c")
    bias_a_per_m = _finite(bias_a_per_m, "bias_a_per_m")
    if frequency_hz <= 0:
        raise ValueError("frequency_hz must be greater than zero")
    if (nominal_flux_t is None) != (duty is None):
        raise ValueError("nominal_flux_t and duty must both be given or both omitted")
    if nominal_flux_t is not None:
        nominal_flux_t = _finite(nominal_flux_t, "nominal_flux_t")
        duty = _finite(duty, "duty")
        if nominal_flux_t <= 0 or not 0 < duty < 1:
            raise ValueError("nominal_flux_t must be positive and duty strictly between 0 and 1")

    norm_values = json.loads((ROOT / material / "norm.json").read_text(encoding="utf-8"))
    if not isinstance(norm_values, list) or len(norm_values) != 10:
        raise ValueError("normalizer must contain ten values")
    norm = np.asarray(norm_values, dtype=np.float32)
    if not np.isfinite(norm).all() or np.any(norm[[1, 3, 5, 7]] == 0):
        raise ValueError("invalid normalization values")

    source = ((b.astype(np.float32) - norm[0]) / norm[1]).reshape(1, 128, 1)
    variables = np.array(
        [
            _normal(np.float32(np.log10(frequency_hz)), norm[2], norm[3]),
            _normal(temperature_c, norm[4], norm[5]),
            _normal(bias_a_per_m, norm[6], norm[7]),
        ],
        dtype=np.float32,
    ).reshape(1, 3)
    options = ort.SessionOptions()
    options.intra_op_num_threads = 1
    encoder = ort.InferenceSession(
        str(ROOT / material / "encoder.onnx"), options, providers=["CPUExecutionProvider"]
    )
    decoder = ort.InferenceSession(
        str(ROOT / material / "decoder.onnx"), options, providers=["CPUExecutionProvider"]
    )
    memory = encoder.run(["memory"], {"source": source, "variables": variables})[0]
    if memory.shape != (1, 128, 24):
        raise ValueError("unexpected encoder output shape")
    target = np.zeros((1, 129, 1), dtype=np.float32)
    target[0, 0, 0] = 0.1
    for step in range(1, 129):
        output = decoder.run(["output"], {"memory": memory, "target": target})[0]
        target[0, step, 0] = output[0, step - 1, 0]
    output = decoder.run(["output"], {"memory": memory, "target": target})[0]
    if output.shape != (1, 129, 1):
        raise ValueError("unexpected decoder output shape")
    h = (output[0, :128, 0] * norm[9] + norm[8]).astype(np.float32)
    loss = float(frequency_hz * np.trapezoid(h, b))
    if not np.isfinite(h).all() or not math.isfinite(loss):
        raise ValueError("model returned a non-finite prediction")

    in_hull = None
    if nominal_flux_t is not None:
        in_hull = measured_hull(
            material, frequency_hz, nominal_flux_t, bias_a_per_m, temperature_c, duty
        )
    warnings = []
    if in_hull is False:
        warnings.append("Operating point lies outside the historical measured-data convex hull.")
    if in_hull is None:
        warnings.append("Measured-domain hull is not applicable to an arbitrary waveform.")
    if loss < 0:
        warnings.append("Negative signed B-H loop integral; inspect waveform and operating point.")
    return {
        "model_id": "legacy-transformer-v1",
        "material_id": material,
        "frequency_hz": frequency_hz,
        "temperature_c": temperature_c,
        "bias_a_per_m": bias_a_per_m,
        "b_waveform_t": b.tolist(),
        "h_waveform_a_per_m": h.tolist(),
        "loss_w_per_m3": loss,
        "inside_measured_hull": in_hull,
        "units": {"flux_density": "T", "field_strength": "A/m", "frequency": "Hz", "loss": "W/m^3"},
        "warnings": warnings,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--material", required=True, choices=MATERIALS)
    parser.add_argument("--frequency-hz", type=float, required=True)
    parser.add_argument("--temperature-c", type=float, default=25.0)
    parser.add_argument("--bias-a-per-m", type=float, default=0.0)
    waveform = parser.add_mutually_exclusive_group()
    waveform.add_argument("--waveform", choices=("sine", "triangle"), default="sine")
    waveform.add_argument("--b-json", type=Path)
    parser.add_argument(
        "--flux-t", type=float, help="positive peak AC B in T for an ideal waveform"
    )
    parser.add_argument("--duty", type=float, default=0.5, help="triangle peak-time fraction (0,1)")
    args = parser.parse_args()
    if args.b_json is not None:
        if args.flux_t is not None:
            parser.error("--flux-t cannot be combined with --b-json")
        b = load_waveform_json(args.b_json)
        flux = duty = None
    else:
        if args.flux_t is None:
            parser.error("--flux-t is required for --waveform")
        b = make_waveform(args.waveform, args.flux_t, args.duty)
        flux = args.flux_t
        duty = args.duty if args.waveform == "triangle" else 0.5
    result = predict(
        args.material, b, args.frequency_hz, args.temperature_c, args.bias_a_per_m, flux, duty
    )
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()