Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Guardian windowed metrics averaging.** Added windowed averaging of stochastic/noisy metrics (hashrate, temperature, power, and hardware error percentage) inside the Guardian governor loop, preventing false-positive throttling from transient dips or peaks. The averaging window can be configured via a new global setting in the general tab (defaults to 30 seconds, set to 0 to disable).
- **NMAxe miner family (NMAxe / NMAxeGamma / NMQAxe++).** Support for the NMAxe
AxeOS fork, whose REST surface is fully nested — `GET /api/system/info` groups
`power` / `temps` / `asic` / `miner` / `identity` / `stratum` / `fans[]` — with
Expand Down
6 changes: 6 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ class GuardianCfg:
# after a live frequency change before the next decision is taken.
interval_seconds: int = 300

# Time window (in seconds) used by the governor to average hashrate,
# temperature, power, and hardware error percentage metrics, preventing
# false-positive throttling from transient dips/peaks. Set to 0 to disable
# and use instantaneous values.
hashrate_average_window_seconds: int = 30

# ---- Control thresholds (the friend's field-tested values). ----
# VR temperature is the primary lever: nothing else in MinerWatch
# governs it in a closed loop (the fan PID watches the chip, the
Expand Down
30 changes: 28 additions & 2 deletions backend/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
best_difficulty REAL,
pool_url TEXT,
worker TEXT,
error_pct REAL,
raw TEXT, -- original payload as JSON
FOREIGN KEY (miner_id) REFERENCES miners(id) ON DELETE CASCADE
);
Expand Down Expand Up @@ -461,6 +462,8 @@ def _init_db_sync() -> None:
"ALTER TABLE miners ADD COLUMN ambient_sensor_name TEXT",
# Per-trophy dashboard visibility (see block_finds DDL).
"ALTER TABLE block_finds ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0",
# Hardware error percentage column in metrics table.
"ALTER TABLE metrics ADD COLUMN error_pct REAL",
]:
try:
conn.execute(column_def)
Expand Down Expand Up @@ -693,8 +696,8 @@ async def insert_metric(miner_id: int, ts: int, sample: dict[str, Any]) -> None:
INSERT INTO metrics
(miner_id, ts, hashrate_ths, power_w, temp_chip_c, temp_vr_c,
fan_rpm, fan_pct, frequency_mhz, voltage_mv, uptime_s,
accepted, rejected, best_difficulty, pool_url, worker)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
accepted, rejected, best_difficulty, pool_url, worker, error_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
miner_id,
Expand All @@ -713,6 +716,7 @@ async def insert_metric(miner_id: int, ts: int, sample: dict[str, Any]) -> None:
sample.get("best_difficulty"),
sample.get("pool_url"),
sample.get("worker"),
sample.get("error_pct"),
),
)
if raw is not None:
Expand Down Expand Up @@ -752,6 +756,28 @@ async def get_latest_raw(miner_id: int) -> dict[str, Any] | None:
return dict(row) if row else None


async def get_recent_metrics_average(miner_id: int, window_seconds: int) -> dict[str, float | None]:
"""Calculate the average hashrate, power, chip temp, VR temp, and error rate over the last N seconds."""
cutoff = int(time.time()) - window_seconds
sql = (
"SELECT AVG(hashrate_ths), AVG(power_w), AVG(temp_chip_c), AVG(temp_vr_c), AVG(error_pct) "
"FROM metrics WHERE miner_id = ? AND ts >= ?"
)
async with connect() as conn:
async with conn.execute(sql, (miner_id, cutoff)) as cur:
row = await cur.fetchone()
if row:
return {
"hashrate_ths": row[0],
"power_w": row[1],
"temp_chip_c": row[2],
"temp_vr_c": row[3],
"error_pct": row[4],
}
return {}



# ---------- Ambient (room) temperature time-series ----------

async def insert_ambient_metric(sensor_id: str, ts: int, temp_c: float) -> None:
Expand Down
67 changes: 49 additions & 18 deletions backend/guardian.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,16 +523,47 @@ async def _govern_one(self, miner: dict, sample: MinerSample, gcfg, cfg) -> None
# behaviour. In chip mode the chip is also driven by the fan PID and the
# 75°C watchdog, so the governor only bites once the fan is saturated.
source = "chip" if str(miner.get("guardian_temp_source") or "").lower() == "chip" else "vr"
temp_c = sample.temp_chip_c if source == "chip" else sample.temp_vr_c
source_label = "Chip" if source == "chip" else "VR"

