"""Generate correlated 113-feature WoFS ensemble storm-track examples.""" import argparse from pathlib import Path import numpy as np import yaml ROOT = Path(__file__).resolve().parents[1] def correlated_block(rng, latent, count, noise, offset): projection_rng = np.random.default_rng(1000 + int(offset * 100)) weights = projection_rng.normal(0, 0.35, (latent.shape[1], count)) for index in range(min(latent.shape[1], count)): weights[index, index::latent.shape[1]] += 0.8 values = latent @ weights + rng.normal(0, noise, (len(latent), count)) values += 0.08 * np.sin(np.arange(count)[None, :] * 0.21 + offset) return values.astype(np.float32) def make_split(path, samples, config, seed): rng = np.random.default_rng(seed) lead_start = rng.choice(np.arange(0, 125, 5), samples) lead_group = (lead_start > 60).astype(np.int64) organization = rng.normal(size=samples) rotation = 0.65 * organization + rng.normal(0, 0.75, samples) instability = rng.normal(size=samples) shear = 0.35 * instability + rng.normal(0, 0.9, samples) cold_pool = 0.45 * organization + rng.normal(0, 0.85, samples) spread = np.maximum(0.15, rng.lognormal(-0.2 + 0.2 * lead_group, 0.35, samples)) latent = np.column_stack((organization, rotation, instability, shear, cold_pool, spread)) amplitude = correlated_block(rng, latent, 30, 0.32, 0.0) spatial = correlated_block(rng, latent, 76, 0.48 + 0.08 * lead_group[:, None], 0.7) area = np.exp(0.45 * organization + rng.normal(5.8, 0.35, samples)) eccentricity = 1 / (1 + np.exp(-(0.5 * shear + rng.normal(0, 0.6, samples)))) major = np.sqrt(area) * (1.2 + eccentricity) minor = area / np.maximum(major, 1) orientation = np.arctan2(spatial[:, 4], spatial[:, 3]) / np.pi extent = np.clip(0.72 - 0.15 * spread + rng.normal(0, 0.08, samples), 0.15, 1) initialization_time = rng.uniform(0, 1, samples) object_properties = np.column_stack((np.log1p(area), eccentricity, orientation, major / 100, minor / 100, extent, initialization_time)).astype(np.float32) features = np.concatenate((amplitude, spatial, object_properties), axis=1).astype(np.float32) risk = np.column_stack((1.25 * rotation + 0.70 * shear + 0.35 * organization, 1.15 * instability + 0.55 * organization + 0.45 * amplitude[:, 5], 1.00 * cold_pool + 0.65 * shear + 0.35 * spatial[:, 12])) risk -= lead_group[:, None] * np.array((0.35, 0.22, 0.18)) # Elevated synthetic rates make the tiny engineering dataset trainable; paper rates were about 1.2/2.5/4%. intercept = np.array((-2.35, -1.75, -1.45)) probabilities = 1 / (1 + np.exp(-(risk + intercept))) targets = (rng.random((samples, 3)) < probabilities).astype(np.float32) np.savez_compressed(path, features=features, targets=targets, lead_group=lead_group, lead_start_minutes=lead_start.astype(np.int16), lead_end_minutes=(lead_start + 30).astype(np.int16), format_version=np.asarray(config["data"]["format_version"]), feature_group_sizes=np.asarray((30, 76, 7), dtype=np.int16), hazards=np.asarray(config["data"]["hazards"]), lead_group_names=np.asarray(config["data"]["lead_groups"]), ensemble_members=np.asarray(18), grid_spacing_km=np.asarray(3), forecast_window_minutes=np.asarray(30), forecast_interval_minutes=np.asarray(5), data_source=np.asarray("structured_synthetic_elevated_event_rates"), paper_event_rates=np.asarray((0.012, 0.025, 0.040), dtype=np.float32)) return targets.mean(0) def main(): parser = argparse.ArgumentParser() parser.add_argument("--force", action="store_true") args = parser.parse_args() config = yaml.safe_load((ROOT / "conf/config.yaml").read_text()) output = ROOT / config["data"]["root"] output.mkdir(parents=True, exist_ok=True) for offset, (name, count) in enumerate((("train.npz", config["data"]["train_samples"]), ("test.npz", config["data"]["test_samples"]))): path = output / name if args.force or not path.exists(): rates = make_split(path, int(count), config, int(config["seed"]) + offset) else: rates = np.load(path)["targets"].mean(0) print(f"generated={path.relative_to(ROOT)} shape=({count},113) event_rates={rates.round(3).tolist()}") if __name__ == "__main__": main()