Herrprofessor commited on
Commit
aed74e2
·
verified ·
1 Parent(s): e55d2d4

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. README.md +53 -3
  2. __init__.py +3 -0
  3. config.json +110 -0
  4. example.py +37 -0
  5. model.pt +3 -0
  6. neon_slab.py +284 -0
README.md CHANGED
@@ -1,3 +1,53 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Neon Slab Predictor
2
+
3
+ A fast neural network predictor for dielectric slab optical response, trained on FDFD simulation data from the Neon solver.
4
+
5
+ ## What it does
6
+ Give it a dielectric slab configuration. Get optical response back in milliseconds.
7
+
8
+ Inputs: slab thickness (um), relative permittivity real part, wavelength (um)
9
+ Outputs: normalized transmission, normalized reflection, normalized peak intensity
10
+
11
+ ## Install and use
12
+ ```python
13
+ from neon import Neon
14
+ model = Neon.from_pretrained()
15
+ result = model.predict(
16
+ thickness=0.30, epsilon_real=2.25, wavelength=0.80
17
+ )
18
+ print(result)
19
+ ```
20
+
21
+ ## What this is
22
+ This model is trained on 2D scalar FDFD simulation data from the Neon benchmark solver. It covers one geometry class: a rectangular dielectric slab at normal incidence in vacuum. Within that class it is a fast, usable predictor.
23
+
24
+ ## What this is not
25
+ This model does not generalize to metasurfaces, waveguides, photonic crystals, multilayer stacks, oblique incidence, dispersive materials, or full-vector Maxwell problems. If your structure is not a simple dielectric slab, this model will give you a number that means nothing.
26
+
27
+ ## Performance
28
+ Saved single-model test MAE:
29
+ - transmission: 0.053586
30
+ - reflection: 0.055240
31
+ - intensity: 0.234785
32
+
33
+ Saved OOD degradation warning:
34
+ - benchmark-facing Model C ensemble OOD transmission MAE: 0.107476
35
+ - benchmark-facing Model C ensemble OOD reflection MAE: 0.105841
36
+ - benchmark-facing Model C ensemble OOD intensity MAE: 0.174486
37
+ - overall OOD mean MAE: 0.129268
38
+
39
+ Note: the repository does not currently store a single-model benchmark-facing OOD summary. The OOD warning above comes from the saved 5-member Model C ensemble evaluation.
40
+
41
+ ## Training data range
42
+ - thickness: 0.12 to 0.46 um
43
+ - epsilon_real: 1.4 to 4.0
44
+ - wavelength: 0.72 to 0.92 um
45
+ - inputs outside this range trigger validation; `predict()` raises a validation error by default and can warn instead with `warn_only=True`
46
+
47
+ ## Companion paper
48
+ Toward Trustworthy Surrogate Models for Electromagnetic Simulation: A Systematic Evaluation of Physics-Informed Training, Uncertainty, and Active Learning on a Controlled Benchmark
49
+
50
+ in preparation
51
+
52
+ ## Citation
53
+ TBD
__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .neon_slab import Neon
2
+
3
+ __all__ = ["Neon"]
config.json ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "Neon Slab Predictor",
3
+ "model_family": "Neon Model C benchmark-facing slab predictor",
4
+ "paper_title": "Toward Trustworthy Surrogate Models for Electromagnetic Simulation: A Systematic Evaluation of Physics-Informed Training, Uncertainty, and Active Learning on a Controlled Benchmark",
5
+ "paper_status": "in preparation",
6
+ "solver": {
7
+ "name": "Neon",
8
+ "type": "2D scalar frequency-domain Helmholtz solver (FDFD)",
9
+ "benchmark": "rectangular dielectric slab at normal incidence in vacuum",
10
+ "source_type": "plane_wave_line",
11
+ "absorber_mode": "pml",
12
+ "base_case_config": "examples/configs/case_b_dielectric_slab.json"
13
+ },
14
+ "architecture": {
15
+ "input_dim": 3,
16
+ "output_dim": 3,
17
+ "latent_dim": 48,
18
+ "encoder_hidden_sizes": [
19
+ 64,
20
+ 64
21
+ ],
22
+ "scalar_hidden_sizes": [
23
+ 64,
24
+ 32
25
+ ],
26
+ "activation": "relu",
27
+ "dropout": 0.0
28
+ },
29
+ "feature_columns": [
30
+ "thickness",
31
+ "epsilon_real",
32
+ "wavelength"
33
+ ],
34
+ "output_columns": [
35
+ "benchmark_normalized_transmission",
36
+ "benchmark_normalized_reflection",
37
+ "normalized_peak_intensity"
38
+ ],
39
+ "normalization": {
40
+ "inputs": {
41
+ "mean": [
42
+ 0.2949636746129761,
43
+ 2.5533463753669827,
44
+ 0.8200000000000003
45
+ ],
46
+ "std": [
47
+ 0.10689016159170563,
48
+ 0.7229321705774375,
49
+ 0.06831300510639735
50
+ ]
51
+ },
52
+ "outputs": {
53
+ "mean": [
54
+ 0.8727000464677505,
55
+ 0.12729995353224952,
56
+ 1.1352850443134685
57
+ ],
58
+ "std": [
59
+ 0.07973106519131831,
60
+ 0.07973106519131834,
61
+ 0.2146406330016548
62
+ ]
63
+ }
64
+ },
65
+ "training_data_range": {
66
+ "thickness": {
67
+ "min": 0.12,
68
+ "max": 0.46,
69
+ "units": "um"
70
+ },
71
+ "epsilon_real": {
72
+ "min": 1.4,
73
+ "max": 4.0,
74
+ "units": "relative permittivity"
75
+ },
76
+ "wavelength": {
77
+ "min": 0.72,
78
+ "max": 0.92,
79
+ "units": "um",
80
+ "values": [
81
+ 0.72,
82
+ 0.76,
83
+ 0.8,
84
+ 0.84,
85
+ 0.88,
86
+ 0.92
87
+ ]
88
+ }
89
+ },
90
+ "target_mapping": {
91
+ "transmission": "benchmark_normalized_transmission",
92
+ "reflection": "benchmark_normalized_reflection",
93
+ "intensity": "normalized_peak_intensity"
94
+ },
95
+ "metrics": {
96
+ "single_model_test_mae": {
97
+ "transmission": 0.05358564214376436,
98
+ "reflection": 0.05523978610544433,
99
+ "intensity": 0.2347853783251872
100
+ },
101
+ "single_model_test_overall_mean_mae": 0.11453693552479864,
102
+ "benchmark_model_c_ensemble_ood_warning_mae": {
103
+ "transmission": 0.10747572146765737,
104
+ "reflection": 0.10584133202889677,
105
+ "intensity": 0.17448644359361698
106
+ },
107
+ "benchmark_model_c_ensemble_ood_warning_overall_mean_mae": 0.12926783236339037,
108
+ "ood_warning_note": "The repository does not currently store a single-model benchmark-facing OOD summary. The OOD warning values here come from the saved 5-member Model C ensemble evaluation."
109
+ }
110
+ }
example.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ try:
7
+ from neon import Neon
8
+ except ImportError:
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
10
+ from neon import Neon
11
+
12
+
13
+ def main() -> None:
14
+ model = Neon.from_pretrained()
15
+ cases = [
16
+ {"thickness": 0.12, "epsilon_real": 2.05, "wavelength": 0.84},
17
+ {"thickness": 0.28, "epsilon_real": 2.56, "wavelength": 0.80},
18
+ {"thickness": 0.46, "epsilon_real": 4.00, "wavelength": 0.84},
19
+ ]
20
+
21
+ for index, case in enumerate(cases, start=1):
22
+ result = model.predict(**case)
23
+ print(
24
+ f"Case {index}: thickness={case['thickness']:.2f} um, "
25
+ f"epsilon_real={case['epsilon_real']:.2f}, wavelength={case['wavelength']:.2f} um"
26
+ )
27
+ print(
28
+ f" transmission={result['transmission']:.6f}, "
29
+ f"reflection={result['reflection']:.6f}, intensity={result['intensity']:.6f}"
30
+ )
31
+
32
+ # For verification, rerun the matching slab configuration with the direct Neon solver
33
+ # and compare the resulting summary.csv transmission, reflection, and peak intensity values.
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a20a76b9f656f366dc8675e0fadf152a39355a8d3d14e591f9ca8ef1a4718bdb
3
+ size 119597
neon_slab.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import warnings
5
+ from pathlib import Path
6
+ from typing import Any, Mapping
7
+
8
+ import numpy as np
9
+ import torch
10
+ from torch import nn
11
+
12
+ _INPUT_COLUMNS = ("thickness", "epsilon_real", "wavelength")
13
+ _OUTPUT_COLUMNS = ("transmission", "reflection", "intensity")
14
+
15
+
16
+ def _activation_layer(name: str) -> type[nn.Module]:
17
+ normalized = name.lower()
18
+ activations: dict[str, type[nn.Module]] = {
19
+ "relu": nn.ReLU,
20
+ "tanh": nn.Tanh,
21
+ "gelu": nn.GELU,
22
+ }
23
+ if normalized not in activations:
24
+ raise ValueError(f"Unsupported activation '{name}'.")
25
+ return activations[normalized]
26
+
27
+
28
+ def _make_mlp(
29
+ *,
30
+ input_dim: int,
31
+ output_dim: int,
32
+ hidden_sizes: list[int],
33
+ activation: str,
34
+ dropout: float,
35
+ ) -> nn.Sequential:
36
+ if not hidden_sizes:
37
+ raise ValueError("Hidden sizes must not be empty.")
38
+
39
+ activation_layer = _activation_layer(activation)
40
+ layer_sizes = [input_dim, *hidden_sizes, output_dim]
41
+ layers: list[nn.Module] = []
42
+ for index in range(len(layer_sizes) - 2):
43
+ layers.append(nn.Linear(layer_sizes[index], layer_sizes[index + 1]))
44
+ layers.append(activation_layer())
45
+ if dropout > 0.0:
46
+ layers.append(nn.Dropout(dropout))
47
+ layers.append(nn.Linear(layer_sizes[-2], layer_sizes[-1]))
48
+ return nn.Sequential(*layers)
49
+
50
+
51
+ class _ScalarNeonNet(nn.Module):
52
+ def __init__(
53
+ self,
54
+ *,
55
+ input_dim: int,
56
+ output_dim: int,
57
+ latent_dim: int,
58
+ encoder_hidden_sizes: list[int],
59
+ scalar_hidden_sizes: list[int],
60
+ activation: str,
61
+ dropout: float,
62
+ ) -> None:
63
+ super().__init__()
64
+ self.encoder = _make_mlp(
65
+ input_dim=input_dim,
66
+ output_dim=latent_dim,
67
+ hidden_sizes=encoder_hidden_sizes,
68
+ activation=activation,
69
+ dropout=dropout,
70
+ )
71
+ self.scalar_head = _make_mlp(
72
+ input_dim=latent_dim,
73
+ output_dim=output_dim,
74
+ hidden_sizes=scalar_hidden_sizes,
75
+ activation=activation,
76
+ dropout=dropout,
77
+ )
78
+
79
+ def forward(self, features: torch.Tensor) -> torch.Tensor:
80
+ return self.scalar_head(self.encoder(features))
81
+
82
+
83
+ class Neon:
84
+ def __init__(
85
+ self,
86
+ *,
87
+ model: _ScalarNeonNet,
88
+ config: dict[str, Any],
89
+ device: str,
90
+ ) -> None:
91
+ self.model = model.to(device)
92
+ self.model.eval()
93
+ self.config = config
94
+ self.device = device
95
+ self.input_mean = np.asarray(config["normalization"]["inputs"]["mean"], dtype=np.float64)
96
+ self.input_std = np.asarray(config["normalization"]["inputs"]["std"], dtype=np.float64)
97
+ self.output_mean = np.asarray(config["normalization"]["outputs"]["mean"], dtype=np.float64)
98
+ self.output_std = np.asarray(config["normalization"]["outputs"]["std"], dtype=np.float64)
99
+ training_range = config["training_data_range"]
100
+ self.training_min = np.asarray([training_range[name]["min"] for name in _INPUT_COLUMNS], dtype=np.float64)
101
+ self.training_max = np.asarray([training_range[name]["max"] for name in _INPUT_COLUMNS], dtype=np.float64)
102
+
103
+ @classmethod
104
+ def from_pretrained(
105
+ cls,
106
+ model_dir: str | Path | None = None,
107
+ *,
108
+ device: str | None = None,
109
+ ) -> "Neon":
110
+ base_dir = Path(model_dir).expanduser().resolve() if model_dir else Path(__file__).resolve().parent
111
+ config_path = base_dir / "config.json"
112
+ model_path = base_dir / "model.pt"
113
+
114
+ if not config_path.exists():
115
+ raise FileNotFoundError(f"Missing config.json at {config_path}.")
116
+ if not model_path.exists():
117
+ raise FileNotFoundError(f"Missing model.pt at {model_path}.")
118
+
119
+ config = json.loads(config_path.read_text())
120
+ resolved_device = _resolve_device(device)
121
+ checkpoint = torch.load(model_path, map_location=resolved_device)
122
+ state_dict = checkpoint["state_dict"] if isinstance(checkpoint, dict) and "state_dict" in checkpoint else checkpoint
123
+
124
+ architecture = config["architecture"]
125
+ model = _ScalarNeonNet(
126
+ input_dim=int(architecture["input_dim"]),
127
+ output_dim=int(architecture["output_dim"]),
128
+ latent_dim=int(architecture["latent_dim"]),
129
+ encoder_hidden_sizes=[int(value) for value in architecture["encoder_hidden_sizes"]],
130
+ scalar_hidden_sizes=[int(value) for value in architecture["scalar_hidden_sizes"]],
131
+ activation=str(architecture["activation"]),
132
+ dropout=float(architecture["dropout"]),
133
+ )
134
+
135
+ scalar_state = {
136
+ key: value
137
+ for key, value in state_dict.items()
138
+ if key.startswith("encoder.") or key.startswith("scalar_head.")
139
+ }
140
+ missing, unexpected = model.load_state_dict(scalar_state, strict=False)
141
+ if missing:
142
+ raise RuntimeError(f"Checkpoint is missing scalar inference weights: {sorted(missing)}")
143
+ if unexpected:
144
+ raise RuntimeError(f"Checkpoint contains unexpected scalar inference weights: {sorted(unexpected)}")
145
+
146
+ return cls(model=model, config=config, device=resolved_device)
147
+
148
+ def predict(
149
+ self,
150
+ inputs: Any = None,
151
+ *,
152
+ thickness: float | None = None,
153
+ epsilon_real: float | None = None,
154
+ epsilon: float | None = None,
155
+ wavelength: float | None = None,
156
+ warn_only: bool = False,
157
+ ) -> dict[str, float] | list[dict[str, float]]:
158
+ values, single_input = self._coerce_inputs(
159
+ inputs,
160
+ thickness=thickness,
161
+ epsilon_real=epsilon_real,
162
+ epsilon=epsilon,
163
+ wavelength=wavelength,
164
+ )
165
+ self._validate_inputs(values, warn_only=warn_only)
166
+ normalized = (values - self.input_mean) / self.input_std
167
+
168
+ with torch.inference_mode():
169
+ prediction_norm = self.model(
170
+ torch.as_tensor(normalized, dtype=torch.float32, device=self.device)
171
+ ).cpu().numpy()
172
+
173
+ prediction = prediction_norm * self.output_std + self.output_mean
174
+ records = [
175
+ {
176
+ "transmission": float(row[0]),
177
+ "reflection": float(row[1]),
178
+ "intensity": float(row[2]),
179
+ }
180
+ for row in prediction
181
+ ]
182
+ return records[0] if single_input else records
183
+
184
+ def _coerce_inputs(
185
+ self,
186
+ inputs: Any,
187
+ *,
188
+ thickness: float | None,
189
+ epsilon_real: float | None,
190
+ epsilon: float | None,
191
+ wavelength: float | None,
192
+ ) -> tuple[np.ndarray, bool]:
193
+ has_keyword_inputs = any(value is not None for value in (thickness, epsilon_real, epsilon, wavelength))
194
+ if inputs is not None and has_keyword_inputs:
195
+ raise ValueError("Pass either `inputs` or keyword arguments, not both.")
196
+
197
+ if inputs is None:
198
+ if epsilon_real is not None and epsilon is not None:
199
+ raise ValueError("Use either `epsilon_real` or `epsilon`, not both.")
200
+ epsilon_value = epsilon_real if epsilon_real is not None else epsilon
201
+ if thickness is None or epsilon_value is None or wavelength is None:
202
+ raise ValueError(
203
+ "Expected thickness, epsilon_real (or epsilon), and wavelength when `inputs` is not provided."
204
+ )
205
+ return self._mapping_to_array(
206
+ {
207
+ "thickness": thickness,
208
+ "epsilon_real": epsilon_value,
209
+ "wavelength": wavelength,
210
+ }
211
+ )
212
+
213
+ if isinstance(inputs, Mapping):
214
+ return self._mapping_to_array(inputs)
215
+
216
+ values = np.asarray(inputs, dtype=np.float64)
217
+ if values.ndim == 1:
218
+ if values.shape[0] != len(_INPUT_COLUMNS):
219
+ raise ValueError(
220
+ f"Expected a 3-element input array ordered as {list(_INPUT_COLUMNS)}, received shape {values.shape}."
221
+ )
222
+ return values.reshape(1, -1), True
223
+ if values.ndim == 2 and values.shape[1] == len(_INPUT_COLUMNS):
224
+ return values, False
225
+ raise ValueError(
226
+ f"Expected an input array with shape (3,) or (N, 3) ordered as {list(_INPUT_COLUMNS)}, "
227
+ f"received shape {values.shape}."
228
+ )
229
+
230
+ def _mapping_to_array(self, mapping: Mapping[str, Any]) -> tuple[np.ndarray, bool]:
231
+ if "epsilon_real" in mapping and "epsilon" in mapping:
232
+ raise ValueError("Use either `epsilon_real` or `epsilon`, not both.")
233
+ epsilon_value = mapping["epsilon_real"] if "epsilon_real" in mapping else mapping.get("epsilon")
234
+ missing = [name for name in ("thickness", "wavelength") if name not in mapping]
235
+ if epsilon_value is None:
236
+ missing.append("epsilon_real")
237
+ if missing:
238
+ raise ValueError(f"Missing required input keys: {', '.join(missing)}.")
239
+
240
+ thickness = np.asarray(mapping["thickness"], dtype=np.float64)
241
+ epsilon_real = np.asarray(epsilon_value, dtype=np.float64)
242
+ wavelength = np.asarray(mapping["wavelength"], dtype=np.float64)
243
+ broadcasted = np.broadcast_arrays(thickness, epsilon_real, wavelength)
244
+ values = np.stack([item.reshape(-1) for item in broadcasted], axis=1)
245
+ single_input = values.shape[0] == 1 and all(item.ndim == 0 for item in (thickness, epsilon_real, wavelength))
246
+ return values, single_input
247
+
248
+ def _validate_inputs(self, values: np.ndarray, *, warn_only: bool) -> None:
249
+ messages: list[str] = []
250
+ for index, name in enumerate(_INPUT_COLUMNS):
251
+ lower = float(self.training_min[index])
252
+ upper = float(self.training_max[index])
253
+ out_of_range = (values[:, index] < lower) | (values[:, index] > upper)
254
+ if not np.any(out_of_range):
255
+ continue
256
+
257
+ units = self.config["training_data_range"][name]["units"]
258
+ bad_values = values[out_of_range, index]
259
+ preview = ", ".join(f"{value:.6g}" for value in bad_values[:3])
260
+ if bad_values.shape[0] > 3:
261
+ preview = f"{preview}, ..."
262
+ messages.append(
263
+ f"{name} values [{preview}] are outside the training range [{lower:.6g}, {upper:.6g}] {units}."
264
+ )
265
+
266
+ if not messages:
267
+ return
268
+
269
+ message = " ".join(messages)
270
+ if warn_only:
271
+ warnings.warn(message, RuntimeWarning, stacklevel=2)
272
+ return
273
+ raise ValueError(message)
274
+
275
+
276
+ def _resolve_device(device: str | None) -> str:
277
+ if device is None:
278
+ return "cuda" if torch.cuda.is_available() else "cpu"
279
+ if device == "cuda" and not torch.cuda.is_available():
280
+ return "cpu"
281
+ return device
282
+
283
+
284
+ __all__ = ["Neon"]