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]