feat(memory): flag RAM below rated speed (XMP/EXPO not enabled) — 0.40.0 #44
@@ -5,6 +5,15 @@ All notable changes to RigDoctor are recorded here. Format follows
|
||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||
release tag (so the auto-updater, D18, can compare versions).
|
||||
|
||||
## [0.40.0] - 2026-05-22
|
||||
### Added
|
||||
- **RAM speed / XMP-EXPO check.** Inventory now shows each module's configured speed and, when it's
|
||||
below the rated speed, the rating (e.g. `4800 MT/s (rated 5600)`); **System Health** flags it
|
||||
("RAM at 4800 MT/s (rated 5600 MT/s)") with the fix — enable XMP/EXPO in BIOS. With the profile
|
||||
off, dmidecode only reports the JEDEC base, so the rated speed is read from both dmidecode and
|
||||
the part number (matched against known DDR5 speed grades, so no false positives). Needs dmidecode
|
||||
(root / launch elevation). Completes the "underperforming hardware" trio with PCIe gen + refresh.
|
||||
|
||||
## [0.39.0] - 2026-05-22
|
||||
### Added
|
||||
- **Displays in the Inventory.** A new `core/displays.py` lists each connected monitor with its
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.39.0"
|
||||
version = "0.40.0"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.39.0"
|
||||
__version__ = "0.40.0"
|
||||
|
||||
@@ -300,6 +300,29 @@ def check_displays() -> list[Finding]:
|
||||
return findings
|
||||
|
||||
|
||||
def check_memory_speed() -> list[Finding]:
|
||||
"""Flag RAM running below its rated speed — i.e. the XMP (Intel) / EXPO (AMD) profile isn't
|
||||
enabled, leaving memory bandwidth on the table. Needs dmidecode (root); silent without it."""
|
||||
from . import elevation, inventory
|
||||
|
||||
priv = elevation.privileged()
|
||||
dmi = priv["dmidecode"] if (priv and priv.get("dmidecode")) else inventory._dmidecode()
|
||||
worst: tuple[int, int] | None = None # (configured, rated) with the biggest gap
|
||||
for m in dmi.get("memory", []):
|
||||
configured, rated = inventory.module_speed(m)
|
||||
if configured and rated and configured < rated:
|
||||
if worst is None or (rated - configured) > (worst[1] - worst[0]):
|
||||
worst = (configured, rated)
|
||||
if worst is None:
|
||||
return []
|
||||
configured, rated = worst
|
||||
return [Finding(
|
||||
INFO, "Memory", f"RAM at {configured} MT/s (rated {rated} MT/s)",
|
||||
f"Memory is running at {configured} MT/s but the modules are rated {rated} MT/s — the "
|
||||
"XMP/EXPO profile isn't enabled, so you're leaving memory bandwidth on the table.",
|
||||
"Enable XMP (Intel) or EXPO (AMD) in your BIOS/UEFI to run at the rated speed.")]
|
||||
|
||||
|
||||
def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||
"""Run all checks and return findings sorted by severity (worst first).
|
||||
|
||||
@@ -324,5 +347,6 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||
findings += check_live_temps()
|
||||
findings += check_pcie_links()
|
||||
findings += check_displays()
|
||||
findings += check_memory_speed() # uses elevation data if present, else dmidecode (root)
|
||||
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
|
||||
return findings
|
||||
|
||||
@@ -86,6 +86,35 @@ def _firmware(dmi: dict) -> Section:
|
||||
return Section("Firmware", items)
|
||||
|
||||
|
||||
# Common DDR5 XMP/EXPO speed grades (MT/s) — used to read a kit's rated speed from its part
|
||||
# number, since with XMP/EXPO off dmidecode only reports the JEDEC base (e.g. 4800).
|
||||
_DDR_SPEEDS = {4800, 5200, 5600, 6000, 6200, 6400, 6600, 6800, 7000, 7200, 7600, 8000, 8200, 8400}
|
||||
|
||||
|
||||
def _mts(value: str) -> int | None:
|
||||
"""Parse a dmidecode speed like '4800 MT/s' (or 'MHz') to its integer MT/s."""
|
||||
m = re.match(r"\s*(\d+)", value or "")
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _rated_from_part(part: str) -> int | None:
|
||||
"""The highest known DDR speed-grade appearing as a 4-digit token in a part number."""
|
||||
grades = [int(n) for n in re.findall(r"(?<!\d)(\d{4})(?!\d)", part or "") if int(n) in _DDR_SPEEDS]
|
||||
return max(grades) if grades else None
|
||||
|
||||
|
||||
def module_speed(m: dict) -> tuple[int | None, int | None]:
|
||||
"""(configured, rated) MT/s for a dmidecode Memory Device.
|
||||
|
||||
Configured = what it's actually running at; rated = the highest of dmidecode's reported max
|
||||
and the part-number speed-grade (so an unapplied XMP/EXPO profile is still detected).
|
||||
"""
|
||||
configured = _mts(m.get("Configured Memory Speed") or m.get("Configured Clock Speed") or m.get("Speed", ""))
|
||||
candidates = [s for s in (_mts(m.get("Speed", "")), _rated_from_part(m.get("Part Number", ""))) if s]
|
||||
rated = max(candidates) if candidates else None
|
||||
return configured, rated
|
||||
|
||||
|
||||
def _memory(dmi: dict) -> Section:
|
||||
items: list[tuple[str, str]] = []
|
||||
try:
|
||||
@@ -99,8 +128,12 @@ def _memory(dmi: dict) -> Section:
|
||||
if modules:
|
||||
items.append(("Modules", str(len(modules))))
|
||||
for i, m in enumerate(modules):
|
||||
desc = " · ".join(p for p in (m.get("Size"), m.get("Type"), m.get("Speed"), m.get("Part Number")) if p)
|
||||
items.append((f"Slot {i}", desc))
|
||||
configured, rated = module_speed(m)
|
||||
speed = f"{configured} MT/s" if configured else m.get("Speed", "")
|
||||
if rated and configured and rated > configured: # XMP/EXPO not applied
|
||||
speed += f" (rated {rated})"
|
||||
parts = (m.get("Size"), m.get("Type"), speed, m.get("Part Number"))
|
||||
items.append((f"Slot {i}", " · ".join(p for p in parts if p)))
|
||||
elif shutil.which("dmidecode"):
|
||||
items.append(("Modules", "run with admin for module details"))
|
||||
return Section("Memory", items)
|
||||
|
||||
@@ -10,6 +10,7 @@ from rigdoctor.core.health import (
|
||||
INFO,
|
||||
WARNING,
|
||||
check_displays,
|
||||
check_memory_speed,
|
||||
check_pcie_links,
|
||||
run_health_checks,
|
||||
scan_journal_text,
|
||||
@@ -97,5 +98,26 @@ class DisplayCheckTests(unittest.TestCase):
|
||||
self.assertEqual(check_displays(), [])
|
||||
|
||||
|
||||
class MemorySpeedCheckTests(unittest.TestCase):
|
||||
def _dmi(self, configured, part):
|
||||
return {"memory": [{"Configured Memory Speed": configured, "Speed": configured,
|
||||
"Part Number": part}]}
|
||||
|
||||
def test_flags_unapplied_expo(self):
|
||||
dmi = self._dmi("4800 MT/s", "CMK32GX5M2B5600Z36")
|
||||
with mock.patch("rigdoctor.core.elevation.privileged", return_value=None), \
|
||||
mock.patch("rigdoctor.core.inventory._dmidecode", return_value=dmi):
|
||||
findings = check_memory_speed()
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertEqual(findings[0].severity, INFO)
|
||||
self.assertIn("5600", findings[0].title)
|
||||
|
||||
def test_no_flag_at_rated(self):
|
||||
dmi = self._dmi("5600 MT/s", "CMK32GX5M2B5600Z36")
|
||||
with mock.patch("rigdoctor.core.elevation.privileged", return_value=None), \
|
||||
mock.patch("rigdoctor.core.inventory._dmidecode", return_value=dmi):
|
||||
self.assertEqual(check_memory_speed(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -54,5 +54,23 @@ class PcieLinkTests(unittest.TestCase):
|
||||
self.assertEqual(inventory._nvme_link("sda"), "")
|
||||
|
||||
|
||||
class MemorySpeedTests(unittest.TestCase):
|
||||
def test_rated_speed_from_part_number(self):
|
||||
self.assertEqual(inventory._rated_from_part("CMK32GX5M2B5600Z36"), 5600)
|
||||
self.assertEqual(inventory._rated_from_part("F5-6000J3038F16G"), 6000)
|
||||
self.assertIsNone(inventory._rated_from_part("NoSpeedHere"))
|
||||
|
||||
def test_detects_unapplied_expo(self):
|
||||
# XMP/EXPO off: dmidecode only sees JEDEC 4800; the 5600 is in the part number.
|
||||
m = {"Configured Memory Speed": "4800 MT/s", "Speed": "4800 MT/s",
|
||||
"Part Number": "CMK32GX5M2B5600Z36"}
|
||||
self.assertEqual(inventory.module_speed(m), (4800, 5600))
|
||||
|
||||
def test_at_rated_speed(self):
|
||||
m = {"Configured Memory Speed": "5600 MT/s", "Speed": "5600 MT/s",
|
||||
"Part Number": "CMK32GX5M2B5600Z36"}
|
||||
self.assertEqual(inventory.module_speed(m), (5600, 5600))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user