Les réglages et les outils communs
La seule source des réglages : fenêtre de l'épisode (jeudi 20 h → samedi 2 h, heure de Paris), modèles, 38 stations, palette des rafales de Meteociel, et la trajectoire de référence, six positions d'Austen posées à la main qui servent uniquement à la reconnaître parmi les autres dépressions. Le binaire C reçoit ces réglages par un petit fichier texte écrit ici.
On y trouve aussi les règles de sécurité tenues partout : aucune commande passée par un shell (tableau d'arguments), téléchargements en HTTPS seulement avec certificat vérifié et taille plafonnée, jeton Météo-France gardé en mémoire et posé dans l'en-tête, écritures par fichier temporaire puis renommage.
<?php
/**
* Tempête Austen : réglages et outils communs de la partie PHP (orchestration, téléchargements,
* données de la page). Le calcul (GRIB2, analyse, cartes) est fait par le binaire C « austen ».
*
* Règles de sécurité tenues partout :
* - aucune commande passée par un shell : proc_open() reçoit un tableau d'arguments ;
* - téléchargements en HTTPS seulement, certificat vérifié, sans redirection, taille plafonnée ;
* - le jeton Météo-France est lu en mémoire et posé dans l'en-tête curl, jamais en argument
* ni sur disque ; aucun POST /token (il révoquerait le jeton de toutes les collectes) ;
* - un fichier d'un dossier inscriptible par le site est lu par « austen catsafe » (ni lien, ni FIFO) ;
* - toute écriture publiée passe par un fichier temporaire puis un renommage.
*/
declare(strict_types=1);
ini_set('memory_limit', '768M');
const HOME_DIR = __DIR__ . '/..';
const BIN = HOME_DIR . '/c/austen';
const UA = 'MeteoThom-Austen/2.0 (+https://meteothom.com)';
const MF_TOKEN = '/chemin/vers/cache-jeton-meteo-france.json';
const OBS_DIR = '/chemin/vers/observations-6min/2026/10';
const VIGILANCE = '/chemin/vers/vigilance.json';
const STATIONS_CSV = '/chemin/vers/stations.csv';
/* Dossiers : données de travail et dossier publié, réglables pour une passe à blanc. */
function data_dir(): string { return getenv('AUSTEN_DATA') ?: HOME_DIR . '/shadow/data'; }
function site_dir(): string { return getenv('AUSTEN_SITE') ?: HOME_DIR . '/shadow/site'; }
function pub_dir(): string { return getenv('AUSTEN_PUB') ?: HOME_DIR . '/shadow/pub'; }
function utc(string $iso): int
{
$t = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i\Z', $iso, new DateTimeZone('UTC'));
if ($t === false) {
throw new RuntimeException("date invalide : $iso");
}
return $t->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(['<lecture-déléguée>', '/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));
}