File size: 10,466 Bytes
f4a39ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Generate OneScience-compatible synthetic ERA5 HDF5 data."""

from __future__ import annotations

import argparse
from pathlib import Path

import h5py
import numpy as np

try:
    from common import PROJECT_ROOT, SYNTHETIC_GENERATOR_VERSION, channel_order, load_config, resolve_path, write_json
except ModuleNotFoundError:  # supports ``python -m scripts.fake_data``
    from scripts.common import PROJECT_ROOT, SYNTHETIC_GENERATOR_VERSION, channel_order, load_config, resolve_path, write_json


def make_field(name: str, step: int, year: int, lat: np.ndarray, lon: np.ndarray, rng: np.random.Generator) -> np.ndarray:
    latr, lonr = np.deg2rad(lat)[:, None], np.deg2rad(lon)[None, :]
    wave = np.cos(latr) * np.sin(lonr + step * 0.05)
    noise = rng.normal(0, 1, wave.shape).astype(np.float32)
    if name == "sea_ice_cover":
        return np.clip(0.55 - 0.45 * np.cos(latr) + 0.03 * noise, 0, 1).astype(np.float32)
    if name == "sea_surface_temperature":
        return (273.15 + 26 * np.cos(latr) + 1.5 * wave + 0.1 * noise).astype(np.float32)
    base, _, suffix = name.rpartition("_")
    level = int(suffix)
    ratio = level / 1000.0
    if base == "geopotential":
        # Hydrostatic log-pressure profile calibrated against the bundled ERA5
        # statistics (about 464 km2/s2 at 1 hPa and 0.7 km2/s2 at 1000 hPa).
        vertical = 733.0 + 67000.0 * np.log(1000.0 / level)
        return (vertical + 1800.0 * (1.0 - ratio) * np.sin(latr) ** 2 + 120.0 * wave + noise).astype(np.float32)
    if base == "temperature":
        # Piecewise standard-atmosphere profile captures stratospheric warming;
        # a monotone 220->288 K profile is not physically valid above 10 hPa.
        anchors_hpa = np.asarray([1.0, 10.0, 100.0, 500.0, 1000.0])
        anchors_k = np.asarray([261.0, 229.0, 207.0, 253.0, 281.0])
        vertical = np.interp(np.log(level), np.log(anchors_hpa), anchors_k)
        return (vertical + 4.0 * wave + 0.1 * noise).astype(np.float32)
    if base == "specific_humidity":
        vertical = 4.0e-6 + 7.0e-3 * ratio**3
        return np.maximum((vertical * (0.8 + 0.2 * np.cos(latr)) + 1e-7 * noise), 1e-8).astype(np.float32)
    if base == "u_component_of_wind":
        return (12 * (1 - ratio) * wave + 0.2 * noise).astype(np.float32)
    if base == "v_component_of_wind":
        return (8 * (1 - ratio) * np.sin(2 * latr) * np.cos(lonr) + 0.2 * noise).astype(np.float32)
    if name.startswith("specific_cloud_ice_water_content_"):
        profile = 8e-6 * np.exp(-0.5 * ((level - 450.0) / 180.0) ** 2)
        return np.maximum(profile * (0.7 + 0.3 * np.cos(latr)) + 1e-7 * noise, 0).astype(np.float32)
    if name.startswith("specific_cloud_liquid_water_content_"):
        profile = 1.2e-5 * np.exp(-0.5 * ((level - 750.0) / 160.0) ** 2)
        return np.maximum(profile * (0.7 + 0.3 * np.cos(latr)) + 1e-7 * noise, 0).astype(np.float32)
    raise ValueError(f"Unsupported channel: {name}")


def generate_year(
    path: Path,
    channels: list[str],
    timesteps: int,
    height: int,
    width: int,
    year: int,
    seed: int,
    time_step_hours: int,
) -> dict:
    lat = np.linspace(90, -90, height, dtype=np.float32)
    lon = np.linspace(0, 360, width, endpoint=False, dtype=np.float32)
    path.parent.mkdir(parents=True, exist_ok=True)
    sums = np.zeros(len(channels), dtype=np.float64)
    sq = np.zeros(len(channels), dtype=np.float64)
    with h5py.File(path, "w") as handle:
        fields = handle.create_dataset("fields", shape=(timesteps, len(channels), height, width), dtype="f4", chunks=(1, 1, min(height, 32), min(width, 64)), compression="gzip", compression_opts=1)
        fields.attrs["variables"] = np.asarray(channels, dtype=h5py.string_dtype("utf-8"))
        fields.attrs["time_step"] = time_step_hours
        fields.attrs["synthetic"] = True
        fields.attrs["generator_version"] = SYNTHETIC_GENERATOR_VERSION
        for t in range(timesteps):
            rng = np.random.default_rng(seed + year * 1009 + t)
            for i, name in enumerate(channels):
                value = make_field(name, t, year, lat, lon, rng)
                fields[t, i] = value
                sums[i] += value.sum(dtype=np.float64)
                sq[i] += np.square(value, dtype=np.float64).sum(dtype=np.float64)
        count = timesteps * height * width
        means = sums / count
        stds = np.sqrt(np.maximum(sq / count - means**2, 1e-12))
        handle.create_dataset("global_means", data=means[None, :, None, None].astype("f4"))
        handle.create_dataset("global_stds", data=stds[None, :, None, None].astype("f4"))
    return {"path": str(path), "shape": [timesteps, len(channels), height, width], "mean_std_min": float(stds.min())}


def generate_static(path: Path, height: int, width: int) -> dict:
    """Generate deterministic ERA5-like static topography and land mask.

    These variables are auxiliary model inputs rather than flattened dynamic
    channels.  Values use the physical units expected by the official Gin
    profiles: geopotential at surface in m² s⁻² and land-sea mask in [0, 1].
    """
    import xarray as xr

    lat = np.linspace(90.0, -90.0, height, dtype=np.float32)
    lon = np.linspace(0.0, 360.0, width, endpoint=False, dtype=np.float32)
    latr, lonr = np.deg2rad(lat)[:, None], np.deg2rad(lon)[None, :]
    # Smooth continent-like mask and non-negative terrain height.  This is
    # intentionally synthetic; real ERA5 static fields should be preferred.
    mask = (0.5 + 0.5 * np.sin(2.0 * latr) * np.cos(3.0 * lonr) > 0.52).astype(np.float32)
    elevation_m = np.maximum(
        0.0,
        2200.0 * mask * (0.35 + 0.65 * np.cos(latr) ** 2)
        + 250.0 * np.sin(latr) ** 2,
    ).astype(np.float32)
    geopotential = (9.80665 * elevation_m).astype(np.float32)
    ds = xr.Dataset(
        {
            "geopotential_at_surface": (("longitude", "latitude"), geopotential.T),
            "land_sea_mask": (("longitude", "latitude"), mask.T),
        },
        coords={"latitude": lat, "longitude": lon},
        attrs={"synthetic": "true", "source": "neuralgcm_develop.fake_data"},
    )
    ds["geopotential_at_surface"].attrs["units"] = "m**2 s**-2"
    ds["land_sea_mask"].attrs["units"] = "dimensionless"
    path.parent.mkdir(parents=True, exist_ok=True)
    ds.to_netcdf(path)
    return {"path": str(path), "shape": [height, width], "variables": list(ds.data_vars)}


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
    parser.add_argument("--output-dir")
    parser.add_argument("--years", nargs="*", type=int)
    parser.add_argument("--timesteps", type=int)
    parser.add_argument(
        "--forecast-steps",
        type=int,
        help="number of future 6-hour frames; defaults to data.virtual.forecast_steps",
    )
    parser.add_argument("--height", type=int)
    parser.add_argument("--width", type=int)
    args = parser.parse_args()
    config = load_config(args.config)
    out = resolve_path(args.output_dir or config["data"]["data_dir"], args.config)
    virtual = config["data"]["virtual"]
    time_step_hours = int(config["data"].get("time_step_hours", 6))
    if time_step_hours <= 0:
        raise ValueError("data.time_step_hours must be positive")
    years = args.years or sorted(set(config["data"]["train_years"] + config["data"]["val_years"] + config["data"]["test_years"]))
    channels = channel_order(config)
    # The explicit value always wins, followed by the virtual-data setting,
    # then the inference default. The project defaults to a compact two-day
    # window; 60 six-hour steps exercise the full official 15-day capability.
    forecast_steps = int(
        args.forecast_steps
        if args.forecast_steps is not None
        else virtual.get("forecast_steps", config.get("inference", {}).get("prediction_steps", 8))
    )
    if forecast_steps <= 0:
        raise ValueError("--forecast-steps must be positive")
    input_steps = int(config["data"].get("input_steps", 1))
    configured_timesteps = int(virtual["timesteps_per_year"])
    # The synthetic file contains one initial frame plus the requested future
    # frames. Explicit --timesteps remains available for tiny smoke tests.
    explicit_timesteps = args.timesteps is not None
    timesteps = int(args.timesteps) if explicit_timesteps else max(
        configured_timesteps, input_steps + forecast_steps
    )
    if timesteps <= 0:
        raise ValueError("--timesteps must be positive")
    if not explicit_timesteps and timesteps < input_steps + forecast_steps:
        raise ValueError(
            f"timesteps={timesteps} is too short for input_steps={input_steps} "
            f"and forecast_steps={forecast_steps}; need at least {input_steps + forecast_steps}"
        )
    complete_window = timesteps >= input_steps + forecast_steps
    forecast_horizon_days = forecast_steps * time_step_hours / 24
    if explicit_timesteps and not complete_window:
        print(
            f"Warning: timesteps={timesteps} provides only a smoke window; "
            f"{input_steps + forecast_steps} frames are required for the full "
            f"{forecast_horizon_days:g}-day configured forecast."
        )
    records = [
        generate_year(
            out / "data" / f"{year}.h5",
            channels,
            timesteps,
            args.height or virtual["height"],
            args.width or virtual["width"],
            year,
            int(virtual["seed"]),
            time_step_hours,
        )
        for year in years
    ]
    static_record = generate_static(out / "static.nc", args.height or virtual["height"], args.width or virtual["width"])
    write_json(out / "metadata" / "dataset_card.json", {"name": "neuralgcm-synthetic-era5", "format": "OneScience ERA5Dataset HDF5", "channels": channels, "files": records, "static_file": static_record, "native_model_grid": config["model"]["grid_shape"], "input_steps": input_steps, "forecast_steps": forecast_steps, "time_step_hours": time_step_hours, "forecast_horizon_hours": forecast_steps * time_step_hours, "forecast_horizon_days": forecast_horizon_days, "official_forecast_capability_days": [2, 15], "official_15_day_window_complete": forecast_horizon_days >= 15, "forecast_window_complete": complete_window})
    print(f"Generated {len(records)} years under {out}")


if __name__ == "__main__":
    main()