= OBS_BOX[0] && $lo <= OBS_BOX[1] && $la >= OBS_BOX[2] && $la <= OBS_BOX[3]) && !isset($follow[$sid])) { continue; } $raf = num($r[$col['raf10']], 3.6); if ($raf !== null && $raf > 250) { /* valeur aberrante (> 69 m/s) */ $raf = null; } $pm = num($r[$col['pmer']], 0.01); if ($pm !== null && !($pm > 930 && $pm < 1060)) { $pm = null; } $rows[$sid] = [$la, $lo, $raf, num($r[$col['ff']], 3.6), num($r[$col['dd']]), $pm, num($r[$col['ddraf10']])]; } return $rows; } /** * Records de rafale d'octobre (fxi, km/h) par station, tirés des climatologies de collecteur (21 Mo) : * extraits une fois dans un petit cache, refait seulement si la source change. */ function october_records(): array { $src = '/chemin/vers/climatologie/monthly_records_10.json'; $cache = data_dir() . '/records_oct.json'; $st = @lstat($src); if ($st === false || ($st['mode'] & 0170000) !== 0100000) { return is_file($cache) ? json_in($cache)['rec'] : []; } if (is_file($cache)) { $c = json_in($cache); if (($c['mtime'] ?? 0) === $st['mtime']) { return $c['rec']; } } $raw = read_untrusted($src, 64_000_000); $d = $raw !== null ? json_decode($raw, true, 64) : null; if (!is_array($d)) { return []; } $rec = []; $walk = function ($o) use (&$walk, &$rec): void { if (!is_array($o)) { return; } if (isset($o['id'], $o['records']) && is_string($o['id']) && preg_match('/^\d{8}$/', $o['id'])) { $f = $o['records']['fxi'] ?? null; if (is_array($f) && is_numeric($f['value'] ?? null) && is_string($f['date'] ?? null)) { $rec['s' . $o['id']] = [round((float) $f['value'], 1), substr($f['date'], 0, 10), (int) ($o['history_year_count'] ?? 0)]; } return; } foreach ($o as $v) { $walk($v); } }; $walk($d); json_out($cache, ['mtime' => $st['mtime'], 'rec' => $rec]); return $rec; } function merge_vigilance(string $outdir): void { $raw = read_untrusted(VIGILANCE, 5_000_000); $v = $raw !== null ? json_decode($raw, true) : null; if (!is_array($v)) { return; } $path = "$outdir/vigilance-austen.json"; $acc = is_file($path) ? json_in($path) : ['update' => null, 'comment' => '', 'depts' => []]; foreach ((is_array($v['periods'] ?? null) ? $v['periods'] : []) as $p) { if (!is_array($p)) { continue; } if (is_string($p['comment'] ?? null) && stripos($p['comment'], 'AUSTEN') !== false) { $acc['comment'] = $p['comment']; } foreach ((is_array($p['depts'] ?? null) ? $p['depts'] : []) as $code => $dv) { $w = $dv['pheno']['1'] ?? null; if (!is_array($w) || !preg_match('/^(\d{2}|2[AB])$/', (string) $code)) { continue; } $sub = is_array($dv['pheno']['9'] ?? null) ? $dv['pheno']['9'] : []; $cur = $acc['depts'][$code] ?? ['c' => 1, 'orange' => [], 'yellow' => [], 'vs' => 1]; $cur['c'] = max($cur['c'], (int) ($w['max'] ?? 1)); $cur['vs'] = max($cur['vs'], (int) ($sub['max'] ?? 1)); foreach ((is_array($w['frise'] ?? null) ? $w['frise'] : []) as $f) { if (!is_array($f) || !is_string($f['b'] ?? null) || !is_string($f['e'] ?? null)) { continue; } $key = (int) ($f['c'] ?? 0) >= 3 ? 'orange' : ((int) ($f['c'] ?? 0) === 2 ? 'yellow' : null); if ($key && !in_array([$f['b'], $f['e']], $cur[$key], true)) { $cur[$key][] = [$f['b'], $f['e']]; sort($cur[$key]); } } $acc['depts'][$code] = $cur; } } if (is_string($v['update_time'] ?? null)) { $acc['update'] = $v['update_time']; } json_out($path, $acc); } function main(): int { if (time() > utc('2026-10-24T12:00Z')) { return 0; /* épisode terminé : obs.json reste figé */ } $out = pub_dir(); ensure_dir($out); $follow = array_flip(array_column(STATIONS, 0)); $cachePath = data_dir() . '/obs_cache.json'; $cache = is_file($cachePath) ? json_in($cachePath) : []; $t0 = utc(T0); $t1 = utc(T1); foreach (scandir(OBS_DIR) ?: [] as $fn) { if (!preg_match(FILE_RE, $fn, $m)) { continue; } $t = utc(str_replace('_', ':', $m[1]) . 'Z'); if ($t < $t0 || $t > $t1) { continue; } $st = @lstat(OBS_DIR . "/$fn"); if ($st === false || ($st['mode'] & 0170000) !== 0100000) { continue; /* lien, FIFO ou autre : ignoré */ } $key = gmdate('YmdHi', $t); if (isset($cache[$key]) && $cache[$key][0] === $st['mtime']) { continue; } $text = read_untrusted(OBS_DIR . "/$fn", 5_000_000); if ($text !== null) { $cache[$key] = [$st['mtime'], parse_csv($text, $follow)]; } } ksort($cache); json_out($cachePath, $cache); $names = []; $csv = read_untrusted(STATIONS_CSV, 5_000_000) ?? ''; foreach (explode("\n", $csv) as $line) { $r = explode(';', $line); if (count($r) >= 6 && preg_match('/^\d{8}$/', $r[0])) { $names[$r[0]] = [$r[2], $r[5]]; } } $ep0 = ep0(); $iso = fn(string $k) => substr($k, 0, 4) . '-' . substr($k, 4, 2) . '-' . substr($k, 6, 2) . 'T' . substr($k, 8, 2) . ':' . substr($k, 10, 2) . 'Z'; $records = october_records(); /* stations suivies : séries complètes */ $followOut = []; foreach (STATIONS as [$sid, $short]) { $ser = []; foreach ($cache as $k => [, $rows]) { if (isset($rows[$sid])) { $r = $rows[$sid]; $ser[] = [$iso((string) $k), $r[2], $r[3], $r[5]]; } } $ep = array_values(array_filter($ser, fn($s) => $s[1] !== null && utc($s[0]) >= $ep0)); $all = array_values(array_filter($ser, fn($s) => $s[1] !== null)); $pr = array_values(array_filter($ser, fn($s) => $s[3] !== null)); $maxOf = fn(array $a, int $i) => $a ? array_reduce($a, fn($c, $s) => $c === null || $s[$i] > $c[$i] ? $s : $c) : null; $minOf = fn(array $a, int $i) => $a ? array_reduce($a, fn($c, $s) => $c === null || $s[$i] < $c[$i] ? $s : $c) : null; $mx = $maxOf($ep, 1); $mxa = $maxOf($all, 1); $pmin = $minOf($pr, 3); $dmax = null; if ($mx) { $k = str_replace(['-', 'T', ':', 'Z'], '', $mx[0]); $dmax = $cache[$k][1][$sid][6] ?? null; } $ro = $records['s' . $sid] ?? null; $followOut[$sid] = ['name' => $short, 'series' => $ser, 'max' => $mx[1] ?? null, 'tmax' => $mx[0] ?? null, 'rec_oct' => $ro ? ['v' => $ro[0], 'd' => $ro[1], 'y' => $ro[2]] : null, 'max_all' => $mxa[1] ?? null, 'tmax_all' => $mxa[0] ?? null, 'dmax' => $dmax, 'pmin' => $pmin[3] ?? null, 'tpmin' => $pmin[0] ?? null]; } /* toutes les stations du cadre : maxima depuis le début de l'épisode */ $allst = []; $last = null; foreach ($cache as $k => [, $rows]) { $t = utc($iso((string) $k)); $last = $iso((string) $k); if ($t < $ep0) { continue; } foreach ($rows as $sid => $r) { $a = $allst[$sid] ?? ['lat' => $r[0], 'lon' => $r[1], 'max' => null, 'tmax' => null, 'pmin' => null, 'tpmin' => null]; if ($r[2] !== null && ($a['max'] === null || $r[2] > $a['max'])) { $a['max'] = $r[2]; $a['tmax'] = $iso((string) $k); } if ($r[5] !== null && ($a['pmin'] === null || $r[5] < $a['pmin'])) { $a['pmin'] = $r[5]; $a['tpmin'] = $iso((string) $k); } $allst[$sid] = $a; } } $row = function (string $sid, array $a) use ($names, $records): array { $ro = $records['s' . $sid] ?? null; return ['id' => $sid, 'name' => $names[$sid][0] ?? $sid, 'alt' => $names[$sid][1] ?? '', 'lat' => $a['lat'], 'lon' => $a['lon'], 'max' => $a['max'], 'tmax' => $a['tmax'], 'pmin' => $a['pmin'], 'tpmin' => $a['tpmin'], 'rec_oct' => $ro ? ['v' => $ro[0], 'd' => $ro[1], 'y' => $ro[2]] : null]; }; $top = array_filter($allst, fn($a) => $a['max'] !== null); uasort($top, fn($a, $b) => $b['max'] <=> $a['max']); $low = array_filter($allst, fn($a) => $a['pmin'] !== null); uasort($low, fn($a, $b) => $a['pmin'] <=> $b['pmin']); $started = $last !== null && utc($last) > $ep0; /* les identifiants à 8 chiffres deviennent des clés entières en PHP : on les rend en texte */ $list = function (array $a, int $n) use ($row): array { $out = []; foreach ($a as $sid => $v) { if (count($out) >= $n) { break; } $out[] = $row(sprintf('%08d', (int) $sid), $v); } return $out; }; $res = ['updated' => gmdate('Y-m-d\TH:i:s\Z'), 'last' => $last, 'started' => $started, 'follow' => $followOut, 'top' => $list($top, 25), 'lowest' => $list($low, 8), /* records d'octobre battus ou égalés, stations d'au moins 10 ans d'historique */ 'records' => $list(array_filter($top, function (array $a, $sid) use ($records): bool { $ro = $records['s' . sprintf('%08d', (int) $sid)] ?? null; return $ro && $ro[2] >= 10 && $a['max'] >= $ro[0]; }, ARRAY_FILTER_USE_BOTH), 40), 'map' => array_values(array_map(fn($a) => [round($a['lon'], 3), round($a['lat'], 3), (int) round($a['max'])], $top))]; /* carte d'abord : la page recharge l'image dès qu'obs.json change */ if ($started && $top) { render_obs_map($out, $top, $last); } json_out("$out/obs.json", $res); if (time() <= ep1()) { merge_vigilance($out); } run([PHP_BINARY, __DIR__ . '/verify.php'], 120); /* classement des modèles, à chaque nouvelle observation */ logm('obs', (string) $last, 'stations', (string) count($allst)); return 0; } function render_obs_map(string $out, array $top, string $last): void { $tmp = data_dir() . '/render'; ensure_dir($tmp); $asc = $top; uasort($asc, fn($a, $b) => $a['max'] <=> $b['max']); /* les plus fortes dessinées en dernier */ atomic_write("$tmp/obs_dots.tsv", implode('', array_map(fn($a) => sprintf("%.5f\t%.5f\t%.1f\n", $a['lon'], $a['lat'], $a['max']), $asc))); $placed = []; $lab = ''; foreach ($top as $a) { /* étiquettes des plus fortes, sans chevauchement */ if ($a['max'] < 70 || count($placed) > 45) { continue; } foreach ($placed as [$x, $y]) { if (abs($a['lon'] - $x) < 0.42 && abs($a['lat'] - $y) < 0.16) { continue 2; } } $lab .= sprintf("%.5f\t%.5f\t*%d\n", $a['lon'], $a['lat'], (int) round($a['max'])); $placed[] = [$a['lon'], $a['lat']]; } atomic_write("$tmp/obs_labels.tsv", $lab); $d = (new DateTimeImmutable($last))->setTimezone(new DateTimeZone('Europe/Paris')); $jours = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; $when = $jours[(int) $d->format('w')] . ' ' . $d->format('j') . ' à ' . $d->format('G') . ' h ' . $d->format('i'); $geo = data_dir() . '/geo'; $spec = "out $out/maps/.obs-gust.webp\nwidth 1200\nquality 88\nbackground #f4f1ea\nbox " . implode(' ', [-6.0, 9.0, 45.6, 51.3]) . "\nidw $tmp/obs_dots.tsv 45\ngeo $geo 1 1\ndots $tmp/obs_dots.tsv\nlabels $tmp/obs_labels.tsv\n" . "title Rafales maxi observées depuis jeudi 20 h (km/h)\nright Stations Météo-France\n" . "sub Données jusqu'au $when (heure de Paris)\nsign Meteociel.fr\nend\n"; atomic_write("$tmp/obs_spec.txt", $spec); ensure_dir("$out/maps"); [$code, , $err] = run([BIN, 'render', "$tmp/obs_spec.txt"], 120); if ($code === 0) { rename("$out/maps/.obs-gust.webp", "$out/maps/obs-gust.webp"); } else { logm('ÉCHEC carte observée', trim($err)); } } exit(main());