Redesign approach for CPU usage tracking

The previous implementation maintained three exponential moving averages
(1/5/15-minute) of CPU busy-fraction, sampled every 5 seconds. This had
two problems:

1. Wrong semantics: the metric was labelled "load_avg" (a Unix concept
   measuring run-queue depth), but the tracker actually measures CPU
   busy-time as a fraction of FreeRTOS ticks — i.e. utilization, not
   load.  The name was misleading.

2. Poor responsiveness: the 1-minute EMA (DECAY = exp(-5/60) ≈ 0.920)
   has a ~60-second time constant.  On a live web panel, this made the
   metric appear frozen, especially during short bursts of activity
   (e.g. an HTTP request from the panel itself).

This commit replaces the EMA with a 64-sample simple moving average
(SMA) over a compact uint8_t circular buffer. The sample interval is
60 s / 64 = 937,500 µs, so the window covers exactly 60 seconds:

Algorithm:
- Every 937,500 µs (= 60 s / 64), the FreeRTOS tick delta (busy vs
  idle ticks on core 0) is computed and stored as a uint8_t
  (0–255 = 0–100% utilization).
- The circular buffer holds 64 samples, spanning exactly 60 seconds
  (64 × 937,500 µs = 60,000,000 µs). The timer interval is derived
  directly from the window size: 60 000 000 / SMA_WINDOW µs.
- The index wraps with a bitwise AND (& 63) instead of modulo, since
  64 is a power of 2.
- The running sum is a uint16_t (max 64 × 255 = 16 320, fits easily).
- The result _sma_avg is a volatile float written atomically by the
  esp_timer task (core 0) and read from the main loop (core 1).
  No spinlock is needed: on Xtensa LX7, a 32-bit aligned float store
  is a single instruction.

The 60-second window smooths out short bursts (e.g. WiFi/HTTP spikes)
while reacting to sustained load changes within ~10–15 seconds.

Naming:
- The public API is now getCore0Util() returning a float in [0.0, 1.0].
  The name explicitly identifies which core is measured (core 0, which
  runs WiFi, MQTT, HTTP, and LwIP — see task_pinning.c).
- JSON keys: "load_avg" (array) → "core0_util" (scalar float, percent)
- HistorySample field: load_avg1_pct → core0_util_pct
- StatsHistory series key: "cpu_load" → "core0_util"
- Web panel label: "Load Avg" → "Core0 Util"

