"""
Synthetic data generators for testing and demonstration.
Provides functions to generate realistic-looking meteorological timeseries
and tower grid configurations without requiring real observational data.
"""
import numpy as np
from typing import List, Optional, Tuple
[docs]
def generate_synthetic_timeseries(
n_timesteps: int = 48,
dt_minutes: int = 30,
start_time: str = "2024-01-01T00:00",
ustar_range: Tuple[float, float] = (0.1, 0.8),
mol_range: Tuple[float, float] = (-500.0, 500.0),
wind_speed_range: Tuple[float, float] = (1.0, 8.0),
wind_dir_mean: float = 270.0,
wind_dir_std: float = 30.0,
seed: Optional[int] = None,
) -> dict:
"""Generate a synthetic meteorological timeseries with diurnal cycle.
Produces ustar, Monin-Obukhov length, wind speed, and wind direction
arrays that follow a realistic diurnal pattern (unstable daytime,
stable nighttime) with added noise.
Parameters
----------
n_timesteps : int
Number of time steps to generate.
dt_minutes : int
Time step interval in minutes.
start_time : str
ISO-format start time string.
ustar_range : tuple of float
(min, max) friction velocity [m/s].
mol_range : tuple of float
(min_negative, max_positive) Monin-Obukhov length [m].
Negative = unstable, positive = stable.
wind_speed_range : tuple of float
(min, max) wind speed [m/s].
wind_dir_mean : float
Mean wind direction [degrees].
wind_dir_std : float
Standard deviation of wind direction [degrees].
seed : int, optional
Random seed for reproducibility.
Returns
-------
dict
Dictionary with keys matching MetConfig schema:
ustar, mol, wind_speed, wind_dir, timestamps (all lists).
"""
rng = np.random.default_rng(seed)
# Time array (hours from start)
t_hours = np.arange(n_timesteps) * dt_minutes / 60.0
# Diurnal phase: 0 at midnight, pi at noon
phase = 2.0 * np.pi * (t_hours % 24.0) / 24.0
# ustar: peaks during daytime (convective mixing)
ustar_min, ustar_max = ustar_range
ustar_mean = 0.5 * (ustar_min + ustar_max)
ustar_amp = 0.5 * (ustar_max - ustar_min)
ustar = ustar_mean + ustar_amp * np.sin(phase - np.pi / 2)
ustar += rng.normal(0, 0.05 * ustar_amp, n_timesteps)
ustar = np.clip(ustar, ustar_min, ustar_max)
# Monin-Obukhov length: negative (unstable) during day, positive (stable) at night
mol_neg, mol_pos = mol_range
# Daytime: unstable (negative L, small magnitude = strong instability at noon)
# Nighttime: stable (positive L, small magnitude = strong stability at midnight)
mol_magnitude = 50.0 + 450.0 * np.abs(np.cos(phase - np.pi / 2))
mol_sign = np.where(np.sin(phase - np.pi / 2) > 0, -1.0, 1.0)
mol = mol_sign * mol_magnitude
mol += rng.normal(0, 20.0, n_timesteps)
mol = np.clip(mol, mol_neg, mol_pos)
# Wind speed: slightly higher during daytime
ws_min, ws_max = wind_speed_range
ws_mean = 0.5 * (ws_min + ws_max)
ws_amp = 0.3 * (ws_max - ws_min)
wind_speed = ws_mean + ws_amp * np.sin(phase - np.pi / 2)
wind_speed += rng.normal(0, 0.5, n_timesteps)
wind_speed = np.clip(wind_speed, ws_min, ws_max)
# Wind direction: random walk around mean
wind_dir = wind_dir_mean + rng.normal(0, wind_dir_std, n_timesteps)
wind_dir = wind_dir % 360.0
# Generate timestamps
timestamps = []
from datetime import datetime, timedelta
t0 = datetime.fromisoformat(start_time)
for i in range(n_timesteps):
timestamps.append((t0 + timedelta(minutes=i * dt_minutes)).isoformat())
return {
"ustar": ustar.tolist(),
"mol": mol.tolist(),
"wind_speed": wind_speed.tolist(),
"wind_dir": wind_dir.tolist(),
"timestamps": timestamps,
}
[docs]
def generate_towers_grid(
n_towers: int = 4,
center_lat: float = 50.9500,
center_lon: float = 11.5860,
spacing_m: float = 500.0,
z_m: float = 10.0,
layout: str = "grid",
seed: Optional[int] = None,
) -> List[dict]:
"""Generate tower configurations in various spatial layouts.
Parameters
----------
n_towers : int
Number of towers to generate.
center_lat, center_lon : float
Center point of the tower array [decimal degrees].
spacing_m : float
Approximate spacing between towers [meters].
z_m : float
Measurement height [meters] (same for all towers).
layout : str
Spatial layout: "grid", "random", or "transect".
seed : int, optional
Random seed for reproducibility (used by "random" layout).
Returns
-------
list of dict
Tower configurations matching TowerConfig schema.
"""
rng = np.random.default_rng(seed)
# Approximate degrees per meter at center latitude
deg_per_m_lat = 1.0 / 111_320.0
deg_per_m_lon = 1.0 / (111_320.0 * np.cos(np.radians(center_lat)))
if layout == "grid":
side = int(np.ceil(np.sqrt(n_towers)))
offsets = []
for i in range(side):
for j in range(side):
if len(offsets) >= n_towers:
break
dx = (j - (side - 1) / 2) * spacing_m
dy = (i - (side - 1) / 2) * spacing_m
offsets.append((dx, dy))
elif layout == "transect":
offsets = [((i - (n_towers - 1) / 2) * spacing_m, 0.0) for i in range(n_towers)]
elif layout == "random":
extent = spacing_m * np.sqrt(n_towers)
offsets = [
(rng.uniform(-extent / 2, extent / 2), rng.uniform(-extent / 2, extent / 2))
for _ in range(n_towers)
]
else:
raise ValueError(
f"Unknown layout: {layout}. Use 'grid', 'transect', or 'random'."
)
towers = []
for i, (dx, dy) in enumerate(offsets):
lat = center_lat + dy * deg_per_m_lat
lon = center_lon + dx * deg_per_m_lon
towers.append(
{
"name": f"tower_{chr(65 + i)}" if i < 26 else f"tower_{i}",
"lat": round(lat, 6),
"lon": round(lon, 6),
"z_m": z_m,
}
)
return towers