Chantier : recuperation de la courbe horaire Forecast.Solar

Workflow "EVCS Solar Forecast V1" (07P8I349ugna9FH4), actif, cadence 15 min —
la frequence de rafraichissement de la prevision elle-meme, l'API precisant
qu'interroger plus souvent n'a aucun interet. 96 appels par jour pour un quota
de 300 par heure.

Rien n'est code en dur : la cle d'API, les plans et les coordonnees viennent
tous de Home Assistant. La cle vit dans un input_text en mode password, exclue
du recorder, et n'apparait ni dans le depot ni dans le JSON du workflow —
l'URL est assemblee a l'execution par BUILD_FORECAST_URL, qui refuse
explicitement de construire une URL incomplete plutot que de gaspiller le
quota sur un appel malforme.

BUILD_HA_CALLS_FORECAST ne publie rien en cas d'echec plutot que d'ecraser une
prevision valide par du vide.

Suite portee a 33 scenarios, dont le refus sur cle absente, sur plans mal
formes, sur kill-switch, et la preservation de la prevision precedente en cas
d'echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-08-25 02:30:28 +02:00
parent 1ce1237965
commit 26fb9f72ea
10 changed files with 636 additions and 2 deletions

View File

@@ -0,0 +1,52 @@
// EVCS Energy Balance — BUILD_FORECAST_URL
// =====================================================================
// Construit l'URL d'appel à Forecast.Solar. Rien n'est codé en dur : la clé,
// les plans et les coordonnées viennent tous de Home Assistant.
//
// ⚠️ CONVENTION D'AZIMUT : l'API utilise 0 = sud, −90 = est, +90 = ouest,
// alors que Home Assistant utilise 0 = nord. Le helper
// `evcs_balance_forecast_planes` contient DÉJÀ les valeurs converties
// (azimut_API = azimut_HA − 180), au format attendu : des triplets
// dec/azimut/kWc successifs séparés par des barres obliques.
// Les listes séparées par des virgules sont refusées par l'API (erreur 422).
//
// ⚠️ La clé apparaît dans l'URL, donc dans les données d'exécution n8n si
// l'enregistrement des erreurs est actif. Compromis assumé : l'API n'accepte
// la clé que dans le chemin, et l'instance n8n est privée.
// =====================================================================
function unwrapSnapshot(raw) {
if (raw && typeof raw.data === 'string') {
try { return JSON.parse(raw.data); } catch { return raw; }
}
return (raw && raw.data) || raw;
}
const input = unwrapSnapshot($input.first().json);
const enabled = (input.enabled ?? 'on').toString().toLowerCase() === 'on';
const key = (input.api_key || '').toString().trim();
const planes = (input.planes || '').toString().trim();
const lat = Number.parseFloat(input.latitude);
const lon = Number.parseFloat(input.longitude);
// Refus explicite plutôt qu'un appel malformé : une URL incomplète
// consommerait le quota pour rien et renverrait une erreur opaque.
const problems = [];
if (!enabled) problems.push('bilan désactivé (kill-switch)');
if (!key || ['unknown', 'unavailable'].includes(key.toLowerCase())) problems.push('clé API absente');
if (!/^[\d.\-\/]+$/.test(planes) || planes.split('/').length % 3 !== 0) problems.push('plans invalides');
if (!Number.isFinite(lat) || !Number.isFinite(lon)) problems.push('coordonnées absentes');
if (problems.length) {
return [{ json: { ok: false, problems, url: null } }];
}
return [{
json: {
ok: true,
nowMs: Number.parseFloat(input.nowMs),
planeCount: planes.split('/').length / 3,
url: `https://api.forecast.solar/${key}/estimate/watthours/period/${lat}/${lon}/${planes}`,
},
}];

View File

