Files
EVCS/workflows/evcs-solar-forecast-v1.json
Tallagrand Thierry 8d40e0d446 La prevision se juge sur la date qu elle decrit, plus sur son age
Nuit du 07 au 08/09 : 32 calculs du bilan sur 120 en statut degrade, tous entre
00 h et 03 h, avec « aucun croisement PV/conso sur l'horizon, 47,6 kWh a
couvrir ». Le controleur, qui exige statut=ok, retombait sur le palier de 35 % —
d'ou une cible du matin oscillant entre 35, 78,8 et 85 % au lieu de decroitre.

Cause immediate : plus aucune publication de prevision de 23:45 a 04:30. Le
noeud d'appel porte neverError, donc les executions restaient en success ; la
reponse etait « socket hang up », et le noeud d'ecriture vers Home Assistant
rendait 0 item la nuit contre 1 en journee — le .17 etait lui aussi injoignable,
si bien que le message d'echec ne pouvait meme pas etre publie.

Mais le defaut qu'on controle est ailleurs : les courbes sont publiees sans leur
date, et PARSE_FORECAST prend days[0] pour « aujourd'hui ». Vrai a l'instant de
la publication, faux des qu'on passe minuit sans rafraichir. A 00:45 le bilan a
donc lu comme journee en cours celle du 7, dont toutes les heures etaient
passees.

Une courbe est desormais utilisable si elle DECRIT le jour dont on a besoin, pas
si elle est recente : une prevision publiee a 23:45 pour le 8 reste exacte a
03:00, la nuit il n'y a rien a reactualiser. La date part dans
evcs_balance_forecast_day0, et RESOURCE_ESTIMATE distingue trois cas — a jour,
decalee d'un jour (la courbe du lendemain devient celle du jour, l'enveloppe
mesuree reprend la main pour demain), perimee. Helper absent : ancienne garde
d'age, comportement inchange.

Le rapport de nuit signale un trou de plus de deux heures dans les
publications. Il lit la base sur l'autre NAS, seul temoin qui survit quand le
.17 est injoignable. Verifie sur la nuit ecoulee : 285 minutes.

Tests 44 -> 48. Les quatre scenarios partagent la meme heure — 00:45, l'instant
du decrochage — et les memes courbes : seule la date change. Quatre mutations,
chacune sur un jeu different, chaque scenario ayant la sienne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-08 09:30:35 +02:00

