getTimestamp(); } function iso(int $t): string { return gmdate('Y-m-d\TH:i\Z', $t); } /* ---------------------------------------------------------------- réglages de l'épisode */ function ep0(): int { return utc('2026-10-22T18:00Z'); } /* jeudi 20 h, heure de Paris */ function ep1(): int { return utc('2026-10-24T00:00Z'); } /* samedi 2 h */ function tr0(): int { return utc('2026-10-22T00:00Z'); } function tr1(): int { return utc('2026-10-24T00:00Z'); } function fetch_end(): int { return utc('2026-10-24T06:00Z'); } const MODELS = [ 'arome' => ['name' => 'AROME', 'res' => '1,3 km', 'color' => '#d6336c', 'gust' => true, 'gnote' => 'rafale maxi horaire', 'org' => 'Météo-France', 'cycle' => 3, 'delay' => 2.5], 'arpege' => ['name' => 'ARPEGE', 'res' => '10 km', 'color' => '#1c7ed6', 'gust' => true, 'gnote' => 'rafale maxi horaire', 'org' => 'Météo-France', 'cycle' => 6, 'delay' => 3.5], 'iconeu' => ['name' => 'ICON-EU', 'res' => '7 km', 'color' => '#2b8a3e', 'gust' => true, 'gnote' => 'rafale maxi horaire', 'org' => 'DWD', 'cycle' => 6, 'delay' => 2.5], 'ifs' => ['name' => 'CEP (IFS)', 'res' => '25 km', 'color' => '#e8590c', 'gust' => true, 'gnote' => 'rafale maxi de la dernière heure, toutes les 3 h', 'org' => 'ECMWF', 'cycle' => 6, 'delay' => 6.5], 'gfs' => ['name' => 'GFS', 'res' => '25 km', 'color' => '#7048e8', 'gust' => true, 'gnote' => 'rafale instantanée horaire', 'org' => 'NOAA', 'cycle' => 6, 'delay' => 3.5], 'aifs' => ['name' => 'AIFS', 'res' => '25 km', 'color' => '#868e96', 'gust' => false, 'gnote' => 'pas de rafale (pression seule)', 'org' => 'ECMWF, IA', 'cycle' => 6, 'delay' => 5.5], ]; /* Positions d'Austen posées à la main (consensus des runs du 22/10 00-06 UTC) : elles ne servent * qu'à reconnaître Austen parmi les autres dépressions au moment d'accrocher le suivi. */ const REF_TRACK = [ ['2026-10-22T12:00Z', 48.6, -14.0], ['2026-10-22T18:00Z', 49.6, -9.8], ['2026-10-23T00:00Z', 49.9, -5.9], ['2026-10-23T06:00Z', 50.3, -1.7], ['2026-10-23T12:00Z', 51.2, 2.0], ['2026-10-23T18:00Z', 52.2, 5.0], ]; /* Stations Météo-France : identifiant, nom court, latitude, longitude. */ const STATIONS = [ ['29155005', 'Ouessant', 48.473333, -5.057000], ['29168001', 'Pointe du Raz', 48.038833, -4.732000], ['29075001', 'Brest', 48.453833, -4.391167], ['29158001', 'Penmarch', 47.797500, -4.374833], ['29216001', 'Quimper', 47.973000, -4.160667], ['22113006', 'Lannion', 48.755333, -3.468667], ['22168001', "Ploumanac'h", 48.825833, -3.473167], ['56069001', 'Île de Groix', 47.652333, -3.502333], ['56185001', 'Lorient', 47.762833, -3.435667], ['56009001', 'Belle-Île', 47.294333, -3.218333], ['22016001', 'Île de Bréhat', 48.855167, -3.004500], ['22372001', 'Saint-Brieuc', 48.536500, -2.853167], ['56243001', 'Vannes', 47.604500, -2.714167], ['35228001', 'Dinard', 48.584833, -2.076333], ['44103001', 'Saint-Nazaire', 47.313833, -2.154500], ['35281001', 'Rennes', 48.068833, -1.734000], ['44020001', 'Nantes', 47.150000, -1.608833], ['50020001', 'La Hague', 49.725167, -1.939833], ['50218001', 'Granville', 48.834500, -1.613667], ['50196001', 'Gatteville', 49.697000, -1.265333], ['14137001', 'Caen', 49.180000, -0.456167], ['61001001', 'Alençon', 48.445500, 0.110167], ['72181001', 'Le Mans', 47.945833, 0.194167], ['76552001', 'Cap de la Hève', 49.509000, 0.071167], ['76217001', 'Dieppe', 49.932833, 1.090000], ['76116001', 'Rouen', 49.389500, 1.178333], ['62826001', 'Le Touquet', 50.514500, 1.622833], ['62160001', 'Boulogne', 50.732500, 1.599667], ['80001001', 'Abbeville', 50.136000, 1.834000], ['59183001', 'Dunkerque', 51.055833, 2.339500], ['59343001', 'Lille', 50.570000, 3.097500], ['60639001', 'Beauvais', 49.446500, 2.127167], ['95527001', 'Roissy', 49.015167, 2.534333], ['75114001', 'Paris', 48.821667, 2.337833], ['45055001', 'Orléans', 47.990667, 1.778167], ['37179001', 'Tours', 47.444500, 0.727333], ['17323001', 'Chassiron', 46.046833, -1.411667], ['51449002', 'Reims', 49.209833, 4.155333], ]; /* Échelle Meteociel des rafales (bornes basses des 30 cases) : légende de la page. */ const GUST_BOUNDS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 100, 110, 120, 130, 140, 150, 160, 180, 200, 220, 240]; const GUST_COLORS = ['#ffffff', '#97e6ff', '#33ccff', '#0099ff', '#00ff99', '#33cc66', '#66cc33', '#66ff00', '#a4f22f', '#b7cf0e', '#d6f017', '#cc9900', '#ff9900', '#ff9966', '#cc9999', '#cc6633', '#cc3333', '#ff0d0d', '#c60000', '#800000', '#800050', '#a00077', '#cc00cc', '#ff00ff', '#ff40ff', '#ff80ff', '#ffa0ff', '#ffffff', '#e3e3e3', '#c6c6c6']; /* conf.tsv pour le binaire C : la seule source des réglages reste ce fichier PHP. */ function write_conf(): string { $lines = ['ep0 ' . ep0(), 'ep1 ' . ep1(), 'tr0 ' . tr0(), 'tr1 ' . tr1()]; foreach (REF_TRACK as [$t, $la, $lo]) { $lines[] = sprintf('ref %d %.4f %.4f', utc($t), $la, $lo); } foreach (STATIONS as [$id, , $la, $lo]) { $lines[] = sprintf('station %s %.6f %.6f', $id, $la, $lo); } $p = data_dir() . '/conf.tsv'; atomic_write($p, implode("\n", $lines) . "\n"); return $p; } /* ---------------------------------------------------------------- outils */ function logm(string ...$parts): void { fwrite(STDOUT, gmdate('H:i:s') . ' ' . implode(' ', $parts) . "\n"); } function ensure_dir(string $d): void { if (!is_dir($d) && !mkdir($d, 0755, true) && !is_dir($d)) { throw new RuntimeException("création impossible : $d"); } } function atomic_write(string $path, string $data): void { ensure_dir(dirname($path)); $tmp = dirname($path) . '/.' . basename($path) . '.new'; if (is_link($tmp) || file_put_contents($tmp, $data, LOCK_EX) !== strlen($data)) { throw new RuntimeException("écriture impossible : $tmp"); } chmod($tmp, 0644); if (!rename($tmp, $path)) { throw new RuntimeException("renommage impossible : $path"); } } function json_out(string $path, mixed $data): void { atomic_write($path, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR)); } function json_in(string $path): mixed { $s = file_get_contents($path); if ($s === false) { throw new RuntimeException("lecture impossible : $path"); } return json_decode($s, true, 64, JSON_THROW_ON_ERROR); } /** * Lance une commande sans shell, avec délai maxi. Retourne [code, sortie standard, erreurs]. */ function run(array $cmd, int $timeout = 900): array { $p = proc_open($cmd, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($p)) { throw new RuntimeException('lancement impossible : ' . $cmd[0]); } stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $out = $err = ''; $deadline = microtime(true) + $timeout; while (true) { $r = [$pipes[1], $pipes[2]]; $w = $e = null; if (stream_select($r, $w, $e, 1) === false) { break; } foreach ($r as $s) { $chunk = fread($s, 1 << 16); if ($chunk !== false) { if ($s === $pipes[1]) { $out .= $chunk; } else { $err .= $chunk; } } } if (feof($pipes[1]) && feof($pipes[2])) { break; } if (microtime(true) > $deadline) { proc_terminate($p, 9); $err .= "\ndélai dépassé"; break; } } fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($p); return [$code, $out, $err]; } /* Lecture d'un fichier d'un dossier inscriptible par le site : par le binaire C (ni lien, ni FIFO). */ function read_untrusted(string $path, int $max): ?string { [$code, $out] = run([BIN, 'catsafe', $path, (string) $max], 60); return $code === 0 ? $out : null; } /* ---------------------------------------------------------------- HTTP */ function curl_handle(string $url, array $headers = [], ?string $range = null, int $max = 1 << 30): CurlHandle { if (!str_starts_with($url, 'https://')) { throw new RuntimeException("URL refusée : $url"); } $h = curl_init($url); $buf = ''; curl_setopt_array($h, [ CURLOPT_PROTOCOLS_STR => 'https', CURLOPT_REDIR_PROTOCOLS_STR => 'https', CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 900, CURLOPT_USERAGENT => UA, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => function ($ch, string $data) use (&$buf, $max): int { if (strlen($buf) + strlen($data) > $max) { return 0; /* taille maxi dépassée : curl abandonne */ } $buf .= $data; return strlen($data); }, ]); if ($range !== null) { curl_setopt($h, CURLOPT_RANGE, $range); } $GLOBALS['curl_buffers'][spl_object_id($h)] = &$buf; return $h; } function curl_body(CurlHandle $h): string { $id = spl_object_id($h); $b = $GLOBALS['curl_buffers'][$id] ?? ''; unset($GLOBALS['curl_buffers'][$id]); return $b; } /** * Téléchargement direct sur disque (paquets Météo-France de 50 à 80 Mo) : fichier temporaire caché, * taille plafonnée, renommé seulement si le code HTTP vaut 200. Retourne le code HTTP (0 = réseau). */ function http_download(string $url, array $headers, string $path, int $max): int { if (!str_starts_with($url, 'https://')) { throw new RuntimeException("URL refusée : $url"); } $tmp = dirname($path) . '/.' . basename($path) . '.part'; $code = 0; for ($k = 0; $k < 3; $k++) { $fh = fopen($tmp, 'wb'); if ($fh === false) { throw new RuntimeException("écriture impossible : $tmp"); } $h = curl_init($url); curl_setopt_array($h, [ CURLOPT_PROTOCOLS_STR => 'https', CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 900, CURLOPT_USERAGENT => UA, CURLOPT_HTTPHEADER => $headers, CURLOPT_FILE => $fh, CURLOPT_MAXFILESIZE_LARGE => $max, CURLOPT_NOPROGRESS => false, CURLOPT_XFERINFOFUNCTION => fn($c, $dlt, $dl) => $dl > $max ? 1 : 0, ]); curl_exec($h); $code = (int) curl_getinfo($h, CURLINFO_RESPONSE_CODE); fclose($fh); if ($code === 200) { chmod($tmp, 0644); rename($tmp, $path); return 200; } @unlink($tmp); if ($code !== 0 && $code < 500) { return $code; } sleep(20); } return $code; } /** GET simple : [code HTTP, corps]. 0 = échec réseau. Quatre essais sur erreur réseau ou 5xx. */ function http_get(string $url, array $headers = [], ?string $range = null, int $max = 1 << 30): array { for ($k = 0; $k < 4; $k++) { $h = curl_handle($url, $headers, $range, $max); curl_exec($h); $code = (int) curl_getinfo($h, CURLINFO_RESPONSE_CODE); $body = curl_body($h); if ($code === 200 || $code === 206 || ($code >= 400 && $code < 500)) { return [$code, $body]; } sleep(5 * ($k + 1)); } return [$code ?? 0, '']; } /** * Téléchargements en parallèle. $jobs : clé => [url, range|null]. Retourne clé => [code, corps]. */ function http_multi(array $jobs, int $parallel = 8, int $max = 256 << 20): array { $res = []; $queue = $jobs; $mh = curl_multi_init(); $active = []; $tries = []; $start = function () use (&$queue, &$active, $mh, $max, &$tries): void { $key = array_key_first($queue); [$url, $range] = $queue[$key]; unset($queue[$key]); $h = curl_handle($url, [], $range, $max); curl_multi_add_handle($mh, $h); $active[spl_object_id($h)] = [$key, $h, $url, $range]; $tries[$key] = ($tries[$key] ?? 0) + 1; }; while ($queue && count($active) < $parallel) { $start(); } do { curl_multi_exec($mh, $running); curl_multi_select($mh, 1.0); while ($info = curl_multi_info_read($mh)) { $h = $info['handle']; [$key, , $url, $range] = $active[spl_object_id($h)]; unset($active[spl_object_id($h)]); $code = (int) curl_getinfo($h, CURLINFO_RESPONSE_CODE); $body = curl_body($h); curl_multi_remove_handle($mh, $h); if (($code === 0 || $code >= 500) && $tries[$key] < 4) { usleep(2_000_000 * $tries[$key]); /* serveur en difficulté : on ne le martèle pas */ $queue[$key] = [$url, $range]; } else { $res[$key] = [$code, $body]; } if ($queue) { $start(); } } } while ($active || $queue); curl_multi_close($mh); return $res; } /* Jeton Météo-France partagé : lu sous le compte des collectes, gardé en mémoire, validé. */ function mf_token(): string { [$code, $out] = run(['', '/usr/bin/cat', MF_TOKEN], 30); $j = $code === 0 ? json_decode($out, true) : null; $t = is_array($j) ? ($j['access_token'] ?? '') : ''; if (!is_string($t) || !preg_match('/^[A-Za-z0-9._-]{20,4096}$/', $t)) { throw new RuntimeException('jeton Météo-France illisible'); } return $t; } function valid_run(string $run): bool { return (bool) preg_match('/^20\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([01]\d|2[0-3])$/', $run); } function run_time(string $run): int { if (!valid_run($run)) { throw new RuntimeException("run invalide : $run"); } return gmmktime((int) substr($run, 8, 2), 0, 0, (int) substr($run, 4, 2), (int) substr($run, 6, 2), (int) substr($run, 0, 4)); }