# Effective hashrate (TH/s) and the ASIC hardware-error counter — the
# signals behind the regression brake. ``hashrate_ths`` is AxeOS's
# reported (real) hashrate; ``hw_errors`` is the summed per-ASIC invalid-
# nonce count, which climbs when an overclock starts producing garbage
# (those bad nonces crater real hashrate but never reach the pool, so the
# reject-% term stays blind to them).
hashrate_ths = sample.hashrate_ths
# Fetch recent averages over the configured window to avoid transient dips/spikes
window = int(getattr(gcfg, "hashrate_average_window_seconds", 30))
avg_metrics = {}
if window > 0:
try:
avg_metrics = await db.get_recent_metrics_average(miner_id, window)
except Exception:
log.exception("guardian: failed to fetch recent averages for miner=%s", miner.get("name"))

# Fallback to instantaneous values if not found or window is 0
hashrate_ths = avg_metrics.get("hashrate_ths")
if hashrate_ths is None:
hashrate_ths = sample.hashrate_ths

temp_chip_c = avg_metrics.get("temp_chip_c")
if temp_chip_c is None:
temp_chip_c = sample.temp_chip_c

temp_vr_c = avg_metrics.get("temp_vr_c")
if temp_vr_c is None:
temp_vr_c = sample.temp_vr_c

power_w = avg_metrics.get("power_w")
if power_w is None:
power_w = sample.power_w

error_pct = avg_metrics.get("error_pct")
if error_pct is None:
error_pct = sample.error_pct

temp_c = temp_chip_c if source == "chip" else temp_vr_c

# Effective hashrate (TH/s) and the ASIC hardware-error percentage (error_pct)
# — the signals behind the regression brake.
#
# ``hashrate_ths`` is AxeOS's reported (real) hashrate.
# ``hw_errors`` is the cumulative summed per-ASIC invalid-nonce count, which climbs
# when an overclock starts producing garbage (cratering real hashrate while the
# pool-reject % stays blind). We track ``hw_errors`` as a delta for telemetry.
hw_errors = sample.hw_errors
err_delta = None
if (
Expand Down Expand Up @@ -619,8 +650,8 @@ async def _govern_one(self, miner: dict, sample: MinerSample, gcfg, cfg) -> None
)
valid_hr = bool(can_validate and hashrate_ths >= expected_ths * float(gcfg.valid_pct))
error_high = (
sample.error_pct is not None
and float(sample.error_pct) > float(gcfg.error_pct_max)
error_pct is not None
and float(error_pct) > float(gcfg.error_pct_max)
)
# Either signal means "unstable": back off (frequency-only) or cure with
# voltage (co-tuner). The ASIC error % climbs when pushing frequency at
Expand All @@ -632,7 +663,7 @@ async def _govern_one(self, miner: dict, sample: MinerSample, gcfg, cfg) -> None
)
tele["expected_ths"] = round(expected_ths, 2) if expected_ths is not None else None
tele["valid"] = valid_hr if can_validate else None
tele["error_pct"] = round(sample.error_pct, 2) if sample.error_pct is not None else None
tele["error_pct"] = round(error_pct, 2) if error_pct is not None else None

