2222_TDP_RE = re .compile (
2323 r"(?<![\d.])(\d{1,4}(?:\.\d+)?)(?:\s*/\s*\d{1,4}(?:\.\d+)?)?\s*W\b" , re .IGNORECASE
2424)
25+ # "12–25 W": a cTDP range, which names no default TDP.
26+ _TDP_RANGE_RE = re .compile (
27+ r"(?<![\d.])\d{1,4}(?:\.\d+)?\s*(?:W\s*)?(?:–|—|−|-|to)\s*\d{1,4}(?:\.\d+)?\s*W\b" ,
28+ re .IGNORECASE ,
29+ )
2530_FREQ_RANGE_RE = re .compile (
2631 r"(\d+(?:\.\d+)?)\s*(?:–|—|-|to)\s*(\d+(?:\.\d+)?)\s*(GHz|MHz)\b" , re .IGNORECASE
2732)
4752 re .IGNORECASE ,
4853)
4954_QUARTER_RE = re .compile (r"\bQ([1-4])\s*'?(\d{2}|\d{4})\b" , re .IGNORECASE )
50- _MONTH_YEAR_RE = re .compile (r"\b" + _MONTH_NAME + r"\s+(\d{4})\b" , re .IGNORECASE )
55+ # "1H 2025" and "H2 2024".
56+ _HALF_YEAR_RE = re .compile (r"\b(?:([12])H|H([12]))\s*(\d{4})\b" , re .IGNORECASE )
57+ _MONTH_YEAR_RE = re .compile (r"\b" + _MONTH_NAME + r",?\s+(\d{4})\b" , re .IGNORECASE )
5158_YEAR_ONLY_RE = re .compile (r"\b(19\d{2}|20\d{2})\b" )
5259
5360_MONTHS = {
@@ -74,10 +81,15 @@ def parse_frequency_ghz(text: str) -> float | None:
7481
7582
7683def parse_tdp_w (text : str ) -> int | None :
77- """``"65 W"`` → ``65``; ``"65/95 W"`` → ``65``; ``"9.5 W"`` → ``10`` (half-up)."""
84+ """``"65 W"`` → ``65``; ``"65/95 W"`` → ``65``; ``"9.5 W"`` → ``10`` (half-up).
85+
86+ ``"45 W (35–54 W)"`` → ``45``: the default precedes its cTDP range. A cell
87+ that is only a range (``"12–25 W"``) states no default, so it gives ``None``;
88+ neither end of the range is the part's TDP.
89+ """
7890 if not text :
7991 return None
80- match = _TDP_RE .search (text )
92+ match = _TDP_RE .search (_TDP_RANGE_RE . sub ( " " , text ) )
8193 return math .floor (float (match .group (1 )) + 0.5 ) if match else None
8294
8395
@@ -142,12 +154,29 @@ def parse_date(text: str) -> date | None:
142154 return period [0 ] if period else None
143155
144156
157+ # A quarter is the longest period whose first day still stands for the date.
158+ _LONGEST_DATED_PERIOD_DAYS = 92
159+
160+
161+ def release_date_of (period : tuple [date , date ] | None ) -> date | None :
162+ """Release date a collector may write for ``period``, or ``None``.
163+
164+ A day, a month or a quarter gives its first day, as the curated records
165+ do. A half-year or a bare year gives ``None``: January 1 would be a guess.
166+ """
167+ if period is None :
168+ return None
169+ first , last = period
170+ return first if (last - first ).days < _LONGEST_DATED_PERIOD_DAYS else None
171+
172+
145173def parse_date_period (text : str ) -> tuple [date , date ] | None :
146174 """``(first, last)`` day of the period a date cell names.
147175
148176 A full date is a one-day period. ``"Q4 2026"`` is Oct 1 – Dec 31, ``"Dec 2024"``
149- the whole month and a bare year the whole year, so a caller can tell whether
150- the period had ended (the part had shipped) by a given day.
177+ the whole month, ``"1H 2025"`` Jan 1 – Jun 30 and a bare year the whole year,
178+ so a caller can tell whether the period had ended (the part had shipped) by a
179+ given day.
151180 """
152181 if not text :
153182 return None
@@ -170,6 +199,9 @@ def parse_date_period(text: str) -> tuple[date, date] | None:
170199 year_raw = match .group (2 )
171200 year = 2000 + int (year_raw ) if len (year_raw ) == 2 else int (year_raw )
172201 return _months (year , (quarter - 1 ) * 3 + 1 , 3 )
202+ if (match := _HALF_YEAR_RE .search (stripped )):
203+ half = int (match .group (1 ) or match .group (2 ))
204+ return _months (int (match .group (3 )), 1 if half == 1 else 7 , 6 )
173205 if (match := _MONTH_YEAR_RE .search (stripped )):
174206 return _months (int (match .group (2 )), _MONTHS [match .group (1 ).lower ()[:3 ]], 1 )
175207 if (match := _YEAR_ONLY_RE .search (stripped )):
@@ -220,6 +252,15 @@ def parse_memory_gb(text: str) -> float | None:
220252 return value if match .group (2 ).lower () == "gb" else round (value / 1024 , 3 )
221253
222254
255+ def mib_to_gb (mib : float ) -> float | None :
256+ """MiB → GB as the dataset stores it (binary): ``32`` → ``0.03125``.
257+
258+ Unrounded, so power-of-two sizes stay exact. 1 MiB and less gives ``None``:
259+ 0.0009765625 is under the validator's 0.001 GB floor.
260+ """
261+ return mib / 1024 if mib > 1 else None
262+
263+
223264def parse_memory_bus_bit (text : str ) -> int | None :
224265 """``"384-bit"`` → ``384``; ``"128 bit"`` → ``128``."""
225266 if not text :
@@ -326,6 +367,17 @@ def guess_os(text: str, *, brand: str = "") -> str | None:
326367 return None
327368
328369
370+ def normalize_socket (text : str ) -> str | None :
371+ """Socket in the model doc's form: ``"LGA 1151"`` → ``"LGA1151"``,
372+ ``"Socket AM4"`` → ``"AM4"``, ``"F"`` → ``"F (1207)"``."""
373+ value = " " .join (text .split ())
374+ value = re .sub (r"^Socket\s+" , "" , value , flags = re .IGNORECASE )
375+ value = re .sub (r"^(FC)?LGA\s+(?=\d)" , r"\1LGA" , value , flags = re .IGNORECASE )
376+ if value == "F" :
377+ value = "F (1207)"
378+ return value or None
379+
380+
329381def guess_cpu_segment (name : str ) -> str :
330382 """Heuristic CPU segment classifier.
331383
@@ -353,6 +405,7 @@ def guess_cpu_segment(name: str) -> str:
353405 suffix = match .group (1 ) if match else ""
354406 if any (letter in suffix for letter in ("h" , "u" , "y" , "p" , "m" )) and "k" not in suffix :
355407 return "laptop"
356- if suffix in {"x" , "xe" }:
408+ # Core X/XE parts are HEDT; Ryzen X parts are ordinary desktop chips.
409+ if suffix in {"x" , "xe" } and re .search (r"\bcore\b" , lowered ):
357410 return "hedt"
358411 return "desktop"
0 commit comments