From fe31fad85547a51c883e3a1d2571aeee419e5580 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Mon, 22 Jun 2026 09:53:26 -0500 Subject: [PATCH 1/4] amdsmi plugin update --- .../plugins/inband/amdsmi/amdsmidata.py | 20 ++++++++++-- test/unit/plugin/test_amdsmi_data.py | 32 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/nodescraper/plugins/inband/amdsmi/amdsmidata.py b/nodescraper/plugins/inband/amdsmi/amdsmidata.py index fd603028..3b8aae3c 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmidata.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmidata.py @@ -523,6 +523,9 @@ class StaticCacheInfoItem(AmdSmiBaseModel): na_validator = field_validator("cache_size", mode="before")(na_to_none) +_STATIC_CLOCK_FREQ_LEVEL_VALIDATOR_FIELDS = tuple(f"Level_{i}" for i in range(16)) + + class StaticFrequencyLevels(AmdSmiBaseModel): """Static clock frequency levels; each level is normalized to ``ValueUnit``.""" @@ -534,8 +537,21 @@ class StaticFrequencyLevels(AmdSmiBaseModel): Level_0: ValueUnit = Field(..., alias="Level 0") Level_1: Optional[ValueUnit] = Field(default=None, alias="Level 1") Level_2: Optional[ValueUnit] = Field(default=None, alias="Level 2") - - _level_value_unit = field_validator("Level_0", "Level_1", "Level_2", mode="before")( + Level_3: Optional[ValueUnit] = Field(default=None, alias="Level 3") + Level_4: Optional[ValueUnit] = Field(default=None, alias="Level 4") + Level_5: Optional[ValueUnit] = Field(default=None, alias="Level 5") + Level_6: Optional[ValueUnit] = Field(default=None, alias="Level 6") + Level_7: Optional[ValueUnit] = Field(default=None, alias="Level 7") + Level_8: Optional[ValueUnit] = Field(default=None, alias="Level 8") + Level_9: Optional[ValueUnit] = Field(default=None, alias="Level 9") + Level_10: Optional[ValueUnit] = Field(default=None, alias="Level 10") + Level_11: Optional[ValueUnit] = Field(default=None, alias="Level 11") + Level_12: Optional[ValueUnit] = Field(default=None, alias="Level 12") + Level_13: Optional[ValueUnit] = Field(default=None, alias="Level 13") + Level_14: Optional[ValueUnit] = Field(default=None, alias="Level 14") + Level_15: Optional[ValueUnit] = Field(default=None, alias="Level 15") + + _level_value_unit = field_validator(*_STATIC_CLOCK_FREQ_LEVEL_VALIDATOR_FIELDS, mode="before")( coerce_value_unit_input ) diff --git a/test/unit/plugin/test_amdsmi_data.py b/test/unit/plugin/test_amdsmi_data.py index 9e28fbb9..f6c4f750 100644 --- a/test/unit/plugin/test_amdsmi_data.py +++ b/test/unit/plugin/test_amdsmi_data.py @@ -23,7 +23,7 @@ # SOFTWARE. # ############################################################################### -"""Unit tests for amd-smi pydantic models (ROCm 7.13 / legacy JSON shapes).""" +"""Unit tests for amd-smi pydantic models (legacy JSON, ROCm 7.2+ / AMD-SMI 26.2+).""" from typing import Any, Optional @@ -341,6 +341,36 @@ def test_static_frequency_levels_optional_levels(): assert levels.Level_2 is not None and levels.Level_2.value == 1300 +def test_static_frequency_levels_accepts_level_three_plus(): + """ROCm 7.2+ / AMD-SMI 26.2+ may expose additional DPM levels (e.g. Level 3).""" + levels = StaticFrequencyLevels.model_validate( + { + "Level 0": "400 MHz", + "Level 1": "800 MHz", + "Level 2": "1000 MHz", + "Level 3": "1143 MHz", + } + ) + assert levels.Level_3 is not None + assert levels.Level_3.value == 1143 + assert levels.Level_3.unit == "MHz" + + +def test_static_frequency_levels_legacy_amd_smi_three_levels_only(): + """Legacy static JSON: only Level 0–2 (no Level 3+ keys).""" + levels = StaticFrequencyLevels.model_validate( + { + "Level 0": {"value": 500, "unit": "MHz"}, + "Level 1": "900 MHz", + "Level 2": "1300 MHz", + } + ) + assert levels.Level_0.value == 500 + assert levels.Level_2 is not None and levels.Level_2.value == 1300 + assert levels.Level_3 is None + assert levels.Level_15 is None + + def test_static_limit_legacy_max_power(): """Legacy flat max_power field still resolves.""" limit = StaticLimit.model_validate(DUMMY_LIMIT_LEGACY) From 1f996d4a357135921972049761e3c30ee922aaf7 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Mon, 22 Jun 2026 10:04:22 -0500 Subject: [PATCH 2/4] updates --- .../plugins/inband/amdsmi/amdsmidata.py | 22 ++++++++++++++ test/unit/plugin/test_amdsmi_data.py | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/nodescraper/plugins/inband/amdsmi/amdsmidata.py b/nodescraper/plugins/inband/amdsmi/amdsmidata.py index 3b8aae3c..1ef3e4fd 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmidata.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmidata.py @@ -525,6 +525,8 @@ class StaticCacheInfoItem(AmdSmiBaseModel): _STATIC_CLOCK_FREQ_LEVEL_VALIDATOR_FIELDS = tuple(f"Level_{i}" for i in range(16)) +_STATIC_FREQ_LEVEL_JSON_KEY_RE = re.compile(r"^level[\s_]*(\d+)\s*$", re.IGNORECASE) + class StaticFrequencyLevels(AmdSmiBaseModel): """Static clock frequency levels; each level is normalized to ``ValueUnit``.""" @@ -534,6 +536,26 @@ class StaticFrequencyLevels(AmdSmiBaseModel): extra="forbid", ) + @model_validator(mode="before") + @classmethod + def _normalize_level_entries(cls, data: Any) -> Any: + """Map amd-smi DPM key spellings to canonical ``Level {n}``, drop non-level keys, ignore index > 15.""" + if not isinstance(data, dict): + return data + out: dict[str, Any] = {} + for raw_key, val in data.items(): + n: Optional[int] = None + if isinstance(raw_key, int): + n = int(raw_key) + elif isinstance(raw_key, str): + m = _STATIC_FREQ_LEVEL_JSON_KEY_RE.match(raw_key.strip()) + if m: + n = int(m.group(1)) + if n is None or n < 0 or n > 15: + continue + out[f"Level {n}"] = val + return out + Level_0: ValueUnit = Field(..., alias="Level 0") Level_1: Optional[ValueUnit] = Field(default=None, alias="Level 1") Level_2: Optional[ValueUnit] = Field(default=None, alias="Level 2") diff --git a/test/unit/plugin/test_amdsmi_data.py b/test/unit/plugin/test_amdsmi_data.py index f6c4f750..1f8c5529 100644 --- a/test/unit/plugin/test_amdsmi_data.py +++ b/test/unit/plugin/test_amdsmi_data.py @@ -371,6 +371,35 @@ def test_static_frequency_levels_legacy_amd_smi_three_levels_only(): assert levels.Level_15 is None +def test_static_frequency_levels_accepts_amd_smi_key_spelling_variants(): + """ROCm / AMD-SMI JSON may use LEVEL_N / Level0 style keys instead of ``Level N``.""" + levels = StaticFrequencyLevels.model_validate( + { + "LEVEL_0": "400 MHz", + "level_1": "800 MHz", + "Level2": "1000 MHz", + "Level 3": "1143 MHz", + } + ) + assert levels.Level_0.value == 400 + assert levels.Level_1 is not None and levels.Level_1.value == 800 + assert levels.Level_2 is not None and levels.Level_2.value == 1000 + assert levels.Level_3 is not None and levels.Level_3.value == 1143 + + +def test_static_frequency_levels_drops_unknown_keys_and_high_indices(): + """Non-level keys are ignored; DPM indices above 15 are dropped (model stores 0–15 only).""" + levels = StaticFrequencyLevels.model_validate( + { + "Level 0": "100 MHz", + "num_states": 99, + "Level 16": "9999 MHz", + } + ) + assert levels.Level_0.value == 100 + assert levels.Level_1 is None + + def test_static_limit_legacy_max_power(): """Legacy flat max_power field still resolves.""" limit = StaticLimit.model_validate(DUMMY_LIMIT_LEGACY) From bc60a72ef7b21f24bb50cf7b603d850a80f3e9d9 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Mon, 22 Jun 2026 10:17:23 -0500 Subject: [PATCH 3/4] updates --- .../plugins/inband/amdsmi/amdsmi_collector.py | 11 +++++------ .../plugins/inband/amdsmi/amdsmidata.py | 9 +++++++-- test/unit/plugin/test_amdsmi_data.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py b/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py index ef60995a..8f702b12 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py @@ -1037,6 +1037,8 @@ def _parse_limit(self, data: Optional[object]) -> Optional[StaticLimit]: def _parse_current_level(self, data: dict) -> Optional[int]: """Extract current DPM level index from static clock JSON.""" cur_raw = data.get("current") + if cur_raw is None: + cur_raw = data.get("current_level") if cur_raw is None: cur_raw = data.get("current level") if isinstance(cur_raw, (int, float)): @@ -1290,14 +1292,10 @@ def _parse_clock(self, data: dict) -> Optional[StaticClockData]: if not isinstance(data, dict): return None - current = self._parse_current_level(data) freq_levels_raw = data.get("frequency_levels") if isinstance(freq_levels_raw, dict) and freq_levels_raw: try: - levels = StaticFrequencyLevels.model_validate(freq_levels_raw) - return StaticClockData.model_validate( - {"frequency_levels": levels, "current level": current} - ) + return StaticClockData.model_validate(data) except ValidationError as err: self._log_event( category=EventCategory.APPLICATION, @@ -1306,6 +1304,7 @@ def _parse_clock(self, data: dict) -> Optional[StaticClockData]: priority=EventPriority.WARNING, ) + current = self._parse_current_level(data) freqs_raw = data.get("frequency") if not isinstance(freqs_raw, list) or not freqs_raw: return None @@ -1342,7 +1341,7 @@ def _fmt(n: Optional[int]) -> Optional[str]: {"Level 0": level0, "Level 1": level1, "Level 2": level2} ) - # Use the alias "current level" as defined in the model + # current_level accepts legacy "current level" / "current" keys via StaticClockData return StaticClockData.model_validate( {"frequency_levels": levels, "current level": current} ) diff --git a/nodescraper/plugins/inband/amdsmi/amdsmidata.py b/nodescraper/plugins/inband/amdsmi/amdsmidata.py index 1ef3e4fd..18f83695 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmidata.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmidata.py @@ -581,10 +581,15 @@ def _normalize_level_entries(cls, data: Any) -> Any: class StaticClockData(BaseModel): model_config = ConfigDict( populate_by_name=True, + extra="ignore", ) frequency_levels: StaticFrequencyLevels - - current_level: Optional[int] = Field(..., alias="current level") + current_level: Optional[int] = Field( + default=None, + validation_alias=AliasChoices("current level", "current_level", "current"), + serialization_alias="current level", + ) + current_frequency: Optional[str] = None na_validator = field_validator("current_level", mode="before")(na_to_none) diff --git a/test/unit/plugin/test_amdsmi_data.py b/test/unit/plugin/test_amdsmi_data.py index 1f8c5529..fe684edc 100644 --- a/test/unit/plugin/test_amdsmi_data.py +++ b/test/unit/plugin/test_amdsmi_data.py @@ -450,6 +450,25 @@ def test_static_clock_frequency_levels_json(): assert clock.frequency_levels.Level_1.value == 900 +def test_static_clock_mi300_amd_smi_26_json_shape(): + """ROCm 7.2 / AMD-SMI 26.x clock domains use current_level, current_frequency, and Level N strings.""" + raw = { + "current_level": 0, + "current_frequency": "132MHz", + "frequency_levels": { + "Level 0": "132 MHz", + "Level 1": "500 MHz", + "Level 2": "2100 MHz", + }, + } + clock = StaticClockData.model_validate(raw) + assert clock.current_level == 0 + assert clock.current_frequency == "132MHz" + assert clock.frequency_levels.Level_0.value == 132 + assert clock.frequency_levels.Level_2 is not None + assert clock.frequency_levels.Level_2.value == 2100 + + def test_amdsmi_data_model_dummy_metric_round_trip(): """Full dummy metric payload validates and preserves key ROCm 7.13 fields.""" metric = AmdSmiMetric.model_validate(dummy_metric_dict()) From a7a36243418c1587ad25fed91dd3e3de8991a6c6 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Mon, 22 Jun 2026 10:29:40 -0500 Subject: [PATCH 4/4] updates --- .../plugins/inband/amdsmi/amdsmi_collector.py | 11 +++-- .../plugins/inband/amdsmi/amdsmidata.py | 31 +----------- test/unit/plugin/test_amdsmi_data.py | 48 ------------------- 3 files changed, 8 insertions(+), 82 deletions(-) diff --git a/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py b/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py index 8f702b12..ef60995a 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py @@ -1037,8 +1037,6 @@ def _parse_limit(self, data: Optional[object]) -> Optional[StaticLimit]: def _parse_current_level(self, data: dict) -> Optional[int]: """Extract current DPM level index from static clock JSON.""" cur_raw = data.get("current") - if cur_raw is None: - cur_raw = data.get("current_level") if cur_raw is None: cur_raw = data.get("current level") if isinstance(cur_raw, (int, float)): @@ -1292,10 +1290,14 @@ def _parse_clock(self, data: dict) -> Optional[StaticClockData]: if not isinstance(data, dict): return None + current = self._parse_current_level(data) freq_levels_raw = data.get("frequency_levels") if isinstance(freq_levels_raw, dict) and freq_levels_raw: try: - return StaticClockData.model_validate(data) + levels = StaticFrequencyLevels.model_validate(freq_levels_raw) + return StaticClockData.model_validate( + {"frequency_levels": levels, "current level": current} + ) except ValidationError as err: self._log_event( category=EventCategory.APPLICATION, @@ -1304,7 +1306,6 @@ def _parse_clock(self, data: dict) -> Optional[StaticClockData]: priority=EventPriority.WARNING, ) - current = self._parse_current_level(data) freqs_raw = data.get("frequency") if not isinstance(freqs_raw, list) or not freqs_raw: return None @@ -1341,7 +1342,7 @@ def _fmt(n: Optional[int]) -> Optional[str]: {"Level 0": level0, "Level 1": level1, "Level 2": level2} ) - # current_level accepts legacy "current level" / "current" keys via StaticClockData + # Use the alias "current level" as defined in the model return StaticClockData.model_validate( {"frequency_levels": levels, "current level": current} ) diff --git a/nodescraper/plugins/inband/amdsmi/amdsmidata.py b/nodescraper/plugins/inband/amdsmi/amdsmidata.py index 18f83695..3b8aae3c 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmidata.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmidata.py @@ -525,8 +525,6 @@ class StaticCacheInfoItem(AmdSmiBaseModel): _STATIC_CLOCK_FREQ_LEVEL_VALIDATOR_FIELDS = tuple(f"Level_{i}" for i in range(16)) -_STATIC_FREQ_LEVEL_JSON_KEY_RE = re.compile(r"^level[\s_]*(\d+)\s*$", re.IGNORECASE) - class StaticFrequencyLevels(AmdSmiBaseModel): """Static clock frequency levels; each level is normalized to ``ValueUnit``.""" @@ -536,26 +534,6 @@ class StaticFrequencyLevels(AmdSmiBaseModel): extra="forbid", ) - @model_validator(mode="before") - @classmethod - def _normalize_level_entries(cls, data: Any) -> Any: - """Map amd-smi DPM key spellings to canonical ``Level {n}``, drop non-level keys, ignore index > 15.""" - if not isinstance(data, dict): - return data - out: dict[str, Any] = {} - for raw_key, val in data.items(): - n: Optional[int] = None - if isinstance(raw_key, int): - n = int(raw_key) - elif isinstance(raw_key, str): - m = _STATIC_FREQ_LEVEL_JSON_KEY_RE.match(raw_key.strip()) - if m: - n = int(m.group(1)) - if n is None or n < 0 or n > 15: - continue - out[f"Level {n}"] = val - return out - Level_0: ValueUnit = Field(..., alias="Level 0") Level_1: Optional[ValueUnit] = Field(default=None, alias="Level 1") Level_2: Optional[ValueUnit] = Field(default=None, alias="Level 2") @@ -581,15 +559,10 @@ def _normalize_level_entries(cls, data: Any) -> Any: class StaticClockData(BaseModel): model_config = ConfigDict( populate_by_name=True, - extra="ignore", ) frequency_levels: StaticFrequencyLevels - current_level: Optional[int] = Field( - default=None, - validation_alias=AliasChoices("current level", "current_level", "current"), - serialization_alias="current level", - ) - current_frequency: Optional[str] = None + + current_level: Optional[int] = Field(..., alias="current level") na_validator = field_validator("current_level", mode="before")(na_to_none) diff --git a/test/unit/plugin/test_amdsmi_data.py b/test/unit/plugin/test_amdsmi_data.py index fe684edc..f6c4f750 100644 --- a/test/unit/plugin/test_amdsmi_data.py +++ b/test/unit/plugin/test_amdsmi_data.py @@ -371,35 +371,6 @@ def test_static_frequency_levels_legacy_amd_smi_three_levels_only(): assert levels.Level_15 is None -def test_static_frequency_levels_accepts_amd_smi_key_spelling_variants(): - """ROCm / AMD-SMI JSON may use LEVEL_N / Level0 style keys instead of ``Level N``.""" - levels = StaticFrequencyLevels.model_validate( - { - "LEVEL_0": "400 MHz", - "level_1": "800 MHz", - "Level2": "1000 MHz", - "Level 3": "1143 MHz", - } - ) - assert levels.Level_0.value == 400 - assert levels.Level_1 is not None and levels.Level_1.value == 800 - assert levels.Level_2 is not None and levels.Level_2.value == 1000 - assert levels.Level_3 is not None and levels.Level_3.value == 1143 - - -def test_static_frequency_levels_drops_unknown_keys_and_high_indices(): - """Non-level keys are ignored; DPM indices above 15 are dropped (model stores 0–15 only).""" - levels = StaticFrequencyLevels.model_validate( - { - "Level 0": "100 MHz", - "num_states": 99, - "Level 16": "9999 MHz", - } - ) - assert levels.Level_0.value == 100 - assert levels.Level_1 is None - - def test_static_limit_legacy_max_power(): """Legacy flat max_power field still resolves.""" limit = StaticLimit.model_validate(DUMMY_LIMIT_LEGACY) @@ -450,25 +421,6 @@ def test_static_clock_frequency_levels_json(): assert clock.frequency_levels.Level_1.value == 900 -def test_static_clock_mi300_amd_smi_26_json_shape(): - """ROCm 7.2 / AMD-SMI 26.x clock domains use current_level, current_frequency, and Level N strings.""" - raw = { - "current_level": 0, - "current_frequency": "132MHz", - "frequency_levels": { - "Level 0": "132 MHz", - "Level 1": "500 MHz", - "Level 2": "2100 MHz", - }, - } - clock = StaticClockData.model_validate(raw) - assert clock.current_level == 0 - assert clock.current_frequency == "132MHz" - assert clock.frequency_levels.Level_0.value == 132 - assert clock.frequency_levels.Level_2 is not None - assert clock.frequency_levels.Level_2.value == 2100 - - def test_amdsmi_data_model_dummy_metric_round_trip(): """Full dummy metric payload validates and preserves key ROCm 7.13 fields.""" metric = AmdSmiMetric.model_validate(dummy_metric_dict())