# ---- Phase 2: voltage co-tuner path (per-miner opt-in) ----
# When the voltage lever is enabled (global master + per-miner opt-in)
Expand Down Expand Up @@ -676,11 +707,11 @@ async def _govern_one(self, miner: dict, sample: MinerSample, gcfg, cfg) -> None
hashrate_invalid=hashrate_invalid,
valid=allow_up,
instability_label=instab_label,
chip_c=sample.temp_chip_c,
chip_c=temp_chip_c,
chip_cutoff_c=float(gcfg.chip_cutoff_c),
vr_c=sample.temp_vr_c,
vr_c=temp_vr_c,
vr_cutoff_c=float(gcfg.vr_cutoff_c),
power_w=sample.power_w,
power_w=power_w,
power_cutoff_w=power_cut,
vin_mv=sample.input_voltage_mv,
vin_min_mv=vin_lo,
Expand Down Expand Up @@ -853,10 +884,10 @@ def _publish(
``temp_c`` is the governed sensor's reading and ``source`` says which
sensor it is ("vr" | "chip"), so the UI can label it correctly. The
legacy ``vr_temp_c`` key is kept (populated only in VR mode) so any
older consumer keeps working. ``hashrate_ths`` / ``asic_errors`` are the
effective-hashrate and ASIC hardware-error readings the regression brake
watches; ``soft_ceiling`` is the in-memory cap pinned after a regression
(``ceiling`` already reflects it — this is for an explicit UI hint).
older consumer keeps working. ``hashrate_ths`` / ``error_pct`` / ``asic_errors`` are
the effective-hashrate, ASIC hardware-error percentage, and ASIC hardware-error
readings the regression brake watches; ``soft_ceiling`` is the in-memory cap pinned
after a regression (``ceiling`` already reflects it — this is for an explicit UI hint).
"""
temp_r = round(temp_c, 1) if temp_c is not None else None
self._status[miner_id] = {
Expand Down
1 change: 1 addition & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2102,6 +2102,7 @@ async def api_get_settings() -> dict:
"storage": asdict(cfg.storage),
"network": asdict(cfg.network),
"auth_enabled": cfg.auth.enabled,
"guardian": asdict(cfg.guardian),
},
"stored": stored,
}
Expand Down
1 change: 1 addition & 0 deletions backend/miners/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ def to_db_sample(self) -> dict[str, Any]:
"best_difficulty": self.best_difficulty,
"pool_url": self.pool_url,
"worker": self.worker,
"error_pct": self.error_pct,
"raw": self.raw,
}

Expand Down
6 changes: 6 additions & 0 deletions docs/guardian-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ la banda morta 65–70 °C si parcheggia su una frequenza d'equilibrio e smette
scrivere. Le scritture avvengono solo quando l'ambiente deriva oltre soglia —
un numero limitato e sotto controllo.

### 3.2 Media mobile delle metriche (windowed metrics averaging)

Per prevenire falsi positivi causati da oscillazioni temporanee o transitorie delle metriche (es. picchi improvvisi di temperatura, di errore hardware o cali temporanei dell'hashrate), il Guardian non lavora sulle metriche istantanee dell'ultimo poll, ma calcola una media dei valori registrati nel database in una finestra temporale (di default 30 secondi) per hashrate, temperatura, potenza e percentuale di errore hardware (error_pct).

La finestra temporale è configurabile globalmente tramite la chiave `guardian.hashrate_average_window_seconds` nelle impostazioni generali. Impostando il valore a `0`, la funzionalità viene disattivata e il governor torna ad utilizzare i valori istantanei.

## 5. Stato per-miner e ciclo del controller

`GuardianController` (in `guardian.py`) è speculare ad `AutoFanController`:
Expand Down
25 changes: 20 additions & 5 deletions frontend-react/src/components/settings/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,26 @@ export function GeneralTab({ form, setForm }: Props) {
max={3650}
onChange={(v) => setForm({ ...form, retentionDays: v })}
/>
<p className="text-xs text-muted-foreground sm:col-span-2">
<span className="font-semibold text-foreground">Hashrate smoothing</span>: tau (time constant) of the
server-side EMA. 60s is a good trade-off between responsiveness and stability. Set to 0 to see raw
firmware values.
</p>
<Field
id="guardian.hashrate_average_window_seconds"
label="Guardian averaging window (seconds, 0 = off)"
value={form.guardianHashrateAverageWindow}
min={0}
max={600}
onChange={(v) => setForm({ ...form, guardianHashrateAverageWindow: v })}
/>
<div className="text-xs text-muted-foreground sm:col-span-2 space-y-1.5">
<div>
<span className="font-semibold text-foreground">Hashrate smoothing</span>: tau (time constant) of the
server-side EMA. 60s is a good trade-off between responsiveness and stability. Set to 0 to see raw
firmware values.
</div>
<div>
<span className="font-semibold text-foreground">Guardian averaging window</span>: The time window used by the
Guardian governor to average hashrate, temperature, power, and hardware error percentage metrics. This
prevents false-positive throttling from transient dips/peaks. Set to 0 to use instantaneous values.
</div>
</div>
</CardContent>
</Card>

Expand Down
3 changes: 3 additions & 0 deletions frontend-react/src/components/settings/SettingsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface SettingsFormState {
scanCidr: string;
authEnabled: boolean;
authPassword: string; // write-only
guardianHashrateAverageWindow: number;
}

export function useSettingsForm(current: SettingsCurrent | null | undefined) {
Expand Down Expand Up @@ -65,6 +66,7 @@ export function useSettingsForm(current: SettingsCurrent | null | undefined) {
scanCidr: current.network.scan_cidr,
authEnabled: current.auth_enabled,
authPassword: '',
guardianHashrateAverageWindow: current.guardian?.hashrate_average_window_seconds ?? 30,
});
}, [current]);

Expand Down Expand Up @@ -132,6 +134,7 @@ export function formToOverrides(form: SettingsFormState): Record<string, unknown
'alerts.wallet_watch_dust_sats': Math.max(0, Math.round(form.walletDustSats) || 0),
'network.scan_cidr': form.scanCidr,
'auth.enabled': form.authEnabled,
'guardian.hashrate_average_window_seconds': form.guardianHashrateAverageWindow,
};
if (form.authPassword) overrides['auth.password'] = form.authPassword;
if (form.telegramBotToken) overrides['alerts.telegram_bot_token'] = form.telegramBotToken;
Expand Down
5 changes: 5 additions & 0 deletions frontend-react/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,11 @@ export interface SettingsCurrent {
scan_timeout: number;
};
auth_enabled: boolean;
guardian: {
enabled: boolean;
interval_seconds: number;
hashrate_average_window_seconds: number;
};
}

export interface SettingsResponse {
Expand Down
74 changes: 74 additions & 0 deletions tests/test_guardian.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import pathlib
import sys
import types
import pytest

# Make the repo root importable whether invoked via pytest or directly.
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
Expand Down Expand Up @@ -391,6 +392,79 @@ def test_pt_above_ceiling_caps():
assert "cap" in r


def test_govern_one_uses_recent_averages():
import asyncio
from unittest.mock import AsyncMock, Mock, patch
from backend.miners.base import MinerSample
from backend.guardian import guardian, _GuardianState

miner = {"id": 1, "name": "miner1", "family": "bitaxe", "guardian_temp_source": "vr"}
sample = MinerSample(
family="bitaxe",
host="10.0.0.1",
online=True,
frequency_mhz=550,
temp_chip_c=60.0,
temp_vr_c=68.0,
hashrate_ths=2.5,
power_w=20.0,
accepted=100,
rejected=0,
)

gcfg = Mock()
gcfg.hashrate_average_window_seconds = 30
gcfg.reject_min_shares = 10
gcfg.reject_pct_max = 5.0
gcfg.frequency_floor_mhz = 400
gcfg.step_down_vr_mhz = 20
gcfg.step_down_err_mhz = 10
gcfg.step_up_mhz = 10
gcfg.temp_band = Mock(return_value=(70.0, 67.0))
gcfg.hashrate_settle_seconds = 0
gcfg.valid_pct = 0.97
gcfg.error_pct_max = 5.0
gcfg.v2_voltage_enabled = False
gcfg.cooldown_seconds = 0

cfg = Mock()

sample.expected_hashrate_ths = 2.0

avg_metrics = {
"hashrate_ths": 1.5,
"power_w": 20.0,
"temp_chip_c": 60.0,
"temp_vr_c": 68.0,
"error_pct": 1.2,
}

async def run():
mock_drv = AsyncMock()
mock_drv.set_frequency.return_value = True
with patch("backend.db.get_recent_metrics_average", AsyncMock(return_value=avg_metrics)) as mock_avg, \
patch("backend.guardian.driver_for_record", Mock(return_value=mock_drv)) as mock_dfr, \
patch.object(guardian, "_publish") as mock_publish, \
patch("backend.guardian.decide_frequency") as mock_decide:

mock_decide.return_value = (530, "hashrate below theoretical")

state = _GuardianState()
state.last_commanded_freq = 550
guardian._states[1] = state

await guardian._govern_one(miner, sample, gcfg, cfg)

mock_avg.assert_called_once_with(1, 30)
mock_decide.assert_called_once()
kwargs = mock_decide.call_args.kwargs
assert kwargs["hashrate_invalid"] is True
mock_publish.assert_called_once()
assert mock_publish.call_args.kwargs["error_pct"] == 1.2

asyncio.run(run())


if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
failures = 0
Expand Down