@@ -0,0 +1,51 @@
// EVCS Energy Balance — BUILD_HA_CALLS_FORECAST
// =====================================================================
// Publie la prévision horaire dans ses helpers.
// MÊME GARANTIE DE PÉRIMÈTRE : uniquement des entités `evcs_balance_*`.
// En cas d'échec, on ne publie rien plutôt que d'écraser une prévision valide
// par du vide — une prévision d'il y a quinze minutes vaut mieux que rien.
// =====================================================================
const parsed = $input.first().json;
function stampParis() {
const p = new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(new Date());
const g = t => p.find(x => x.type === t)?.value || '';
return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;
}
const calls = [];
function setText(entity, value) {
calls.push({ json: {
domain: 'input_text', service: 'set_value',
data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) },
} });
}
if (!parsed.ok) {
setText('input_text.evcs_balance_forecast_updated',
`échec : ${(parsed.problems || []).join(', ')}`);
return calls;
}
setText('input_text.evcs_balance_forecast_today', parsed.today.csv);
if (parsed.tomorrow) {
setText('input_text.evcs_balance_forecast_tomorrow', parsed.tomorrow.csv);
}
setText('input_text.evcs_balance_forecast_updated', stampParis());
calls.push({ json: {
domain: 'logbook', service: 'log',
data: {
name: 'EVCS Bilan — prévision solaire',
message: `${parsed.today.date} : ${parsed.today.totalKwh} kWh attendus`
+ (parsed.tomorrow ? `, lendemain ${parsed.tomorrow.totalKwh} kWh` : '')
+ ` (${parsed.days} jours reçus, quota restant ${parsed.rateLimitRemaining ?? '?'})`,
entity_id: 'input_text.evcs_balance_forecast_today',
},
} });
return calls;

View File

@@ -0,0 +1,8 @@
{{ {
"nowMs": as_timestamp(now()) * 1000,
"enabled": states("input_boolean.evcs_balance_enabled"),
"api_key": states("input_text.evcs_balance_forecast_api_key"),
"planes": states("input_text.evcs_balance_forecast_planes"),
"latitude": state_attr("zone.home", "latitude"),
"longitude": state_attr("zone.home", "longitude")
} | tojson }}

View File

@@ -0,0 +1,64 @@
// EVCS Energy Balance — PARSE_FORECAST
// =====================================================================
// Transforme la réponse de Forecast.Solar en deux profils horaires (jour et
// lendemain), en kWh par heure, prêts à être publiés en CSV compact.
//
// Cette courbe REMPLACE l'enveloppe mesurée comme source de forme dans
// `RESOURCE_ESTIMATE` : elle apporte forme ET magnitude, sur 6 jours, et elle
// n'est pas censurée par l'état de notre batterie. L'enveloppe conservait un
// biais connu — étant un maximum horaire pris sur des jours différents, elle
// surpondère le matin, dont la variabilité jour à jour est plus forte qu'à
// midi (constaté le 25/08 : deux modèles indépendants la contredisent de la
// même façon).
// =====================================================================
function round2(v) {
return Number.isFinite(v) ? Math.round(v * 100) / 100 : 0;
}
const raw = $input.first().json;
// L'étape amont a pu refuser de construire l'URL.
if (raw && raw.ok === false) {
return [{ json: { ok: false, problems: raw.problems || ['url non construite'] } }];
}
let body = raw;
if (body && typeof body.data === 'string') {
try { body = JSON.parse(body.data); } catch { body = null; }
} else if (body && body.data) {
body = body.data;
}
if (!body || !body.result) {
return [{ json: { ok: false, problems: ['réponse illisible ou sans résultat'] } }];
}
// result : { "2026-08-25 07:00:00": <wattheures de la période>, ... }
const byDay = new Map();
for (const [stamp, wh] of Object.entries(body.result)) {
const day = stamp.slice(0, 10);
const hour = Number.parseInt(stamp.slice(11, 13), 10);
if (!Number.isFinite(hour)) continue;
if (!byDay.has(day)) byDay.set(day, Array.from({ length: 24 }, () => 0));
byDay.get(day)[hour] += Number.parseFloat(wh) / 1000;
}
const days = [...byDay.keys()].sort();
const csv = (arr) => arr.map(v => round2(v).toFixed(2)).join(',');
const sum = (arr) => round2(arr.reduce((a, b) => a + b, 0));
const today = days[0] ? byDay.get(days[0]) : null;
const tomorrow = days[1] ? byDay.get(days[1]) : null;
return [{
json: {
ok: !!today,
days: days.length,
today: today ? { date: days[0], csv: csv(today), totalKwh: sum(today) } : null,
tomorrow: tomorrow ? { date: days[1], csv: csv(tomorrow), totalKwh: sum(tomorrow) } : null,
// Reste du quota, utile pour surveiller qu'on n'appelle pas trop souvent.
rateLimitRemaining: body.message?.ratelimit?.remaining ?? null,
place: body.message?.info?.place ?? null,
},
}];