File size: 6,433 Bytes
3e77c56 | 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 | """Geo-FNO Elasticity dataset.
Reconciled against the authoritative reference files (see ``docs/RECONCILIATION.md``):
- Geo-FNO ``elasticity/elas_geofno.py`` (raw .npy axis layout, filenames)
- Transolver ``exp_elas.py`` + ``utils/normalizer.py`` (split, output normalizer, loss scale)
Raw array layout (the **sample axis is LAST** in every raw ``.npy``):
Random_UnitCell_sigma_10.npy : (972, 2000) -> per-node von Mises stress (target)
Random_UnitCell_XY_10.npy : (972, 2, 2000) -> node coordinates (input)
Random_UnitCell_rr_10.npy : (42, 2000) -> void-boundary radii (geometry params; OOD)
Split (verbatim from ``exp_elas.py``): train = first ``ntrain`` samples, test = LAST ``ntest``,
out of 2000 total. The middle samples are unused.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
import numpy as np
import torch
from torch.utils.data import Dataset
SIGMA_FILE = "Random_UnitCell_sigma_10.npy"
XY_FILE = "Random_UnitCell_XY_10.npy"
RR_FILE = "Random_UnitCell_rr_10.npy"
N_NODES = 972
N_TOTAL = 2000
class UnitTransformer:
"""Global scalar z-score normalizer for the stress target.
Verbatim behavior of Transolver ``utils/normalizer.py::UnitTransformer``:
mean/std are reduced over dims ``(0, 1)`` (samples and nodes), giving a single
scalar mean and std. ``decode`` is applied to predictions before the metric.
"""
def __init__(self, x: torch.Tensor):
self.mean = x.mean(dim=(0, 1), keepdim=True)
self.std = x.std(dim=(0, 1), keepdim=True) + 1e-8
def to(self, device) -> "UnitTransformer":
self.mean = self.mean.to(device)
self.std = self.std.to(device)
return self
def encode(self, x: torch.Tensor) -> torch.Tensor:
return (x - self.mean) / self.std
def decode(self, x: torch.Tensor) -> torch.Tensor:
return x * self.std + self.mean
def _find_file(data_dir: str, name: str) -> str:
"""Locate ``name`` under ``data_dir`` (gdown may nest files in a subfolder)."""
direct = os.path.join(data_dir, name)
if os.path.isfile(direct):
return direct
for root, _dirs, files in os.walk(data_dir):
if name in files:
return os.path.join(root, name)
raise FileNotFoundError(
f"Could not find {name} under {data_dir!r}. "
f"Run `make data` (or python -m stress_operator.data.download --out {data_dir})."
)
def load_raw_arrays(data_dir: str):
"""Load and reorient the raw arrays so the sample axis is first.
Returns float32 tensors:
coords : (N_TOTAL, 972, 2)
sigma : (N_TOTAL, 972) physical (de-normalized) stress
rr : (N_TOTAL, 42) geometry params (may be absent -> None)
"""
sigma_np = np.load(_find_file(data_dir, SIGMA_FILE)) # (972, 2000)
xy_np = np.load(_find_file(data_dir, XY_FILE)) # (972, 2, 2000)
sigma = torch.tensor(sigma_np, dtype=torch.float).permute(1, 0).contiguous() # (2000, 972)
coords = torch.tensor(xy_np, dtype=torch.float).permute(2, 0, 1).contiguous() # (2000, 972, 2)
rr: Optional[torch.Tensor] = None
try:
rr_np = np.load(_find_file(data_dir, RR_FILE)) # (42, 2000)
rr = torch.tensor(rr_np, dtype=torch.float).permute(1, 0).contiguous() # (2000, 42)
except FileNotFoundError:
rr = None
return coords, sigma, rr
@dataclass
class Splits:
train_coords: torch.Tensor
train_sigma: torch.Tensor # physical
test_coords: torch.Tensor
test_sigma: torch.Tensor # physical
normalizer: UnitTransformer
train_rr: Optional[torch.Tensor] = None
test_rr: Optional[torch.Tensor] = None
def build_splits(data_dir: str, ntrain: int = 1000, ntest: int = 200) -> Splits:
"""Build the train/test split exactly as ``exp_elas.py`` does.
train = first ``ntrain``; test = LAST ``ntest``. The normalizer is fit on the
(physical) train stress only.
"""
coords, sigma, rr = load_raw_arrays(data_dir)
train_coords = coords[:ntrain]
train_sigma = sigma[:ntrain]
test_coords = coords[-ntest:]
test_sigma = sigma[-ntest:]
normalizer = UnitTransformer(train_sigma)
train_rr = rr[:ntrain] if rr is not None else None
test_rr = rr[-ntest:] if rr is not None else None
return Splits(
train_coords=train_coords,
train_sigma=train_sigma,
test_coords=test_coords,
test_sigma=test_sigma,
normalizer=normalizer,
train_rr=train_rr,
test_rr=test_rr,
)
def build_splits_from_indices(data_dir: str, train_idx, test_idx) -> Splits:
"""Build a split from explicit sample indices over all 2000 samples (used by the OOD eval).
The normalizer is fit on the (physical) train stress only.
"""
coords, sigma, rr = load_raw_arrays(data_dir)
train_idx = torch.as_tensor(np.asarray(train_idx), dtype=torch.long)
test_idx = torch.as_tensor(np.asarray(test_idx), dtype=torch.long)
train_sigma = sigma[train_idx]
normalizer = UnitTransformer(train_sigma)
return Splits(
train_coords=coords[train_idx],
train_sigma=train_sigma,
test_coords=coords[test_idx],
test_sigma=sigma[test_idx],
normalizer=normalizer,
train_rr=(rr[train_idx] if rr is not None else None),
test_rr=(rr[test_idx] if rr is not None else None),
)
class ElasticityDataset(Dataset):
"""Yields ``(coords, sigma)`` per sample.
``coords`` : (972, 2) node coordinates (model input)
``sigma`` : (972, 1) **physical** stress target
Normalization is intentionally *not* applied here: the training loop predicts in
normalized space and decodes before the relative-L2 loss (identical to ``exp_elas.py``;
see ``docs/RECONCILIATION.md`` §4). Keeping physical targets in the dataset makes the
de-normalize-before-metric contract explicit and impossible to forget.
"""
def __init__(self, coords: torch.Tensor, sigma: torch.Tensor):
assert coords.shape[0] == sigma.shape[0]
self.coords = coords
# ensure (n, 972, 1)
self.sigma = sigma if sigma.dim() == 3 else sigma.unsqueeze(-1)
def __len__(self) -> int:
return self.coords.shape[0]
def __getitem__(self, idx: int):
return self.coords[idx], self.sigma[idx]
|