feat(memory): flag RAM below rated speed (XMP/EXPO not enabled) — 0.40.0
Inventory shows configured RAM speed + the rated speed when lower
('4800 MT/s (rated 5600)'); System Health flags it with the fix (enable
XMP/EXPO in BIOS). With the profile off dmidecode only reports the JEDEC base,
so the rated speed comes from dmidecode's max OR the part number, matched against
known DDR5 speed grades to avoid false positives. inventory.module_speed() shared
by both; needs dmidecode (root/launch elevation). +tests (incl. the user's
CMK..5600 kit → (4800, 5600)). Completes the underperforming-hardware trio with
PCIe gen + refresh rate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||||
release tag (so the auto-updater, D18, can compare versions).
|
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
|
## [0.39.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **Displays in the Inventory.** A new `core/displays.py` lists each connected monitor with its
|
- **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]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.39.0"
|
version = "0.40.0"
|
||||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
"""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
|
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]:
|
def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||||
"""Run all checks and return findings sorted by severity (worst first).
|
"""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_live_temps()
|
||||||
findings += check_pcie_links()
|
findings += check_pcie_links()
|
||||||
findings += check_displays()
|
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))
|
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
@@ -86,6 +86,35 @@ def _firmware(dmi: dict) -> Section:
|
|||||||
return Section("Firmware", items)
|
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:
|
def _memory(dmi: dict) -> Section:
|
||||||
items: list[tuple[str, str]] = []
|
items: list[tuple[str, str]] = []
|
||||||
try:
|
try:
|
||||||
@@ -99,8 +128,12 @@ def _memory(dmi: dict) -> Section:
|
|||||||
if modules:
|
if modules:
|
||||||
items.append(("Modules", str(len(modules))))
|
items.append(("Modules", str(len(modules))))
|
||||||
for i, m in enumerate(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)
|
configured, rated = module_speed(m)
|
||||||
items.append((f"Slot {i}", desc))
|
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"):
|
elif shutil.which("dmidecode"):
|
||||||
items.append(("Modules", "run with admin for module details"))
|
items.append(("Modules", "run with admin for module details"))
|
||||||
return Section("Memory", items)
|
return Section("Memory", items)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from rigdoctor.core.health import (
|
|||||||
INFO,
|
INFO,
|
||||||
WARNING,
|
WARNING,
|
||||||
check_displays,
|
check_displays,
|
||||||
|
check_memory_speed,
|
||||||
check_pcie_links,
|
check_pcie_links,
|
||||||
run_health_checks,
|
run_health_checks,
|
||||||
scan_journal_text,
|
scan_journal_text,
|
||||||
@@ -97,5 +98,26 @@ class DisplayCheckTests(unittest.TestCase):
|
|||||||
self.assertEqual(check_displays(), [])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -54,5 +54,23 @@ class PcieLinkTests(unittest.TestCase):
|
|||||||
self.assertEqual(inventory._nvme_link("sda"), "")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user