1

Ajout de données météo

This commit is contained in:
2025-11-27 01:38:50 +01:00
parent bf500c183a
commit 88fd44c0fb
450 changed files with 6182 additions and 205 deletions

View File

@@ -0,0 +1,60 @@
const fs = require("fs");
const path = require("path");
const DEFAULT_WEATHER_CONFIG = {
timezone: "Europe/Paris",
defaultHour: 12,
defaultMinute: 0,
windowMinutes: 60,
precipitationThreshold: 0.1,
providers: {
influxdb: {
windowMinutes: 60,
},
openMeteo: {
timezone: null,
pressureOffset: 0,
},
},
};
function loadWeatherConfig(configPath = path.resolve(__dirname, "..", "..", "config.json")) {
let raw = {};
if (fs.existsSync(configPath)) {
try {
raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch (error) {
console.error(`Unable to read weather config at ${configPath}: ${error.message}`);
}
}
const weather = raw.weather || {};
const providers = {
...DEFAULT_WEATHER_CONFIG.providers,
...(weather.providers || {}),
};
providers.influxdb = {
...DEFAULT_WEATHER_CONFIG.providers.influxdb,
...(weather.providers?.influxdb || {}),
};
providers.openMeteo = {
...DEFAULT_WEATHER_CONFIG.providers.openMeteo,
timezone: weather.providers?.openMeteo?.timezone || weather.timezone || DEFAULT_WEATHER_CONFIG.timezone,
...(weather.providers?.openMeteo || {}),
};
return {
...DEFAULT_WEATHER_CONFIG,
...weather,
providers,
};
}
module.exports = {
DEFAULT_WEATHER_CONFIG,
loadWeatherConfig,
};

View File

@@ -0,0 +1,18 @@
const WEATHER_FIELDS = [
"temperature",
"humidity",
"pressure",
"illuminance",
"precipitations",
"wind_speed",
"wind_direction",
];
function hasValue(value) {
return value !== null && value !== undefined;
}
module.exports = {
WEATHER_FIELDS,
hasValue,
};

View File

@@ -0,0 +1,41 @@
const fs = require("fs/promises");
const YAML = require("yaml");
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/;
async function readFrontmatter(filePath) {
const raw = await fs.readFile(filePath, "utf8");
const match = raw.match(FRONTMATTER_PATTERN);
if (!match) return null;
const frontmatterText = match[1];
const doc = YAML.parseDocument(frontmatterText);
if (doc.errors.length) {
const [error] = doc.errors;
throw new Error(`Invalid frontmatter in ${filePath}: ${error.message}`);
}
const body = raw.slice(match[0].length);
return { doc, body, frontmatterText };
}
async function writeFrontmatter(filePath, doc, body) {
const serialized = doc.toString().trimEnd();
const nextContent = `---\n${serialized}\n---\n${body}`;
await fs.writeFile(filePath, nextContent, "utf8");
}
function extractRawDate(frontmatterText) {
const match = frontmatterText.match(/^date:\s*(.+)$/m);
return match ? match[1].trim() : null;
}
module.exports = {
extractRawDate,
readFrontmatter,
writeFrontmatter,
};

View File

@@ -0,0 +1,58 @@
const { WEATHER_FIELDS, hasValue } = require("../constants");
const { createInfluxProvider } = require("./influxdb");
const { createOpenMeteoProvider } = require("./open-meteo");
function mergeWeather(target, addition, providerName) {
let added = false;
for (const field of WEATHER_FIELDS) {
if (!hasValue(addition[field])) continue;
if (hasValue(target[field])) continue;
target[field] = addition[field];
added = true;
}
if (added) {
const existing = target.source || [];
const nextSources = Array.from(new Set([...existing, ...(addition.source || [providerName])].filter(Boolean)));
target.source = nextSources;
}
return added;
}
function buildProviders(config) {
const providers = [];
const influx = createInfluxProvider(config.providers?.influxdb, config);
const openMeteo = createOpenMeteoProvider(config.providers?.openMeteo, config);
if (influx) providers.push(influx);
if (openMeteo) providers.push(openMeteo);
return providers;
}
async function fetchWeather(targetDateTime, config) {
const providers = buildProviders(config);
const weather = {};
for (const provider of providers) {
const result = await provider.fetch({ target: targetDateTime });
if (!result) continue;
mergeWeather(weather, result, provider.name);
const complete = WEATHER_FIELDS.every((field) => hasValue(weather[field]));
if (complete) break;
}
return Object.keys(weather).length > 0 ? weather : null;
}
module.exports = {
fetchWeather,
hasConfiguredProvider: (config) => buildProviders(config).length > 0,
mergeWeather,
};

View File

@@ -0,0 +1,137 @@
const { InfluxDB } = require("@influxdata/influxdb-client");
const { DateTime } = require("luxon");
const { buildTimeWindow } = require("../time");
const { hasValue } = require("../constants");
function escapeValue(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function buildFluxQuery(bucket, sensor, window) {
const startIso = window.start.toUTC().toISO();
const stopIso = window.end.toUTC().toISO();
const filters = [];
if (sensor.measurement) filters.push(`r._measurement == "${escapeValue(sensor.measurement)}"`);
if (sensor.field) filters.push(`r._field == "${escapeValue(sensor.field)}"`);
if (sensor.tags) {
for (const [key, value] of Object.entries(sensor.tags)) {
filters.push(`r.${key} == "${escapeValue(value)}"`);
}
}
let query = `from(bucket: "${escapeValue(bucket)}")\n |> range(start: time(v: "${startIso}"), stop: time(v: "${stopIso}"))`;
if (filters.length > 0) {
query += `\n |> filter(fn: (r) => ${filters.join(" and ")})`;
}
query += "\n |> keep(columns: [\"_time\", \"_value\"])";
return query;
}
function pickNearestRow(rows, target) {
let closest = null;
let smallestDiff = Number.POSITIVE_INFINITY;
for (const row of rows) {
if (!hasValue(row._time)) continue;
const rowTime = DateTime.fromISO(row._time);
if (!rowTime.isValid) continue;
const diff = Math.abs(rowTime.diff(target).as("milliseconds"));
if (diff < smallestDiff) {
smallestDiff = diff;
closest = { time: rowTime, value: row._value };
}
}
return closest;
}
function normalizeValue(key, rawValue, sensor, precipitationThreshold) {
if (!hasValue(rawValue)) return null;
let value = typeof rawValue === "number" ? rawValue : Number(rawValue);
if (Number.isNaN(value)) return null;
if (sensor.multiplier && Number.isFinite(sensor.multiplier)) {
value *= sensor.multiplier;
}
if (sensor.offset && Number.isFinite(sensor.offset)) {
value += sensor.offset;
}
if (key === "wind_speed" && sensor.unit === "mps") {
value *= 3.6;
}
if (key === "precipitations") {
const threshold = Number.isFinite(sensor.threshold) ? sensor.threshold : precipitationThreshold;
if (!Number.isFinite(threshold)) return null;
return value > threshold;
}
return value;
}
function createInfluxProvider(config = {}, globalConfig = {}) {
const { url, token, org, bucket, sensors } = config;
if (!url || !token || !org || !bucket || !sensors || Object.keys(sensors).length === 0) {
return null;
}
const queryApi = new InfluxDB({ url, token }).getQueryApi(org);
const windowMinutes = config.windowMinutes ?? globalConfig.windowMinutes ?? 60;
const precipitationThreshold = config.precipitationThreshold ?? globalConfig.precipitationThreshold ?? 0.1;
async function fetch({ target }) {
const window = buildTimeWindow(target, windowMinutes);
const weather = {};
const contributed = new Set();
for (const [key, sensor] of Object.entries(sensors)) {
const query = buildFluxQuery(bucket, sensor, window);
try {
const rows = await queryApi.collectRows(query);
const nearest = pickNearestRow(rows, target);
if (!nearest) continue;
const value = normalizeValue(key, nearest.value, sensor, precipitationThreshold);
if (!hasValue(value)) continue;
weather[key] = value;
contributed.add("influxdb");
} catch (error) {
console.error(`InfluxDB error for ${key}: ${error.message}`);
}
}
if (Object.keys(weather).length === 0) return null;
weather.source = Array.from(contributed);
return weather;
}
return {
name: "influxdb",
fetch,
};
}
module.exports = {
createInfluxProvider,
};

View File

@@ -0,0 +1,157 @@
const { fetch: httpFetch } = require("undici");
const { DateTime } = require("luxon");
const { hasValue } = require("../constants");
const { buildTimeWindow } = require("../time");
function isConfigured(config) {
return config && Number.isFinite(config.latitude) && Number.isFinite(config.longitude);
}
function normalizeTarget(target, zone) {
if (!target) return null;
if (DateTime.isDateTime(target)) return target.setZone(zone || undefined);
if (target instanceof Date) return DateTime.fromJSDate(target, { zone });
if (typeof target === "string") return DateTime.fromISO(target, { zone });
return null;
}
function selectNearestHour(hourly, target, timezone, windowMinutes) {
if (!hourly || !Array.isArray(hourly.time) || hourly.time.length === 0) return null;
const window = buildTimeWindow(target, windowMinutes);
let nearest = null;
let smallestDiff = Number.POSITIVE_INFINITY;
for (let i = 0; i < hourly.time.length; i++) {
const timeValue = DateTime.fromISO(hourly.time[i], { zone: timezone });
if (!timeValue.isValid) continue;
if (timeValue < window.start || timeValue > window.end) continue;
const diff = Math.abs(timeValue.diff(target).as("milliseconds"));
if (diff < smallestDiff) {
smallestDiff = diff;
nearest = {
time: timeValue,
temperature_2m: hourly.temperature_2m?.[i],
relative_humidity_2m: hourly.relative_humidity_2m?.[i],
surface_pressure: hourly.surface_pressure?.[i],
shortwave_radiation: hourly.shortwave_radiation?.[i],
precipitation: hourly.precipitation?.[i],
wind_speed_10m: hourly.wind_speed_10m?.[i],
wind_direction_10m: hourly.wind_direction_10m?.[i],
};
}
}
return nearest;
}
function createOpenMeteoProvider(config = {}, globalConfig = {}) {
if (!isConfigured(config)) return null;
const windowMinutes = config.windowMinutes ?? globalConfig.windowMinutes ?? 60;
const precipitationThreshold = config.precipitationThreshold ?? globalConfig.precipitationThreshold ?? 0.1;
const pressureOffset = Number.isFinite(config.pressureOffset) ? config.pressureOffset : 0;
const timezone = config.timezone || globalConfig.timezone || "UTC";
async function fetchData({ target }) {
const normalized = normalizeTarget(target, timezone);
if (!normalized || !normalized.isValid) return null;
const isFuture = normalized > DateTime.now().setZone(timezone).plus({ days: 1 });
const baseUrl = isFuture ? "https://api.open-meteo.com/v1/forecast" : "https://archive-api.open-meteo.com/v1/archive";
const params = new URLSearchParams({
latitude: config.latitude.toString(),
longitude: config.longitude.toString(),
timezone,
start_date: normalized.toISODate(),
end_date: normalized.toISODate(),
hourly: [
"temperature_2m",
"relative_humidity_2m",
"surface_pressure",
"shortwave_radiation",
"precipitation",
"wind_speed_10m",
"wind_direction_10m",
].join(","),
});
if (isFuture) {
params.set("forecast_days", "16");
params.set("past_days", "7");
}
const url = `${baseUrl}?${params.toString()}`;
let json;
try {
const response = await httpFetch(url);
if (!response || typeof response.ok === "undefined") {
const sanitizedUrl = url
.replace(/(latitude=)[^&]+/, "$1***")
.replace(/(longitude=)[^&]+/, "$1***");
console.error("Open-Meteo unexpected response", {
type: typeof response,
ctor: response?.constructor?.name,
url: sanitizedUrl,
});
throw new Error("Invalid response object");
}
if (!response.ok) {
let details = "";
try {
details = await response.text();
} catch (_) {
// ignore
}
const suffix = details ? `: ${details}` : "";
throw new Error(`HTTP ${response.status}${suffix}`);
}
json = await response.json();
} catch (error) {
console.error(`Open-Meteo error: ${error.message}`);
return null;
}
if (!json?.hourly) return null;
const nearest = selectNearestHour(json.hourly, target, timezone, windowMinutes);
if (!nearest) return null;
const weather = {};
if (hasValue(nearest.temperature_2m)) weather.temperature = nearest.temperature_2m;
if (hasValue(nearest.relative_humidity_2m)) weather.humidity = nearest.relative_humidity_2m;
if (hasValue(nearest.surface_pressure)) weather.pressure = nearest.surface_pressure + pressureOffset;
if (hasValue(nearest.shortwave_radiation)) weather.illuminance = nearest.shortwave_radiation;
if (hasValue(nearest.precipitation)) {
weather.precipitations = nearest.precipitation > precipitationThreshold;
}
if (hasValue(nearest.wind_speed_10m)) weather.wind_speed = nearest.wind_speed_10m;
if (hasValue(nearest.wind_direction_10m)) weather.wind_direction = nearest.wind_direction_10m;
if (Object.keys(weather).length === 0) return null;
weather.source = ["open-meteo"];
return weather;
}
return {
name: "open-meteo",
fetch: fetchData,
};
}
module.exports = {
createOpenMeteoProvider,
};

46
tools/lib/weather/time.js Normal file
View File

@@ -0,0 +1,46 @@
const { DateTime } = require("luxon");
function hasExplicitTime(rawDate) {
if (!rawDate) return false;
const cleaned = rawDate.replace(/^['"]|['"]$/g, "");
return /\d{2}:\d{2}/.test(cleaned);
}
function resolveArticleDate(dateValue, rawDate, { timezone = "Europe/Paris", defaultHour = 12, defaultMinute = 0 } = {}) {
const hasTime = hasExplicitTime(rawDate);
const zone = timezone || "UTC";
let parsed;
if (typeof dateValue === "string") {
parsed = DateTime.fromISO(dateValue, { zone });
if (!parsed.isValid) {
parsed = DateTime.fromRFC2822(dateValue, { zone });
}
} else if (dateValue instanceof Date) {
parsed = DateTime.fromJSDate(dateValue, { zone });
}
if (!parsed || !parsed.isValid) return null;
if (!hasTime) {
parsed = parsed.set({ hour: defaultHour, minute: defaultMinute, second: 0, millisecond: 0 });
}
return parsed;
}
function buildTimeWindow(target, windowMinutes = 60) {
const minutes = Number.isFinite(windowMinutes) ? windowMinutes : 60;
return {
start: target.minus({ minutes }),
end: target.plus({ minutes }),
};
}
module.exports = {
buildTimeWindow,
hasExplicitTime,
resolveArticleDate,
};