File size: 10,305 Bytes
cb18693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""Shared data, normalization, metric, and serialization utilities."""

from __future__ import annotations

import json
import os
import random
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Sequence

import h5py
import numpy as np
import torch
from torch import Tensor
from torch.utils.data import Dataset
import yaml


PROJECT_ROOT = Path(__file__).resolve().parents[1]


def load_config(path: str | Path) -> dict[str, Any]:
    config_path = Path(path).expanduser().resolve()
    if not config_path.is_file():
        raise FileNotFoundError(f"configuration file not found: {config_path}")
    with config_path.open("r", encoding="utf-8") as handle:
        config = yaml.safe_load(handle)
    if not isinstance(config, dict):
        raise ValueError(f"configuration root must be a mapping: {config_path}")
    for section in ("experiment", "paths", "data", "normalization", "model"):
        if section not in config:
            raise KeyError(f"missing required config section: {section}")
    return config


def project_path(path: str | Path) -> Path:
    candidate = Path(path).expanduser()
    return candidate.resolve() if candidate.is_absolute() else (PROJECT_ROOT / candidate).resolve()


def data_file(config: dict[str, Any], filename_key: str) -> Path:
    directory = Path(config["paths"]["data_dir"]).expanduser()
    path = (directory / config["paths"][filename_key]).resolve()
    if not path.is_file():
        raise FileNotFoundError(f"data file not found: {path}")
    return path


def numeric_sample_ids(split: dict[str, int]) -> list[int]:
    start, stop = int(split["start"]), int(split["stop"])
    if start < 0 or stop <= start:
        raise ValueError(f"invalid half-open sample range [{start}, {stop})")
    return list(range(start, stop))


def set_reproducibility(seed: int, deterministic: bool = True) -> None:
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
    if deterministic:
        torch.backends.cudnn.benchmark = False
        torch.backends.cudnn.deterministic = True
        try:
            torch.use_deterministic_algorithms(True, warn_only=True)
        except TypeError:
            torch.use_deterministic_algorithms(True)


def select_device(requested: str) -> torch.device:
    requested = requested.lower()
    if requested == "auto":
        return torch.device("cuda" if torch.cuda.is_available() else "cpu")
    device = torch.device(requested)
    if device.type == "cuda" and not torch.cuda.is_available():
        raise RuntimeError("CUDA was requested but torch.cuda.is_available() is false")
    return device


@dataclass(frozen=True)
class MinMaxNormalizer:
    input_min: float
    input_max: float
    output_min: float
    output_max: float
    epsilon: float = 1.0e-12
    source: str = ""

    @classmethod
    def from_config(cls, config: dict[str, Any]) -> "MinMaxNormalizer":
        values = config["normalization"]
        result = cls(
            input_min=float(values["input_min"]),
            input_max=float(values["input_max"]),
            output_min=float(values["output_min"]),
            output_max=float(values["output_max"]),
            epsilon=float(values.get("epsilon", 1.0e-12)),
            source=str(values.get("source", "")),
        )
        result.validate()
        return result

    @classmethod
    def from_state(cls, state: dict[str, Any]) -> "MinMaxNormalizer":
        result = cls(**state)
        result.validate()
        return result

    def validate(self) -> None:
        values = (self.input_min, self.input_max, self.output_min, self.output_max)
        if not all(np.isfinite(value) for value in values):
            raise ValueError(f"normalization contains nonfinite values: {values}")
        if self.input_max - self.input_min <= self.epsilon:
            raise ValueError("input normalization range is zero or negative")
        if self.output_max - self.output_min <= self.epsilon:
            raise ValueError("output normalization range is zero or negative")

    def normalize_input(self, value: Tensor) -> Tensor:
        return (value - self.input_min) / (self.input_max - self.input_min)

    def normalize_output(self, value: Tensor) -> Tensor:
        return (value - self.output_min) / (self.output_max - self.output_min)

    def denormalize_input(self, value: Tensor) -> Tensor:
        return value * (self.input_max - self.input_min) + self.input_min

    def denormalize_output(self, value: Tensor) -> Tensor:
        return value * (self.output_max - self.output_min) + self.output_min

    def state_dict(self) -> dict[str, Any]:
        return asdict(self)


class NavierStokesH5Dataset(Dataset[tuple[Tensor, Tensor, int]]):
    """Lazy reader for the supplied ``Sample_i/{input,output}`` benchmark."""

    def __init__(
        self,
        path: str | Path,
        sample_ids: Sequence[int],
        normalizer: MinMaxNormalizer,
        input_key: str = "input",
        output_key: str = "output",
    ) -> None:
        self.path = Path(path).expanduser().resolve()
        if not self.path.is_file():
            raise FileNotFoundError(f"HDF5 file not found: {self.path}")
        self.sample_ids = [int(sample_id) for sample_id in sample_ids]
        if not self.sample_ids:
            raise ValueError("dataset sample_ids must not be empty")
        self.normalizer = normalizer
        self.input_key = input_key
        self.output_key = output_key
        self._handle: h5py.File | None = None
        self._validate_contract()

    def _validate_contract(self) -> None:
        with h5py.File(self.path, "r") as handle:
            for sample_id in (self.sample_ids[0], self.sample_ids[-1]):
                group_name = f"Sample_{sample_id}"
                if group_name not in handle:
                    raise KeyError(f"missing group {group_name} in {self.path}")
                group = handle[group_name]
                if self.input_key not in group or self.output_key not in group:
                    raise KeyError(
                        f"{group_name} must contain {self.input_key!r} and {self.output_key!r}"
                    )
                input_shape = tuple(group[self.input_key].shape)
                output_shape = tuple(group[self.output_key].shape)
                if len(input_shape) != 2 or input_shape != output_shape:
                    raise ValueError(
                        f"invalid field shapes in {group_name}: {input_shape}, {output_shape}"
                    )

    def _file(self) -> h5py.File:
        if self._handle is None:
            self._handle = h5py.File(self.path, "r")
        return self._handle

    def __len__(self) -> int:
        return len(self.sample_ids)

    def __getitem__(self, index: int) -> tuple[Tensor, Tensor, int]:
        sample_id = self.sample_ids[index]
        group = self._file()[f"Sample_{sample_id}"]
        input_array = np.asarray(group[self.input_key], dtype=np.float32)
        output_array = np.asarray(group[self.output_key], dtype=np.float32)
        if input_array.shape != output_array.shape or input_array.ndim != 2:
            raise ValueError(f"invalid shapes for Sample_{sample_id}")
        if not np.isfinite(input_array).all() or not np.isfinite(output_array).all():
            raise ValueError(f"nonfinite field values in Sample_{sample_id}")
        input_tensor = torch.from_numpy(input_array.copy()).unsqueeze(0)
        output_tensor = torch.from_numpy(output_array.copy()).unsqueeze(0)
        return (
            self.normalizer.normalize_input(input_tensor),
            self.normalizer.normalize_output(output_tensor),
            sample_id,
        )

    def __getstate__(self) -> dict[str, Any]:
        state = self.__dict__.copy()
        state["_handle"] = None
        return state

    def close(self) -> None:
        if self._handle is not None:
            self._handle.close()
            self._handle = None

    def __del__(self) -> None:
        # h5py modules may already be partially torn down during interpreter
        # shutdown.  Explicit ``close`` remains available for normal control
        # flow; finalization must never emit a spurious exception.
        try:
            self.close()
        except Exception:
            self._handle = None


def relative_l1_per_sample(prediction: Tensor, target: Tensor, epsilon: float) -> Tensor:
    if prediction.shape != target.shape:
        raise ValueError(
            f"prediction/target shape mismatch: {prediction.shape} versus {target.shape}"
        )
    reduce_dims = tuple(range(1, prediction.ndim))
    numerator = torch.sum(torch.abs(prediction - target), dim=reduce_dims)
    denominator = torch.sum(torch.abs(target), dim=reduce_dims).clamp_min(epsilon)
    return numerator / denominator


def atomic_json_dump(payload: Any, path: str | Path) -> None:
    destination = Path(path)
    destination.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(
        mode="w", encoding="utf-8", dir=destination.parent, suffix=".json", delete=False
    ) as handle:
        json.dump(payload, handle, indent=2, ensure_ascii=False)
        handle.write("\n")
        temporary = Path(handle.name)
    os.replace(temporary, destination)


def atomic_torch_save(payload: Any, path: str | Path) -> None:
    destination = Path(path)
    destination.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(dir=destination.parent, suffix=".pth", delete=False) as handle:
        temporary = Path(handle.name)
    try:
        torch.save(payload, temporary)
        os.replace(temporary, destination)
    finally:
        if temporary.exists():
            temporary.unlink()


def atomic_npz_save(path: str | Path, **arrays: np.ndarray) -> None:
    destination = Path(path)
    destination.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(dir=destination.parent, suffix=".npz", delete=False) as handle:
        temporary = Path(handle.name)
    try:
        np.savez_compressed(temporary, **arrays)
        os.replace(temporary, destination)
    finally:
        if temporary.exists():
            temporary.unlink()