setTimezone(new DateTimeZone('Europe/Paris')); return JOURS[(int) $d->format('w')] . ' ' . $d->format('j') . ' à ' . $d->format('G') . ' h'; } function ucfirst_fr(string $s): string { return mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1); } /* ---------------------------------------------------------------- étapes C */ function grd_path(string $m, string $r): string { return data_dir() . "/grd/$m/$r.grd"; } function derived(string $m, string $r): string { return data_dir() . "/derived/$m/$r.json"; } function process(string $model, string $run, string $conf): bool { $raw = data_dir() . "/raw/$model/$run"; $grd = grd_path($model, $run); $js = derived($model, $run); ensure_dir(dirname($grd)); ensure_dir(dirname($js)); if (!is_file($grd) && !is_file($js)) { if (!is_file("$raw/.complete")) { return false; } [$code, , $err] = run([BIN, 'decode', $raw, $grd], 900); if ($code !== 0) { logm('ÉCHEC décodage', $model, $run, trim($err)); return false; } } if (is_file($grd) && (!is_file($js) || filemtime($js) < filemtime($grd) || filemtime($js) < filemtime(BIN))) { [$code, , $err] = run([BIN, 'analyze', $conf, $grd, $js, data_dir() . "/derived/$model/{$run}_gmax.grd"], 900); if ($code !== 0) { logm('ÉCHEC analyse', $model, $run, trim($err)); return false; } } foreach (glob("$raw/*.grib2") ?: [] as $f) { /* les GRIB bruts ne servent plus */ unlink($f); } if (is_dir($raw)) { touch("$raw/.decoded"); } return true; } /** Runs analysés (le .json suffit : la grille d'un ancien run est effacée, sa trajectoire reste). */ function decoded_runs(string $m): array { $out = []; foreach (glob(data_dir() . "/derived/$m/*.json") ?: [] as $p) { $r = basename($p, '.json'); if (valid_run($r)) { $out[] = $r; } } sort($out); return $out; } /** Garde la grille des seuls runs encore affichés (dernier run, run de prévision) : 300 Mo par run AROME. */ function prune_grids(array $keep): void { foreach (glob(data_dir() . '/grd/*/*.grd') ?: [] as $p) { $m = basename(dirname($p)); $r = basename($p, '.grd'); if (!in_array($r, $keep[$m] ?? [], true)) { unlink($p); } } } /** * Runs retenus pour la « moyenne de tous les runs » : partis avant le début de l'épisode et allant au moins * jusqu'à 6 h avant sa fin (les plus anciens s'arrêtent parfois un peu avant). */ function allruns_selection(array $all): array { $out = []; foreach ($all as $m => $runs) { if (!MODELS[$m]['gust']) { continue; } foreach ($runs as $r) { if (run_time($r) > ep0() || !is_file(data_dir() . "/derived/$m/{$r}_gmax.grd")) { continue; } $d = json_in(derived($m, $r)); $last = $d['gust_times'] ? utc(end($d['gust_times'])) : 0; if ($last >= ep1() - 6 * 3600) { $out[$m][] = $r; } } } return $out; } /* ---------------------------------------------------------------- rendu */ /** Lance plusieurs « austen render » en parallèle, un par fichier de description. */ function render_parallel(array $specs): void { $procs = []; foreach ($specs as $spec) { $p = proc_open([BIN, 'render', $spec], [0 => ['file', '/dev/null', 'r'], 1 => ['file', '/dev/null', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($p)) { throw new RuntimeException('rendu impossible'); } $procs[] = [$p, $pipes[2], $spec]; } foreach ($procs as [$p, $err, $spec]) { $e = stream_get_contents($err); fclose($err); if (proc_close($p) !== 0) { logm('ÉCHEC rendu', basename($spec), trim((string) $e)); } } } function spec_block(array $kv): string { $s = ''; foreach ($kv as $k => $v) { $v = str_replace(["\n", "\r"], ' ', (string) $v); $s .= "$k $v\n"; } return $s . "end\n"; } function grd_times(string $path): array { $h = fopen($path, 'rb'); $head = fread($h, 56); ['nt_msl' => $nm, 'nt_gust' => $ng] = unpack('a4magic/Vver/Vnx/Vny/Vnt_msl/Vnt_gust', $head); $tm = $nm ? array_values(unpack("q$nm", fread($h, 8 * $nm))) : []; $tg = $ng ? array_values(unpack("q$ng", fread($h, 8 * $ng))) : []; fclose($h); return [$tm, $tg]; } function runs_label(array $runs): string { $runs = array_values(array_unique($runs)); sort($runs); $f = fn($r) => substr($r, 6, 2) . '/' . substr($r, 4, 2) . ' ' . substr($r, 8, 2) . ' UTC'; $label = 'Runs du ' . implode(', ', array_map($f, $runs)); return mb_strlen($label) > 48 ? 'Runs du ' . $f($runs[0]) . ' au ' . $f(end($runs)) : $label; } function render_all(array $latest, array $forecast, array $stations, array $allSel): array { $tmp = data_dir() . '/render'; ensure_dir($tmp); $site = site_dir(); ensure_dir("$site/maps"); $geo = data_dir() . '/geo'; $ens = data_dir() . '/derived/ensemble'; $gustFc = array_filter($forecast, fn($m) => MODELS[$m]['gust'], ARRAY_FILTER_USE_KEY); $label = runs_label(array_values($gustFc)); $period = 'Du ' . fr_time(ep0()) . ' au ' . fr_time(ep1()) . ' (heure de Paris)'; $common = ['box' => implode(' ', FR_BOX), 'geo' => "$geo 1 1", 'sign' => 'Meteociel.fr']; /* étiquettes : moyenne des modèles aux stations retenues, puis chaque modèle */ $byName = array_column($stations, null, 'name'); $lab = fn(callable $val) => implode('', array_map(fn($n) => $val($byName[$n]) === null ? '' : sprintf("%.6f\t%.6f\t%d\n", $byName[$n]['lon'], $byName[$n]['lat'], $val($byName[$n])), array_filter(SHOW, fn($n) => isset($byName[$n])))); atomic_write("$tmp/lab_mean.tsv", $lab(fn($s) => $s['mean'])); $maps = spec_block(['out' => "$site/maps/gust-mean.webp", 'width' => 1200, 'quality' => 88] + $common + [ 'field' => "$ens.grd 0 gust", 'labels' => "$tmp/lab_mean.tsv", 'title' => 'Rafales maxi de l\'épisode, moyenne de ' . count($gustFc) . ' modèles (km/h)', 'sub' => $period, 'right' => $label]); $maps .= spec_block(['out' => "$site/maps/gust-n100.webp", 'field' => "$ens.grd 3 n100 " . count($gustFc), 'title' => 'Combien de modèles voient des rafales de 100 km/h ou plus ?', 'right' => $label, 'sub' => 'Sur ' . count($gustFc) . ' modèles, du ' . fr_time(ep0()) . ' au ' . fr_time(ep1())] + $common); if ($allSel && is_file(data_dir() . '/derived/ensemble_all.grd')) { $nr = array_sum(array_map('count', $allSel)); atomic_write("$tmp/lab_all.tsv", $lab(fn($s) => $s['all'] ?? null)); $maps .= spec_block(['out' => "$site/maps/gust-allruns.webp", 'width' => 1200, 'field' => data_dir() . '/derived/ensemble_all.grd 0 gust', 'labels' => "$tmp/lab_all.tsv", 'title' => "Rafales maxi de l'épisode, moyenne de $nr runs de " . count($allSel) . ' modèles (km/h)', 'sub' => $period, 'right' => 'Tous les runs partis avant jeudi 20 h'] + $common); } $layer = 4; foreach ($gustFc as $m => $r) { atomic_write("$tmp/lab_$m.tsv", $lab(fn($s) => isset($s['models'][$m]) ? $s['models'][$m] : null)); $maps .= spec_block(['out' => "$site/maps/gust-$m.webp", 'width' => 1000, 'field' => "$ens.grd $layer gust", 'labels' => "$tmp/lab_$m.tsv", 'title' => MODELS[$m]['name'] . ' : rafales maxi de l\'épisode (km/h)', 'sub' => MODELS[$m]['gnote'], 'right' => 'Run du ' . substr($r, 6, 2) . '/' . substr($r, 4, 2) . ' ' . substr($r, 8, 2) . ' UTC'] + $common); $layer++; } atomic_write("$tmp/maps.txt", $maps); $specs = ["$tmp/maps.txt"]; /* animation : une image par heure de 12 UTC jeudi à 0 UTC samedi, un processus par modèle */ $frames = []; foreach ($latest as $m => $r) { if (!MODELS[$m]['gust']) { continue; } $grd = grd_path($m, $r); [$tm, $tg] = grd_times($grd); $track = []; foreach (json_in(derived($m, $r))['track'] as $p) { $track[utc($p['t'])] = $p; } $dir = "$site/frames/$m"; ensure_dir($dir); $txt = ''; $times = []; $rt = run_time($r); for ($t = utc('2026-10-22T12:00Z'); $t <= utc('2026-10-24T00:00Z'); $t += 3600) { $im = array_search($t, $tm, true); $ig = array_search($t, $tg, true); if ($im === false || $ig === false) { continue; } $b = ['out' => "$dir/" . gmdate('YmdH', $t) . '.webp', 'width' => 960, 'quality' => 82, 'box' => implode(' ', AN_BOX), 'geo' => "$geo 1 0.9", 'field' => "$grd $ig gust", 'isobars' => "$grd $im " . ($m === 'arome' ? 1 : 0)]; if (isset($track[$t])) { $b['center'] = sprintf('%.4f %.4f %.1f', $track[$t]['lon'], $track[$t]['lat'], $track[$t]['p']); } $b += ['title' => MODELS[$m]['name'] . ' : rafales et pression', 'sub' => ucfirst_fr(fr_time($t)) . ' (heure de Paris)', 'right' => sprintf('Run %s UTC du %s/%s, +%dh', substr($r, 8, 2), substr($r, 6, 2), substr($r, 4, 2), intdiv($t - $rt, 3600)), 'sign' => 'Meteociel.fr']; $txt .= spec_block($b); $times[] = gmdate('YmdH', $t); } atomic_write("$tmp/frames_$m.txt", $txt); $specs[] = "$tmp/frames_$m.txt"; $frames[$m] = ['run' => $r, 'times' => $times]; } render_parallel($specs); json_out("$site/frames/frames.json", $frames); return $frames; } /* ---------------------------------------------------------------- data.json */ function station_table(array $forecast, array $allSel = []): array { /* moyenne de tous les runs : maxi de chaque run moyenné par modèle, puis entre modèles */ $allMax = []; foreach ($allSel as $m => $runs) { foreach ($runs as $r) { foreach (json_in(derived($m, $r))['stations'] ?? [] as $sid => $v) { if ($v['max'] !== null) { $allMax[(string) $sid][$m][] = $v['max']; } } } } $per = []; foreach ($forecast as $m => $r) { if (MODELS[$m]['gust']) { $per[$m] = json_in(derived($m, $r))['stations'] ?? []; } } $out = []; foreach (STATIONS as [$id, $name, $lat, $lon]) { $models = $tmax = []; foreach ($per as $m => $st) { $v = $st[$id] ?? null; if ($v !== null && $v['max'] !== null) { $models[$m] = (int) round($v['max']); $tmax[$m] = $v['tmax']; } } $raw = array_values(array_filter(array_map(fn($m) => $per[$m][$id]['max'] ?? null, array_keys($per)), fn($v) => $v !== null)); $perModel = array_map(fn($v) => array_sum($v) / count($v), $allMax[$id] ?? []); $out[] = ['id' => $id, 'name' => $name, 'lat' => $lat, 'lon' => $lon, 'models' => $models, 'tmax' => $tmax, 'all' => $perModel ? (int) round(array_sum($perModel) / count($perModel)) : null, 'mean' => $raw ? (int) round(array_sum($raw) / count($raw)) : null, 'lo' => $raw ? (int) round(min($raw)) : null, 'hi' => $raw ? (int) round(max($raw)) : null]; } return $out; } /** Positions horaires : les modèles à pas de 3 ou 6 h sont interpolés entre deux points suivis. */ function hourly(array $points): array { $out = []; for ($k = 0; $k + 1 < count($points); $k++) { $a = $points[$k]; $b = $points[$k + 1]; $ta = utc($a['t']); $gap = (utc($b['t']) - $ta) / 3600; if ($gap > 6.01) { continue; } for ($h = 0; $h <= (int) round($gap); $h++) { $f = $gap ? $h / $gap : 0; $out[iso($ta + $h * 3600)] = [$a['lat'] + $f * ($b['lat'] - $a['lat']), $a['lon'] + $f * ($b['lon'] - $a['lon']), $a['p'] + $f * ($b['p'] - $a['p'])]; } } if (count($points) === 1) { $out[$points[0]['t']] = [$points[0]['lat'], $points[0]['lon'], $points[0]['p']]; } return $out; } /** * Trajectoire « observée » : position du centre au départ de chaque run (l'analyse du modèle, qui * assimile les observations), moyennée entre modèles à chaque heure d'analyse. Après la dernière * analyse, et sur 6 h au plus, la prévision à courte échéance des derniers runs prend le relais * (marquée « f ») pour placer le centre sur les images satellite les plus récentes. */ function analysis_track(array $sel, array $tracks): array { $byTime = []; foreach ($sel['all'] as $m => $runs) { foreach ($runs as $r) { $t0 = iso(run_time($r)); foreach (json_in(derived($m, $r))['track'] as $p) { if ($p['t'] === $t0) { $byTime[$t0][$m] = [$p['lat'], $p['lon'], $p['p']]; } } } } ksort($byTime); $out = []; foreach ($byTime as $t => $ms) { $v = array_values($ms); $out[] = ['t' => $t, 'lat' => round(array_sum(array_column($v, 0)) / count($v), 3), 'lon' => round(array_sum(array_column($v, 1)) / count($v), 3), 'p' => round(array_sum(array_column($v, 2)) / count($v), 1), 'n' => count($v), 'models' => $ms]; } if ($out) { $last = utc(end($out)['t']); $hly = array_map(fn($tr) => hourly($tr['points']), $tracks); for ($t = $last + 3600; $t <= $last + 6 * 3600; $t += 3600) { $k = iso($t); $pts = array_values(array_filter(array_map(fn($h) => $h[$k] ?? null, $hly))); if (count($pts) >= 2) { $out[] = ['t' => $k, 'lat' => round(array_sum(array_column($pts, 0)) / count($pts), 3), 'lon' => round(array_sum(array_column($pts, 1)) / count($pts), 3), 'p' => round(array_sum(array_column($pts, 2)) / count($pts), 1), 'n' => count($pts), 'f' => true]; } } } return $out; } function build(array $sel, array $frames): void { $tracks = $history = $series = []; foreach ($sel['all'] as $m => $runs) { $history[$m] = []; foreach ($runs as $r) { $d = json_in(derived($m, $r)); $tr = $d['track']; if (!$tr) { continue; } $pm = array_reduce($tr, fn($c, $p) => $c === null || $p['p'] < $c['p'] ? $p : $c); $at6 = null; foreach ($tr as $p) { if ($p['t'] === '2026-10-23T06:00Z') { $at6 = $p; } } $history[$m][] = ['run' => $r, 'pmin' => $pm['p'], 'tmin' => $pm['t'], 'lat' => $pm['lat'], 'lon' => $pm['lon'], 'at6' => $at6, 'track' => array_values(array_filter($tr, fn($p) => substr($p['t'], 14, 2) === '00' && (int) substr($p['t'], 11, 2) % 3 === 0))]; if ($r === ($sel['latest'][$m] ?? null)) { $tracks[$m] = ['run' => $r, 'points' => $tr]; if (isset($d['stations'])) { $series[$m] = ['run' => $r, 'times' => $d['gust_times'], 'values' => array_map(fn($v) => $v['series'], $d['stations'])]; } } } } $hly = array_map(fn($tr) => hourly($tr['points']), $tracks); $cons = []; for ($t = utc('2026-10-22T06:00Z'); $t <= ep1(); $t += 3600) { $k = iso($t); $pts = array_values(array_filter(array_map(fn($h) => $h[$k] ?? null, $hly))); if (count($pts) >= 3) { $ps = array_column($pts, 2); $cons[] = ['t' => $k, 'lat' => round(array_sum(array_column($pts, 0)) / count($pts), 3), 'lon' => round(array_sum(array_column($pts, 1)) / count($pts), 3), 'p' => round(array_sum($ps) / count($ps), 1), 'pmin' => round(min($ps), 1), 'pmax' => round(max($ps), 1), 'n' => count($pts)]; } } $analysis = analysis_track($sel, $tracks); /* moyenne de tous les runs : pour chaque heure, moyenne des runs de chaque modèle, puis entre modèles */ $perModel = []; foreach ($sel['all'] as $m => $runs) { foreach ($runs as $r) { foreach (hourly(json_in(derived($m, $r))['track']) as $k => $p) { $perModel[$k][$m][] = $p; } } } ksort($perModel); $consAll = []; foreach ($perModel as $k => $ms) { if ($k < '2026-10-22T06:00Z' || utc($k) > ep1() || count($ms) < 3) { continue; } $avg = array_map(fn($ps) => [array_sum(array_column($ps, 0)) / count($ps), array_sum(array_column($ps, 1)) / count($ps), array_sum(array_column($ps, 2)) / count($ps)], $ms); $v = array_values($avg); $consAll[] = ['t' => $k, 'lat' => round(array_sum(array_column($v, 0)) / count($v), 3), 'lon' => round(array_sum(array_column($v, 1)) / count($v), 3), 'p' => round(array_sum(array_column($v, 2)) / count($v), 1), 'n' => array_sum(array_map('count', $ms))]; } $ens = json_in(data_dir() . '/derived/ensemble.json'); $names = json_in(data_dir() . '/geo/departements.json'); $depts = array_map(fn($d) => ['code' => $d['code'], 'nom' => $names[$d['code']] ?? $d['code']] + $d, $ens['depts']); usort($depts, fn($a, $b) => $b['max'] <=> $a['max'] ?: strcmp($a['code'], $b['code'])); $gustFc = array_filter($sel['forecast'], fn($m) => MODELS[$m]['gust'], ARRAY_FILTER_USE_KEY); $data = [ 'updated' => gmdate('Y-m-d\TH:i:s\Z'), 'episode' => [iso(ep0()), iso(ep1())], 'models' => array_map(fn($v) => array_intersect_key($v, array_flip(['name', 'res', 'color', 'gust', 'gnote', 'org'])), MODELS), 'selection' => ['latest' => $sel['latest'], 'forecast' => $sel['forecast']], 'tracks' => $tracks, 'consensus' => $cons, 'history' => $history, 'series' => $series, 'stations' => station_table($sel['forecast'], allruns_selection($sel['all'])), 'depts' => $depts, 'ensemble_models' => array_map(fn($m, $r) => [$m, $r], array_keys($gustFc), array_values($gustFc)), 'frames' => $frames, 'track_box' => [-21.0, 11.0, 45.0, 55.8], 'analysis' => $analysis, 'consensus_all' => $consAll, 'allruns' => allruns_selection($sel['all']), 'legend' => ['bounds' => GUST_BOUNDS, 'colors' => GUST_COLORS], ]; json_out(site_dir() . '/data.json', $data); if (!is_file(site_dir() . '/basemap.json')) { copy(data_dir() . '/geo/basemap.json', site_dir() . '/basemap.json'); } } /* ---------------------------------------------------------------- publication */ /** Copie le dossier de travail vers le dossier publié, fichier par fichier (temporaire caché puis renommage). */ function publish(): void { $src = site_dir(); $dst = pub_dir(); $n = 0; $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)); foreach ($it as $f) { if ($f->isLink() || !$f->isFile() || str_starts_with($f->getFilename(), '.')) { continue; } $rel = substr($f->getPathname(), strlen($src) + 1); $to = "$dst/$rel"; if (is_file($to) && filesize($to) === $f->getSize() && filemtime($to) >= $f->getMTime()) { continue; } ensure_dir(dirname($to)); $tmp = dirname($to) . '/.' . basename($to) . '.new'; if (!copy($f->getPathname(), $tmp) || !chmod($tmp, 0644) || !rename($tmp, $to)) { throw new RuntimeException("publication impossible : $rel"); } $n++; } logm('publié', (string) $n, 'fichiers'); } /* ---------------------------------------------------------------- principal */ function main(array $argv): int { $runsArg = []; $noFetch = $force = false; for ($k = 1; $k < count($argv); $k++) { match ($argv[$k]) { '--no-fetch' => $noFetch = true, '--force' => $force = true, '--runs' => null, default => $runsArg[] = $argv[$k], }; } if (time() > utc('2026-10-24T12:00Z') && !$runsArg) { logm('épisode terminé, plus de mise à jour automatique'); return 0; } ensure_dir(data_dir()); $lock = fopen(data_dir() . '/.pipeline.lock', 'c'); if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { logm('déjà en cours'); return 0; } $conf = write_conf(); $todo = $runsArg ? array_map(fn($a) => explode(':', $a, 2), $runsArg) : array_map(fn($m) => [$m, null], array_keys(MODELS)); foreach ($todo as [$model, $run]) { if (!isset(MODELS[$model]) || ($run !== null && !valid_run($run))) { logm('argument ignoré', $model); continue; } if (!$noFetch) { [$code, $out, $err] = run(array_merge([PHP_BINARY, __DIR__ . '/fetch.php', $model], $run ? [$run] : []), 1800); file_put_contents(data_dir() . '/fetch.log', $out . $err, FILE_APPEND); if (preg_match('/^RUN (\d{10})$/m', $out, $mm)) { $run = $mm[1]; } } if ($run) { logm($model, $run, process($model, $run, $conf) ? 'ok' : 'non traité'); } } $latest = $forecast = $all = []; foreach (array_keys(MODELS) as $m) { $runs = decoded_runs($m); $all[$m] = $runs; if (!$runs) { continue; } $latest[$m] = end($runs); $before = array_values(array_filter($runs, fn($r) => run_time($r) <= ep0())); if ($before) { $forecast[$m] = end($before); } } $sel = ['latest' => $latest, 'forecast' => $forecast, 'all' => $all]; $sp = data_dir() . '/selection.json'; if (!$force && is_file($sp) && json_in($sp) == $sel) { logm('aucun nouveau run'); return 0; } $args = [BIN, 'ensemble', data_dir() . '/geo/departements.bin', data_dir() . '/derived/ensemble']; foreach ($forecast as $m => $r) { if (MODELS[$m]['gust']) { $args[] = "$m=" . data_dir() . "/derived/$m/{$r}_gmax.grd"; } } [$code, , $err] = run($args, 600); if ($code !== 0) { logm('ÉCHEC moyenne', trim($err)); return 1; } $allSel = allruns_selection($all); if ($allSel) { $a2 = [BIN, 'ensemble', data_dir() . '/geo/departements.bin', data_dir() . '/derived/ensemble_all']; foreach ($allSel as $m => $runs) { $a2[] = "$m=" . implode(',', array_map(fn($r) => data_dir() . "/derived/$m/{$r}_gmax.grd", $runs)); } [$code, , $err] = run($a2, 900); if ($code !== 0) { logm('ÉCHEC moyenne de tous les runs', trim($err)); } } $stations = station_table($forecast, $allSel); $frames = render_all($latest, $forecast, $stations, $allSel); build($sel, $frames); run([PHP_BINARY, __DIR__ . '/export_code.php'], 300); publish(); json_out($sp, $sel); $keep = []; foreach (array_keys(MODELS) as $m) { $keep[$m] = array_values(array_unique(array_filter([$latest[$m] ?? null, $forecast[$m] ?? null]))); } prune_grids($keep); return 0; } exit(main($argv));