245 lines
13 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"name": "EVCS Solar Forecast V1",
"nodes": [
{
"name": "Schedule Forecast",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
],
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
}
},
{
"name": "HA Snapshot Forecast",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
0
],
"parameters": {
"method": "POST",
"url": "http://10.10.10.17:8123/api/template",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "text/plain"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "template",
"value": "{{ {\n \"nowMs\": as_timestamp(now()) * 1000,\n \"enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"api_key\": states(\"input_text.evcs_balance_forecast_api_key\"),\n \"planes\": states(\"input_text.evcs_balance_forecast_planes\"),\n \"latitude\": state_attr(\"zone.home\", \"latitude\"),\n \"longitude\": state_attr(\"zone.home\", \"longitude\")\n} | tojson }}\n"
}
]
},
"options": {
"timeout": 15000
}
},
"credentials": {
"httpBearerAuth": {
"id": "uv3sknYYigxBkaVN",
"name": "Bearer Auth account 2"
}
}
},
{
"name": "BUILD_FORECAST_URL",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
0
],
"parameters": {
"jsCode": "// EVCS Energy Balance — BUILD_FORECAST_URL\n// =====================================================================\n// Construit l'URL d'appel à Forecast.Solar. Rien n'est codé en dur : la clé,\n// les plans et les coordonnées viennent tous de Home Assistant.\n//\n// ⚠️ CONVENTION D'AZIMUT : l'API utilise 0 = sud, −90 = est, +90 = ouest,\n// alors que Home Assistant utilise 0 = nord. Le helper\n// `evcs_balance_forecast_planes` contient DÉJÀ les valeurs converties\n// (azimut_API = azimut_HA − 180), au format attendu : des triplets\n// dec/azimut/kWc successifs séparés par des barres obliques.\n// Les listes séparées par des virgules sont refusées par l'API (erreur 422).\n//\n// ⚠️ La clé apparaît dans l'URL, donc dans les données d'exécution n8n si\n// l'enregistrement des erreurs est actif. Compromis assumé : l'API n'accepte\n// la clé que dans le chemin, et l'instance n8n est privée.\n// =====================================================================\n\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst input = unwrapSnapshot($input.first().json);\n\nconst enabled = (input.enabled ?? 'on').toString().toLowerCase() === 'on';\nconst key = (input.api_key || '').toString().trim();\nconst planes = (input.planes || '').toString().trim();\nconst lat = Number.parseFloat(input.latitude);\nconst lon = Number.parseFloat(input.longitude);\n\n// Refus explicite plutôt qu'un appel malformé : une URL incomplète\n// consommerait le quota pour rien et renverrait une erreur opaque.\nconst problems = [];\nif (!enabled) problems.push('bilan désactivé (kill-switch)');\nif (!key || ['unknown', 'unavailable'].includes(key.toLowerCase())) problems.push('clé API absente');\nif (!/^[\\d.\\-\\/]+$/.test(planes) || planes.split('/').length % 3 !== 0) problems.push('plans invalides');\nif (!Number.isFinite(lat) || !Number.isFinite(lon)) problems.push('coordonnées absentes');\n\nif (problems.length) {\n return [{ json: { ok: false, problems, url: null } }];\n}\n\nreturn [{\n json: {\n ok: true,\n nowMs: Number.parseFloat(input.nowMs),\n planeCount: planes.split('/').length / 3,\n url: `https://api.forecast.solar/${key}/estimate/watthours/period/${lat}/${lon}/${planes}`,\n },\n}];\n"
}
},
{
"name": "Forecast.Solar",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
0
],
"parameters": {
"method": "GET",
"url": "={{ $json.url }}",
"options": {
"timeout": 30000,
"response": {
"response": {
"responseFormat": "text",
"neverError": true
}
}
}
},
"onError": "continueRegularOutput"
},
{
"name": "PARSE_FORECAST",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
0
],
"parameters": {
"jsCode": "// EVCS Energy Balance — PARSE_FORECAST\n// =====================================================================\n// Transforme la réponse de Forecast.Solar en deux profils horaires (jour et\n// lendemain), en kWh par heure, prêts à être publiés en CSV compact.\n//\n// Cette courbe REMPLACE l'enveloppe mesurée comme source de forme dans\n// `RESOURCE_ESTIMATE` : elle apporte forme ET magnitude, sur 6 jours, et elle\n// n'est pas censurée par l'état de notre batterie. L'enveloppe conservait un\n// biais connu — étant un maximum horaire pris sur des jours différents, elle\n// surpondère le matin, dont la variabilité jour à jour est plus forte qu'à\n// midi (constaté le 25/08 : deux modèles indépendants la contredisent de la\n// même façon).\n// =====================================================================\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : 0;\n}\n\nconst raw = $input.first().json;\n\n// L'étape amont a pu refuser de construire l'URL.\nif (raw && raw.ok === false) {\n return [{ json: { ok: false, problems: raw.problems || ['url non construite'] } }];\n}\n\nlet body = raw;\nif (body && typeof body.data === 'string') {\n try { body = JSON.parse(body.data); } catch { body = null; }\n} else if (body && body.data) {\n body = body.data;\n}\n\nif (!body || !body.result) {\n return [{ json: { ok: false, problems: ['réponse illisible ou sans résultat'] } }];\n}\n\n// result : { \"2026-08-25 07:00:00\": <wattheures de la période>, ... }\nconst byDay = new Map();\nfor (const [stamp, wh] of Object.entries(body.result)) {\n const day = stamp.slice(0, 10);\n const hour = Number.parseInt(stamp.slice(11, 13), 10);\n if (!Number.isFinite(hour)) continue;\n if (!byDay.has(day)) byDay.set(day, Array.from({ length: 24 }, () => 0));\n byDay.get(day)[hour] += Number.parseFloat(wh) / 1000;\n}\n\nconst days = [...byDay.keys()].sort();\nconst csv = (arr) => arr.map(v => round2(v).toFixed(2)).join(',');\nconst sum = (arr) => round2(arr.reduce((a, b) => a + b, 0));\n\nconst today = days[0] ? byDay.get(days[0]) : null;\nconst tomorrow = days[1] ? byDay.get(days[1]) : null;\n\nreturn [{\n json: {\n ok: !!today,\n days: days.length,\n today: today ? { date: days[0], csv: csv(today), totalKwh: sum(today) } : null,\n tomorrow: tomorrow ? { date: days[1], csv: csv(tomorrow), totalKwh: sum(tomorrow) } : null,\n // Reste du quota, utile pour surveiller qu'on n'appelle pas trop souvent.\n rateLimitRemaining: body.message?.ratelimit?.remaining ?? null,\n place: body.message?.info?.place ?? null,\n },\n}];\n"
}
},
{
"name": "BUILD_HA_CALLS_FORECAST",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
0
],
"parameters": {
"jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS_FORECAST\n// =====================================================================\n// Publie la prévision horaire dans ses helpers.\n// MÊME GARANTIE DE PÉRIMÈTRE : uniquement des entités `evcs_balance_*`.\n// En cas d'échec, on ne publie rien plutôt que d'écraser une prévision valide\n// par du vide — une prévision d'il y a quinze minutes vaut mieux que rien.\n// =====================================================================\n\nconst parsed = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction setText(entity, value) {\n calls.push({ json: {\n domain: 'input_text', service: 'set_value',\n data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) },\n } });\n}\n\nif (!parsed.ok) {\n setText('input_text.evcs_balance_forecast_updated',\n `échec : ${(parsed.problems || []).join(', ')}`);\n return calls;\n}\n\n// La DATE que decrit la courbe, et non l'heure a laquelle on l'a recue.\n// Sans elle, « today » veut dire « le premier jour renvoye par l'API au moment\n// de la publication » — ce qui devient faux des qu'on passe minuit sans\n// rafraichir. Nuit du 07 au 08/09 : plus aucune publication de 23:45 a 04:30,\n// et le bilan a lu comme « aujourd'hui » la courbe du 7, dont toutes les heures\n// etaient passees. Il en a conclu « aucun croisement, 47,6 kWh a couvrir, SOC\n// requis 100 % », s'est declare degrade, et le controleur est retombe sur le\n// palier de 35 % pendant quatre heures.\nsetText('input_text.evcs_balance_forecast_day0', parsed.today.date);\nsetText('input_text.evcs_balance_forecast_today', parsed.today.csv);\nif (parsed.tomorrow) {\n setText('input_text.evcs_balance_forecast_tomorrow', parsed.tomorrow.csv);\n}\nsetText('input_text.evcs_balance_forecast_updated', stampParis());\n\ncalls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — prévision solaire',\n message: `${parsed.today.date} : ${parsed.today.totalKwh} kWh attendus`\n + (parsed.tomorrow ? `, lendemain ${parsed.tomorrow.totalKwh} kWh` : '')\n + ` (${parsed.days} jours reçus, quota restant ${parsed.rateLimitRemaining ?? '?'})`,\n entity_id: 'input_text.evcs_balance_forecast_today',\n },\n} });\n\nreturn calls;\n"
}
},
{
"name": "Execute HA Service Forecast",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1320,
0
],
"parameters": {
"method": "POST",
"url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.data }}",
"options": {
"timeout": 15000
}
},
"credentials": {
"httpBearerAuth": {
"id": "uv3sknYYigxBkaVN",
"name": "Bearer Auth account 2"
}
},
"onError": "continueRegularOutput"
}
],
"connections": {
"Schedule Forecast": {
"main": [
[
{
"node": "HA Snapshot Forecast",
"type": "main",
"index": 0
}
]
]
},
"HA Snapshot Forecast": {
"main": [
[
{
"node": "BUILD_FORECAST_URL",
"type": "main",
"index": 0
}
]
]
},
"BUILD_FORECAST_URL": {
"main": [
[
{
"node": "Forecast.Solar",
"type": "main",
"index": 0
}
]
]
},
"Forecast.Solar": {
"main": [
[
{
"node": "PARSE_FORECAST",
"type": "main",
"index": 0
}
]
]
},
"PARSE_FORECAST": {
"main": [
[
{
"node": "BUILD_HA_CALLS_FORECAST",
"type": "main",
"index": 0
}
]
]
},
"BUILD_HA_CALLS_FORECAST": {
"main": [
[
{
"node": "Execute HA Service Forecast",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner"
},
"staticData": null,
"id": "07P8I349ugna9FH4"
}