The history snapshot (once per minute) reads the same _sma_avg value,
which at that point represents the rolling average of the last 60
seconds — exactly one history interval.
This commit is contained in:
Valentin V. Bartenev
2026-05-10 06:26:08 +03:00
rodzic 5bf017be1b
commit a9273f02bb
6 zmienionych plików z 47 dodań i 53 usunięć
+9 -9
Wyświetl plik
@@ -211,8 +211,8 @@ bool buildPointValue(const HistorySample& sample, const HistorySample* previous,
value = static_cast<int>(sample.battery_mv);
return true;
}
if (strcmp(series, "cpu_load") == 0) {
value = static_cast<int>(sample.load_avg1_pct);
if (strcmp(series, "core0_util") == 0) {
value = static_cast<int>(sample.core0_util_pct);
return true;
}
if (strcmp(series, "memory") == 0) {
@@ -313,8 +313,8 @@ const char* seriesTitle(const char* series) {
if (strcmp(series, "battery") == 0) {
return "Battery";
}
if (strcmp(series, "cpu_load") == 0) {
return "CPU Load";
if (strcmp(series, "core0_util") == 0) {
return "Core0 Util";
}
if (strcmp(series, "memory") == 0) {
return "Heap Free";
@@ -362,7 +362,7 @@ const char* seriesUnit(const char* series) {
if (strcmp(series, "battery") == 0) {
return "mV";
}
if (strcmp(series, "cpu_load") == 0) {
if (strcmp(series, "core0_util") == 0) {
return "pct";
}
if (strcmp(series, "memory") == 0) {
@@ -649,7 +649,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
long gps_lon_e6 = 0;
unsigned sensor_flags = 0;
unsigned gps_satellites = 0;
unsigned load_avg1_pct = 0;
unsigned core0_util_pct = 0;
int parsed = sscanf(line,
"%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%u,%u,%u",
@@ -681,7 +681,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
&gps_lon_e6,
&sensor_flags,
&gps_satellites,
&load_avg1_pct);
&core0_util_pct);
if (parsed == 29 || parsed == 28) {
memset(&sample, 0, sizeof(sample));
sample.epoch_secs = static_cast<uint32_t>(epoch_secs);
@@ -715,7 +715,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
sample.gps_satellites = static_cast<uint8_t>(gps_satellites);
sample.flags = static_cast<uint8_t>(flags);
sample.battery_pct = -1;
sample.load_avg1_pct = (parsed == 29) ? static_cast<uint8_t>(load_avg1_pct) : 0;
sample.core0_util_pct = (parsed == 29) ? static_cast<uint8_t>(core0_util_pct) : 0;
return true;
}
@@ -1129,7 +1129,7 @@ void StatsHistory::flushSummaryLog() {
static_cast<long>(latest.gps_lon_e6),
static_cast<unsigned>(latest.sensor_flags),
static_cast<unsigned>(latest.gps_satellites),
static_cast<unsigned>(latest.load_avg1_pct));
static_cast<unsigned>(latest.core0_util_pct));
File latest_file = openArchiveWriteWithRecovery(_archive, kSummaryLatestPath);
if (!latest_file) {
+1 -1
Wyświetl plik
@@ -37,7 +37,7 @@ struct HistorySample {
uint8_t gps_satellites;
uint8_t flags;
int8_t battery_pct;
uint8_t load_avg1_pct;
uint8_t core0_util_pct;
};
struct HistoryEvent {
+5 -14
Wyświetl plik
@@ -531,8 +531,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
:root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); }
.metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
.metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); }
.metric-spread { display:flex; justify-content:space-between; margin-top:4px; }
.metric-spread span { font-size:16px; font-weight:700; color:var(--text); }
.events-table-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; }
.events-table { width:100%; border-collapse:collapse; }
.events-table th, .events-table td { padding:10px 12px; text-align:left; font-size:13px; }
@@ -1490,14 +1488,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
<div class="metric-grid core-metrics">
${renderMetric("Uptime", formatDuration(core.uptime_secs))}
${renderMetric("Battery", (core.battery_mv || 0) + " mV")}
${Array.isArray(core.load_avg) ? `<div class="metric">
<div class="metric-label">Load Avg</div>
<div class="metric-spread">
<span>${core.load_avg[0].toFixed(2)}</span>
<span>${core.load_avg[1].toFixed(2)}</span>
<span>${core.load_avg[2].toFixed(2)}</span>
</div>
</div>` : ""}
${renderMetric("Core0 Util", (core.core0_util || 0) + " %")}
${renderMetric("Queue", String(core.queue_len ?? 0))}
${renderMetric("Errors", String(core.errors ?? 0))}
</div>
@@ -1779,7 +1770,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
const kTrendFmt = {
battery: [v => Math.round(v), "mV"],
voltage: [v => (v/100).toFixed(2), "V"],
cpu_load: [v => Math.round(v), "%"],
core0_util: [v => v, "%"],
memory: [v => formatBytes(v), ""],
packets: [v => Math.round(v), "pkts"],
error_rate: [v => (v/10).toFixed(1), "%"],
@@ -1827,7 +1818,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
if (recent >= 750) return "#d7a531"; // high
return "#2f8f4e"; // normal
}
if (key === "cpu_load") return "#e07b39";
if (key === "core0_util") return "#e07b39";
if (key === "error_rate") return "#ef4444";
if (key === "gps_satellites") return "#2f8f4e";
if (key === "signal") return "#3b82f6";
@@ -1847,7 +1838,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
return baseColor || "#2f8f4e";
}
function sparkValueRange(key, values) {
if (key === "cpu_load") {
if (key === "core0_util") {
return { min: 0, max: Math.max(10, ...values) };
}
if (key === "packets" || key === "gps_satellites") {
@@ -2110,7 +2101,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
const sensors = summaryPayload && summaryPayload.sensors ? summaryPayload.sensors : null;
const gpsEnabled = !!(sensors && sensors.gps_enabled === true);
const mcuTempPresent = !!(sensors && Number.isFinite(sensors.mcu_temp_c));
const order = ["battery", "cpu_load", "memory", "packets", "error_rate", "signal", "noise_floor"];
const order = ["battery", "core0_util", "memory", "packets", "error_rate", "signal", "noise_floor"];
if (mcuTempPresent) {
order.splice(1, 0, "mcu_temp");
}