Compare commits

...

2 Commits

Author SHA1 Message Date
jessey 31178bace8 Merge pull request 'feat(memory): flag RAM below rated speed (XMP/EXPO not enabled) — 0.40.0' (#44) from feat/ram-speed into main
release / test (push) Successful in 13s
release / release (push) Successful in 16s
Reviewed-on: #44
2026-05-22 15:00:25 +00:00
jessey 04e8d72bce feat(memory): flag RAM below rated speed (XMP/EXPO not enabled) — 0.40.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
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>
2026-05-22 17:00:02 +02:00
7 changed files with 110 additions and 4 deletions
+9
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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"
+24
View File
@@ -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
+35 -2
View File
@@ -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)
+22
View File
@@ -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()
+18
View File
@@ -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()