homebridge-viessmann-vicare 2.0.78 → 2.0.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -0
- package/README.md +3 -0
- package/SETUP-GUIDE.md +5 -2
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +1 -1
- package/package.json +1 -1
- package/viessmann-report.js +203 -48
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
All notable changes to homebridge-viessmann-vicare.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
### [2.0.79] - 2026-09-26
|
|
7
|
+
- fix: **report gas forecast** rewritten: weather-normalised degree-day model (real outdoor temperatures from Open-Meteo for the installation location, calibrated on the boiler yearly counters), daily gas from the monthly counters over the whole history. Works with any amount of data (was "0 m³" / "needs 300 days"). Options `--lat/--lon` and `--hddBase`
|
|
8
|
+
|
|
6
9
|
### [2.0.78] - 2026-09-26
|
|
7
10
|
- fix: **report "Device messages" were frozen**: the plugin stopped writing `viessmann-messages-<installation>-<device>.json` in 2.0.50, so the report showed months-old codes. The writer is back, now also includes fault codes (`device.messages.errors.raw`) and keeps a history (the API only returns current messages)
|
|
8
11
|
- fix: report heating-curve analysis uses only heating-season samples (outdoor < 16 °C): no more "weather compensation not active" advice in summer
|
package/README.md
CHANGED
|
@@ -1103,6 +1103,9 @@ For issues and questions:
|
|
|
1103
1103
|
|
|
1104
1104
|
## 📈 Changelog
|
|
1105
1105
|
|
|
1106
|
+
### [2.0.79] - 2026-09-26
|
|
1107
|
+
- fix: **report gas forecast** rewritten: weather-normalised degree-day model (real outdoor temperatures from Open-Meteo for the installation location, calibrated on the boiler yearly counters), daily gas from the monthly counters over the whole history. Works with any amount of data (was "0 m³" / "needs 300 days"). Options `--lat/--lon` and `--hddBase`
|
|
1108
|
+
|
|
1106
1109
|
### [2.0.78] - 2026-09-26
|
|
1107
1110
|
- fix: **report "Device messages" were frozen**: the plugin stopped writing `viessmann-messages-<installation>-<device>.json` in 2.0.50, so the report showed months-old codes. The writer is back, now also includes fault codes (`device.messages.errors.raw`) and keeps a history (the API only returns current messages)
|
|
1108
1111
|
- fix: report heating-curve analysis uses only heating-season samples (outdoor < 16 °C): no more "weather compensation not active" advice in summer
|
package/SETUP-GUIDE.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Complete Setup Guide - v2.0.
|
|
1
|
+
# Complete Setup Guide - v2.0.79
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
This guide will walk you through setting up the Viessmann ViCare plugin v2.0.
|
|
5
|
+
This guide will walk you through setting up the Viessmann ViCare plugin v2.0.79 for Homebridge, including all the advanced features like intelligent caching, rate limiting protection, comprehensive configuration options, **complete localization support with custom names**, **CSV history logging**, **HTML diagnostic reports**, **energy system monitoring** (PV, battery, wallbox), and **heating schedule awareness** with visual bands in the HTML report, and **heat pump (Wärmepumpe) support** with automatic device detection.
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -1292,6 +1292,9 @@ sudo systemctl restart homebridge
|
|
|
1292
1292
|
|
|
1293
1293
|
## Changelog
|
|
1294
1294
|
|
|
1295
|
+
### v2.0.79 (2026-09-26)
|
|
1296
|
+
- feat: report gas forecast with real weather (Open-Meteo) and degree-day model calibrated on the boiler yearly counters; works with any amount of data
|
|
1297
|
+
|
|
1295
1298
|
### v2.0.78 (2026-09-26)
|
|
1296
1299
|
- fix: report device messages up to date again (with fault codes and history); seasonal-aware heating-curve, annual gas and comfort analyses
|
|
1297
1300
|
|
package/dist/settings.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare const PLUGIN_NAME = "homebridge-viessmann-vicare";
|
|
|
9
9
|
/**
|
|
10
10
|
* Plugin version for User-Agent and logging
|
|
11
11
|
*/
|
|
12
|
-
export declare const PLUGIN_VERSION = "2.0.
|
|
12
|
+
export declare const PLUGIN_VERSION = "2.0.79";
|
|
13
13
|
/**
|
|
14
14
|
* Default configuration values
|
|
15
15
|
*/
|
package/dist/settings.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-viessmann-vicare",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.79",
|
|
4
4
|
"description": "Complete Homebridge plugin for Viessmann ViCare heating systems with full control capabilities, advanced rate limiting protection, and intelligent cache management",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"scripts": {
|
package/viessmann-report.js
CHANGED
|
@@ -200,6 +200,15 @@ const STRINGS = {
|
|
|
200
200
|
thermalEffNote: 'Thermal efficiency >100% is possible for condensing boilers (latent heat recovery). Values >105% may indicate rounding in Viessmann API data.',
|
|
201
201
|
forecastNote: 'Annual estimate requires at least {min} days of gas data (currently {n}). Run with --days {min} or more. Use --gasPriceEur to set your tariff.',
|
|
202
202
|
forecastProjectionNote: 'Projection based on last {n} day(s) of data · gas price: €{price}/m³ · {trend}',
|
|
203
|
+
fcModelNote: 'Model from {n} days of gas data (since {from}): {base} m³/day hot water + {k} m³ per degree-day (heating, base 16 °C, R² {r2}) · price €{price}/m³ (--gasPriceEur)',
|
|
204
|
+
fcNoHeatingNote: '{n} days of gas data without heating days yet: only hot water is projected ({base} m³/day) · price €{price}/m³ (--gasPriceEur)',
|
|
205
|
+
fcWeatherNote: 'Real outdoor temperatures from Open-Meteo: next 30 days use the weather forecast ({f} days) and the same dates of last year; the annual estimate uses the weather of the last 365 days.',
|
|
206
|
+
fcNoLocation: 'Installation location unknown: add --lat and --lon to include the weather in the forecast.',
|
|
207
|
+
fcWeatherDown: 'Weather service not reachable: heating is not included in the projection.',
|
|
208
|
+
fcNeedHeating: 'needs some heating days',
|
|
209
|
+
fcCalNote: 'Degree-day model calibrated on the boiler yearly counters (Viessmann API): {base} m³/day hot water + {k} m³ per degree-day (heating, base 16 °C) · {n} days of detailed data · price €{price}/m³ (--gasPriceEur)',
|
|
210
|
+
fcRecentNote: 'Average of the last {n} days of gas data ({base} m³/day), without weather correction · price €{price}/m³ (--gasPriceEur)',
|
|
211
|
+
fcNeedWeather: 'needs weather data',
|
|
203
212
|
forecastTrendRising: '↑ Rising',
|
|
204
213
|
forecastTrendFalling: '↓ Falling',
|
|
205
214
|
forecastTrendStable: '→ Stable',
|
|
@@ -542,6 +551,15 @@ const STRINGS = {
|
|
|
542
551
|
thermalEffNote: 'Efficienza >100% possibile nelle caldaie a condensazione (recupero calore latente). Valori >105% possono indicare arrotondamenti nei dati API Viessmann.',
|
|
543
552
|
forecastNote: 'La stima annuale richiede almeno {min} giorni di dati gas (attualmente {n}). Usa --days {min} o più. Usa --gasPriceEur per impostare la tariffa.',
|
|
544
553
|
forecastProjectionNote: 'Proiezione basata sugli ultimi {n} giorni · prezzo gas: €{price}/m³ · {trend}',
|
|
554
|
+
fcModelNote: 'Modello da {n} giorni di dati gas (dal {from}): {base} m³/giorno acqua calda + {k} m³ per grado-giorno (riscaldamento, base 16 °C, R² {r2}) · prezzo €{price}/m³ (--gasPriceEur)',
|
|
555
|
+
fcNoHeatingNote: '{n} giorni di dati gas ancora senza giorni di riscaldamento: si proietta solo l\'acqua calda ({base} m³/giorno) · prezzo €{price}/m³ (--gasPriceEur)',
|
|
556
|
+
fcWeatherNote: 'Temperature esterne reali da Open-Meteo: i prossimi 30 giorni usano le previsioni meteo ({f} giorni) e le stesse date dell\'anno scorso; la stima annuale usa il meteo degli ultimi 365 giorni.',
|
|
557
|
+
fcNoLocation: 'Posizione dell\'impianto sconosciuta: aggiungi --lat e --lon per includere il meteo nella previsione.',
|
|
558
|
+
fcWeatherDown: 'Servizio meteo non raggiungibile: il riscaldamento non è incluso nella proiezione.',
|
|
559
|
+
fcNeedHeating: 'servono giorni di riscaldamento',
|
|
560
|
+
fcCalNote: 'Modello a gradi-giorno calibrato sui contatori annui della caldaia (API Viessmann): {base} m³/giorno acqua calda + {k} m³ per grado-giorno (riscaldamento, base 16 °C) · {n} giorni di dati dettagliati · prezzo €{price}/m³ (--gasPriceEur)',
|
|
561
|
+
fcRecentNote: 'Media degli ultimi {n} giorni di dati gas ({base} m³/giorno), senza correzione meteo · prezzo €{price}/m³ (--gasPriceEur)',
|
|
562
|
+
fcNeedWeather: 'servono i dati meteo',
|
|
545
563
|
forecastTrendRising: '↑ In aumento',
|
|
546
564
|
forecastTrendFalling: '↓ In calo',
|
|
547
565
|
forecastTrendStable: '→ Stabile',
|
|
@@ -1677,49 +1695,182 @@ const comfortEfficiency = (() => {
|
|
|
1677
1695
|
// Strategy: linear regression on last N days → extrapolate to 30/365 days.
|
|
1678
1696
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1679
1697
|
let gasForecast = null;
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
const
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1698
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1699
|
+
// GAS FORECAST (v2.0.78) — weather-normalised degree-day model
|
|
1700
|
+
// gas/day = base (hot water) + k × HDD, HDD = max(0, 16 °C − real daily mean)
|
|
1701
|
+
// • Daily gas comes from the boiler MONTHLY counters (day counters reset hours late
|
|
1702
|
+
// and under-count), using ALL CSV history, not only the report period.
|
|
1703
|
+
// • Real outdoor temperatures come from Open-Meteo for the installation location
|
|
1704
|
+
// (the boiler outdoor sensor is often biased by sun/wall heat).
|
|
1705
|
+
// • Next 30 days: 16-day weather forecast + same dates last year.
|
|
1706
|
+
// • Annual estimate: base × 365 + k × degree-days of the last 365 days.
|
|
1707
|
+
// Works with any amount of data; without heating days only hot water is projected.
|
|
1708
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1709
|
+
const { spawnSync } = require('child_process');
|
|
1710
|
+
function httpJson(url, headers) {
|
|
1711
|
+
const code = `fetch(${JSON.stringify(url)},{headers:${JSON.stringify(headers || {})}})` +
|
|
1712
|
+
`.then(r=>r.ok?r.text():Promise.reject(new Error('HTTP '+r.status)))` +
|
|
1713
|
+
`.then(t=>process.stdout.write(t)).catch(e=>{process.stderr.write(String(e.message||e));process.exit(1)})`;
|
|
1714
|
+
const r = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', timeout: 25000, maxBuffer: 20 * 1024 * 1024 });
|
|
1715
|
+
if (r.status !== 0) throw new Error((r.stderr || 'fetch failed').slice(0, 200));
|
|
1716
|
+
return JSON.parse(r.stdout);
|
|
1717
|
+
}
|
|
1718
|
+
const localDay = (d) => { const x = new Date(d); return `${x.getFullYear()}-${String(x.getMonth()+1).padStart(2,'0')}-${String(x.getDate()).padStart(2,'0')}`; };
|
|
1719
|
+
const addDays = (ds, n) => { const d = new Date(ds + 'T12:00:00'); d.setDate(d.getDate() + n); return localDay(d); };
|
|
1720
|
+
|
|
1721
|
+
function getLocation() {
|
|
1722
|
+
const cliLat = parseFloat(getArg('--lat', '')), cliLon = parseFloat(getArg('--lon', ''));
|
|
1723
|
+
if (!isNaN(cliLat) && !isNaN(cliLon)) return { lat: cliLat, lon: cliLon, src: 'cli' };
|
|
1724
|
+
const locFile = path.join(HB_PATH, `viessmann-location-${INSTALLATION_ID || 'default'}.json`);
|
|
1725
|
+
try { const l = JSON.parse(fs.readFileSync(locFile, 'utf8')); if (l.lat && l.lon) return { ...l, src: 'cache' }; } catch (_) {}
|
|
1726
|
+
if (!INSTALLATION_ID) return null;
|
|
1727
|
+
try {
|
|
1728
|
+
const tokens = JSON.parse(fs.readFileSync(path.join(HB_PATH, 'viessmann-tokens.json'), 'utf8'));
|
|
1729
|
+
const find = (o) => { if (o && typeof o === 'object') { for (const [k, v] of Object.entries(o)) { if ((k === 'accessToken' || k === 'access_token') && typeof v === 'string') return v; const r = find(v); if (r) return r; } } return null; };
|
|
1730
|
+
const tok = find(tokens);
|
|
1731
|
+
if (!tok) return null;
|
|
1732
|
+
const j = httpJson(`https://api.viessmann-climatesolutions.com/iot/v2/equipment/installations/${INSTALLATION_ID}`, { Authorization: 'Bearer ' + tok });
|
|
1733
|
+
const g = j?.data?.address?.geolocation;
|
|
1734
|
+
if (g?.latitude && g?.longitude) {
|
|
1735
|
+
const loc = { lat: +(+g.latitude).toFixed(3), lon: +(+g.longitude).toFixed(3) };
|
|
1736
|
+
try { fs.writeFileSync(locFile, JSON.stringify(loc)); } catch (_) {}
|
|
1737
|
+
return { ...loc, src: 'api' };
|
|
1738
|
+
}
|
|
1739
|
+
} catch (e) { process.stderr.write(`[forecast] location lookup failed: ${e.message}\n`); }
|
|
1740
|
+
return null;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Daily mean temperatures (real), cached on disk: { 'YYYY-MM-DD': °C }
|
|
1744
|
+
function getDailyTemps(loc, fromDay, toDay) {
|
|
1745
|
+
const cacheFile = path.join(HB_PATH, `viessmann-weather-${loc.lat}_${loc.lon}.json`);
|
|
1746
|
+
let cache = {};
|
|
1747
|
+
try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch (_) {}
|
|
1748
|
+
const today = localDay(new Date());
|
|
1749
|
+
const q = `latitude=${loc.lat}&longitude=${loc.lon}&daily=temperature_2m_mean&timezone=auto`;
|
|
1750
|
+
// Archive (final data) for days older than 7 days that are not cached yet
|
|
1751
|
+
const archEnd = addDays(today, -7);
|
|
1752
|
+
let missingFrom = null;
|
|
1753
|
+
for (let d = fromDay; d <= archEnd; d = addDays(d, 1)) { if (cache[d] === undefined) { missingFrom = d; break; } }
|
|
1754
|
+
if (missingFrom) {
|
|
1755
|
+
try {
|
|
1756
|
+
const j = httpJson(`https://archive-api.open-meteo.com/v1/archive?${q}&start_date=${missingFrom}&end_date=${archEnd}`);
|
|
1757
|
+
(j.daily?.time || []).forEach((t, i) => { const v = j.daily.temperature_2m_mean[i]; if (v !== null && v !== undefined) cache[t] = v; });
|
|
1758
|
+
} catch (e) { process.stderr.write(`[forecast] archive fetch failed: ${e.message}\n`); }
|
|
1759
|
+
}
|
|
1760
|
+
// Recent days + 16-day forecast (not cached: values change)
|
|
1761
|
+
const recent = {};
|
|
1762
|
+
try {
|
|
1763
|
+
const j = httpJson(`https://api.open-meteo.com/v1/forecast?${q}&past_days=10&forecast_days=16`);
|
|
1764
|
+
(j.daily?.time || []).forEach((t, i) => { const v = j.daily.temperature_2m_mean[i]; if (v !== null && v !== undefined) recent[t] = v; });
|
|
1765
|
+
} catch (e) { process.stderr.write(`[forecast] forecast fetch failed: ${e.message}\n`); }
|
|
1766
|
+
try { fs.writeFileSync(cacheFile, JSON.stringify(cache)); } catch (_) {}
|
|
1767
|
+
return { ...cache, ...recent };
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Daily gas (heating + DHW) from monthly counters, over the WHOLE CSV history
|
|
1771
|
+
const gasDailyAll = (() => {
|
|
1772
|
+
const last = {};
|
|
1773
|
+
for (const r of rows) {
|
|
1774
|
+
if (r.accessory !== 'boiler' || (r.event_type && r.event_type !== 'snapshot') || !r.timestamp) continue;
|
|
1775
|
+
if (r.gas_heating_month_m3 === '' && r.gas_dhw_month_m3 === '') continue;
|
|
1776
|
+
const d = localDay(r.timestamp);
|
|
1777
|
+
const v = (parseFloat(r.gas_heating_month_m3) || 0) + (parseFloat(r.gas_dhw_month_m3) || 0);
|
|
1778
|
+
if (!last[d] || r.timestamp > last[d].ts) last[d] = { ts: r.timestamp, v };
|
|
1779
|
+
}
|
|
1780
|
+
const days = Object.keys(last).sort();
|
|
1781
|
+
const out = {};
|
|
1782
|
+
for (let i = 1; i < days.length; i++) {
|
|
1783
|
+
const d = days[i], p = days[i - 1];
|
|
1784
|
+
if (addDays(p, 1) !== d) continue; // gap in data: skip
|
|
1785
|
+
const newMonth = d.slice(0, 7) !== p.slice(0, 7);
|
|
1786
|
+
const delta = newMonth ? last[d].v : last[d].v - last[p].v;
|
|
1787
|
+
if (delta >= 0 && delta < 50) out[d] = delta; // ignore glitches
|
|
1788
|
+
}
|
|
1789
|
+
return out;
|
|
1790
|
+
})();
|
|
1791
|
+
|
|
1792
|
+
const HDD_BASE = parseFloat(getArg('--hddBase', '16'));
|
|
1793
|
+
const GAS_PRICE_EUR = parseFloat(getArg('--gasPriceEur', process.env.GAS_PRICE_EUR || '0.90'));
|
|
1794
|
+
const gasModelDays = Object.keys(gasDailyAll).sort();
|
|
1795
|
+
if (gasModelDays.length >= 1) {
|
|
1796
|
+
let loc = null, temps = {};
|
|
1797
|
+
try { loc = getLocation(); } catch (_) {}
|
|
1798
|
+
const today = localDay(new Date());
|
|
1799
|
+
if (loc) {
|
|
1800
|
+
try { temps = getDailyTemps(loc, addDays(today, -400), today); } catch (_) {}
|
|
1801
|
+
}
|
|
1802
|
+
const hdd = (t) => Math.max(0, HDD_BASE - t);
|
|
1803
|
+
// Fit gas = a + b·HDD on days with both gas and temperature
|
|
1804
|
+
const pts = gasModelDays.filter(d => temps[d] !== undefined).map(d => [hdd(temps[d]), gasDailyAll[d]]);
|
|
1805
|
+
const heatingDays = pts.filter(p => p[0] >= 3).length;
|
|
1806
|
+
let a, b = null, r2 = null;
|
|
1807
|
+
const summer = pts.filter(p => p[0] === 0).map(p => p[1]);
|
|
1808
|
+
if (heatingDays >= 3) {
|
|
1809
|
+
const n = pts.length, mx = pts.reduce((s, p) => s + p[0], 0) / n, my = pts.reduce((s, p) => s + p[1], 0) / n;
|
|
1810
|
+
const sxy = pts.reduce((s, p) => s + (p[0] - mx) * (p[1] - my), 0), sxx = pts.reduce((s, p) => s + (p[0] - mx) ** 2, 0);
|
|
1811
|
+
b = sxx > 0 ? Math.max(0, sxy / sxx) : 0;
|
|
1812
|
+
a = Math.max(0, my - b * mx);
|
|
1813
|
+
if (summer.length >= 5) a = summer.reduce((s, v) => s + v, 0) / summer.length; // hot water base from warm days
|
|
1814
|
+
const syy = pts.reduce((s, p) => s + (p[1] - my) ** 2, 0);
|
|
1815
|
+
const sse = pts.reduce((s, p) => s + (p[1] - (a + b * p[0])) ** 2, 0);
|
|
1816
|
+
r2 = syy > 0 ? Math.max(0, 1 - sse / syy) : null;
|
|
1817
|
+
} else if (pts.length) {
|
|
1818
|
+
// weather available but no heating days yet: hot water only
|
|
1819
|
+
const vals = summer.length ? summer : pts.map(p => p[1]);
|
|
1820
|
+
a = vals.reduce((s, v) => s + v, 0) / vals.length;
|
|
1821
|
+
} else {
|
|
1822
|
+
// no weather data: plain average of the last 30 days
|
|
1823
|
+
const recent = gasModelDays.slice(-30).map(d => gasDailyAll[d]);
|
|
1824
|
+
a = recent.reduce((s, v) => s + v, 0) / recent.length;
|
|
1825
|
+
}
|
|
1826
|
+
// Calibration on the boiler's own yearly counters (Viessmann API summary):
|
|
1827
|
+
// heating currentYear ÷ real degree-days since 1 January gives a robust k even when
|
|
1828
|
+
// the CSV history covers only a few mild weeks; DHW currentYear gives the base.
|
|
1829
|
+
let calibrated = false;
|
|
1830
|
+
if (apiSummary && apiSummary.timestamp && Object.keys(temps).length) {
|
|
1831
|
+
const end = localDay(apiSummary.timestamp);
|
|
1832
|
+
let sh = 0, cnt = 0, tot = 0;
|
|
1833
|
+
for (let d = `${end.slice(0, 4)}-01-01`; d <= end; d = addDays(d, 1)) {
|
|
1834
|
+
tot++;
|
|
1835
|
+
if (temps[d] !== undefined) { sh += hdd(temps[d]); cnt++; }
|
|
1836
|
+
}
|
|
1837
|
+
if (apiSummary.gasHeatYear > 0 && cnt >= tot * 0.9 && sh > 50) {
|
|
1838
|
+
b = apiSummary.gasHeatYear / (sh * tot / cnt);
|
|
1839
|
+
calibrated = true;
|
|
1840
|
+
}
|
|
1841
|
+
if (apiSummary.gasDhwYear > 0 && tot > 0) a = apiSummary.gasDhwYear / tot;
|
|
1842
|
+
}
|
|
1843
|
+
// Next 30 days: forecast temps, else same date last year
|
|
1844
|
+
let month30 = 0, usedForecastDays = 0;
|
|
1845
|
+
for (let i = 1; i <= 30; i++) {
|
|
1846
|
+
const d = addDays(today, i);
|
|
1847
|
+
let t = temps[d];
|
|
1848
|
+
if (t !== undefined) usedForecastDays++; else t = temps[addDays(d, -365)];
|
|
1849
|
+
month30 += a + (b !== null && t !== undefined ? b * hdd(t) : 0);
|
|
1850
|
+
}
|
|
1851
|
+
// Annual: real weather of the last 365 days
|
|
1852
|
+
let annual = null, hddYear = 0, tempDays = 0;
|
|
1853
|
+
for (let i = 1; i <= 365; i++) { const t = temps[addDays(today, -i)]; if (t !== undefined) { hddYear += hdd(t); tempDays++; } }
|
|
1854
|
+
if (b !== null && tempDays >= 330) annual = a * 365 + b * hddYear * 365 / tempDays;
|
|
1855
|
+
const periodAvg = gasDays.length ? gasDays.reduce((s, d) => s + (gasDailyAll[d] ?? 0), 0) / gasDays.length : a;
|
|
1711
1856
|
gasForecast = {
|
|
1712
|
-
avgPerDay:
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
month30
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1857
|
+
avgPerDay: (gasModelDays.length ? gasModelDays.slice(-Math.max(1, Math.min(DAYS, gasModelDays.length))).reduce((s, d) => s + gasDailyAll[d], 0) / Math.max(1, Math.min(DAYS, gasModelDays.length)) : periodAvg).toFixed(2),
|
|
1858
|
+
month30: month30.toFixed(1),
|
|
1859
|
+
annualEst: annual !== null ? annual.toFixed(0) : null,
|
|
1860
|
+
costMonth: (month30 * GAS_PRICE_EUR).toFixed(2),
|
|
1861
|
+
costAnnual: annual !== null ? (annual * GAS_PRICE_EUR).toFixed(0) : null,
|
|
1862
|
+
gasPrice: GAS_PRICE_EUR.toFixed(2),
|
|
1863
|
+
daysUsed: gasModelDays.length,
|
|
1864
|
+
firstDay: gasModelDays[0],
|
|
1865
|
+
base: a.toFixed(2),
|
|
1866
|
+
perHdd: b !== null ? b.toFixed(3) : null,
|
|
1867
|
+
r2: r2 !== null ? r2.toFixed(2) : null,
|
|
1868
|
+
heatingDays,
|
|
1869
|
+
calibrated,
|
|
1870
|
+
weather: loc ? (Object.keys(temps).length ? 'ok' : 'unavailable') : 'nolocation',
|
|
1871
|
+
forecastDays: usedForecastDays,
|
|
1872
|
+
hasEnoughForAnnual: annual !== null,
|
|
1873
|
+
trend: 'stable',
|
|
1723
1874
|
};
|
|
1724
1875
|
}
|
|
1725
1876
|
|
|
@@ -2222,17 +2373,21 @@ ${apiSummary ? `
|
|
|
2222
2373
|
${gasForecast ? `
|
|
2223
2374
|
<div class="box">
|
|
2224
2375
|
<h2>${T('sectionGasForecast')}</h2>
|
|
2225
|
-
<p class="note" style="margin-bottom:14px">${
|
|
2376
|
+
<p class="note" style="margin-bottom:14px">${gasForecast.weather !== 'ok'
|
|
2377
|
+
? T('fcRecentNote', {n: Math.min(30, gasForecast.daysUsed), base: gasForecast.base, price: gasForecast.gasPrice})
|
|
2378
|
+
: gasForecast.perHdd !== null
|
|
2379
|
+
? T(gasForecast.calibrated ? 'fcCalNote' : 'fcModelNote', {n: gasForecast.daysUsed, from: gasForecast.firstDay, base: gasForecast.base, k: gasForecast.perHdd, r2: gasForecast.r2 ?? '—', price: gasForecast.gasPrice})
|
|
2380
|
+
: T('fcNoHeatingNote', {n: gasForecast.daysUsed, base: gasForecast.base, price: gasForecast.gasPrice})}</p>
|
|
2226
2381
|
<div class="grid">
|
|
2227
2382
|
${sc(T('avgConsPerDay'), gasForecast.avgPerDay, ' m³')}
|
|
2228
|
-
${sc(T('projNext30'), gasForecast.month30, ' m³', badge(
|
|
2383
|
+
${sc(T('projNext30'), gasForecast.month30, ' m³', badge('neutral', '≈ €' + gasForecast.costMonth))}
|
|
2229
2384
|
${gasForecast.hasEnoughForAnnual
|
|
2230
2385
|
? sc(T('annualEstLabel'), gasForecast.annualEst, ' m³', badge('neutral', '≈ €' + gasForecast.costAnnual))
|
|
2231
|
-
: sc(T('annualEstLabel'), 'N/A', '', badge('neutral', T('
|
|
2386
|
+
: sc(T('annualEstLabel'), 'N/A', '', badge('neutral', T(gasForecast.weather !== 'ok' ? 'fcNeedWeather' : 'fcNeedHeating')))}
|
|
2232
2387
|
</div>
|
|
2233
|
-
<p class="note" style="margin-top:10px">ℹ️ ${gasForecast.
|
|
2234
|
-
|
|
2235
|
-
|
|
2388
|
+
<p class="note" style="margin-top:10px">ℹ️ ${gasForecast.weather === 'ok'
|
|
2389
|
+
? T('fcWeatherNote', {f: gasForecast.forecastDays})
|
|
2390
|
+
: gasForecast.weather === 'nolocation' ? T('fcNoLocation') : T('fcWeatherDown')}</p>
|
|
2236
2391
|
</div>` : ''}
|
|
2237
2392
|
|
|
2238
2393
|
${energyRows.length >= 1 ? `
|