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 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.78
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.78 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.
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
 
@@ -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.78";
12
+ export declare const PLUGIN_VERSION = "2.0.79";
13
13
  /**
14
14
  * Default configuration values
15
15
  */
package/dist/settings.js CHANGED
@@ -12,7 +12,7 @@ exports.PLUGIN_NAME = 'homebridge-viessmann-vicare';
12
12
  /**
13
13
  * Plugin version for User-Agent and logging
14
14
  */
15
- exports.PLUGIN_VERSION = '2.0.78';
15
+ exports.PLUGIN_VERSION = '2.0.79';
16
16
  /**
17
17
  * Default configuration values
18
18
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-viessmann-vicare",
3
- "version": "2.0.78",
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": {
@@ -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
- if (gasDays.length >= 3) {
1681
- // Use all available days; total = heating + dhw
1682
- const totalPerDay = gasDays.map(d => gasPerDay[d].heating + gasPerDay[d].dhw);
1683
- const n = totalPerDay.length;
1684
- // Simple linear regression: y = a + b*x (x = day index)
1685
- const xMean = (n - 1) / 2;
1686
- const yMean = totalPerDay.reduce((s, v) => s + v, 0) / n;
1687
- let num = 0, den = 0;
1688
- totalPerDay.forEach((y, i) => { num += (i - xMean) * (y - yMean); den += (i - xMean) ** 2; });
1689
- const slope = den !== 0 ? num / den : 0;
1690
- const intercept = yMean - slope * xMean;
1691
- // Project from today (index = n-1) forward
1692
- const projectDay = (offset) => Math.max(0, intercept + slope * (n - 1 + offset));
1693
- // Monthly: sum of next 30 days
1694
- let monthSum = 0;
1695
- for (let i = 1; i <= 30; i++) monthSum += projectDay(i);
1696
- // With few days a regression extrapolated 30 days ahead is unreliable (a falling
1697
- // trend clamps to ~0). Below 14 days use the period average instead.
1698
- if (n < 14) monthSum = yMean * 30;
1699
- // Annual: sum of next 365 days (approximated as 30-day avg × 12 with seasonal note)
1700
- // For simplicity: annualise the period avg × 365 (more stable than long regression)
1701
- const periodAvgPerDay = yMean;
1702
- const annualEst = periodAvgPerDay * 365;
1703
- // Cost estimate (€): use --gasPriceEur param or env (default 0.90 €/m³ — Italian average)
1704
- const GAS_PRICE = parseFloat(getArg('--gasPriceEur', process.env.GAS_PRICE_EUR || '0.90'));
1705
- // Annual estimate requires at least 14 days to avoid misleading projections
1706
- // from short atypical periods (e.g. unusually cold/warm week).
1707
- // A yearly figure extrapolated from a few summer weeks (DHW only) is meaningless:
1708
- // require data covering most of a year.
1709
- const ANNUAL_MIN_DAYS = 300;
1710
- const hasEnoughForAnnual = n >= ANNUAL_MIN_DAYS;
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: yMean.toFixed(2),
1713
- trend: slope > 0.05 ? 'rising' : slope < -0.05 ? 'falling' : 'stable',
1714
- trendSlope: slope.toFixed(3),
1715
- month30: monthSum.toFixed(1),
1716
- annualEst: hasEnoughForAnnual ? annualEst.toFixed(0) : null,
1717
- costMonth: (monthSum * GAS_PRICE).toFixed(2),
1718
- costAnnual: hasEnoughForAnnual ? (annualEst * GAS_PRICE).toFixed(2) : null,
1719
- gasPrice: GAS_PRICE.toFixed(2),
1720
- daysUsed: n,
1721
- annualMinDays: ANNUAL_MIN_DAYS,
1722
- hasEnoughForAnnual,
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">${T('forecastProjectionNote', {n: gasForecast.daysUsed, price: gasForecast.gasPrice, trend: gasForecast.trend === 'rising' ? T('forecastTrendRising') : gasForecast.trend === 'falling' ? T('forecastTrendFalling') : T('forecastTrendStable')})}</p>
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(gasForecast.trend === 'rising' ? 'warn' : 'good', '≈ €' + gasForecast.costMonth))}
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('needDaysShort',{min:gasForecast.annualMinDays,n:gasForecast.daysUsed})))}
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.hasEnoughForAnnual
2234
- ? 'Annual estimate uses period average × 365 — seasonal variations not accounted for.'
2235
- : T('forecastNote', {min: gasForecast.annualMinDays, n: gasForecast.daysUsed})}</p>
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 ? `