added support for dell matinfo6 2025 html format

- this new format is handled by the new parser `DellConfiguratorParser2025`
- also added a sample and a test to chack that it works
- note: this parser has not been tested extensively, the results need to be verified

work related to [https://bugzilla.ipr.univ-rennes.fr/show_bug.cgi?id=4171]
This commit is contained in:
Guillaume Raffy 2025-10-21 16:01:29 +02:00
parent b702f1b230
commit 34552b83b5
7 changed files with 10999 additions and 187 deletions

View File

@ -219,6 +219,20 @@ intel-xeon-gold-6448h 2.4 32 4 250 0 0 60.0
intel-xeon-gold-6448y 2.1 32 2 225 0 0 60.0
intel-xeon-gold-6454s 2.2 32 2 270 0 0 60.0
intel-xeon-gold-6458q 3.1 32 2 350 0 0 60.0
intel-xeon-performance-6505p 2.2 12 2 150 0 0 48.0
intel-xeon-performance-6507p 3.5 8 2 150 0 0 48.0
intel-xeon-performance-6515p 2.3 16 2 150 0 0 72.0
intel-xeon-performance-6517p 3.2 16 2 190 0 0 72.0
intel-xeon-performance-6520p 2.4 24 2 210 0 0 144.0
intel-xeon-performance-6527p 3.0 24 2 250 0 0 144.0
intel-xeon-performance-6714p 4.0 8 2 165 0 0 48.0
intel-xeon-performance-6724p 3.6 16 2 210 0 0 72.0
intel-xeon-performance-6730p 2.5 32 2 250 0 0 288.0
intel-xeon-performance-6737p 2.9 32 2 270 0 0 144.0
intel-xeon-performance-6740p 2.1 48 2 270 0 0 288.0
intel-xeon-performance-6747p 2.7 48 2 350 0 0 288.0
intel-xeon-performance-6767p 2.4 64 2 350 0 0 336.0
intel-xeon-performance-6787p 2.0 86 2 350 0 0 336.0
intel-xeon-platinum-8444h 2.9 16 2 270 0 0 45.0
intel-xeon-platinum-8452y 2.0 36 2 300 0 0 67.5
intel-xeon-platinum-8458p 2.7 44 2 350 0 0 82.5

1 #id clock num_cores max_cpus tdp cpumark_1_cpu cpumark_2_cpu l3_cache_mb
219 intel-xeon-platinum-8444h intel-xeon-performance-6505p 2.9 2.2 16 12 2 270 150 0 0 45.0 48.0
220 intel-xeon-platinum-8452y intel-xeon-performance-6507p 2.0 3.5 36 8 2 300 150 0 0 67.5 48.0
221 intel-xeon-platinum-8458p intel-xeon-performance-6515p 2.7 2.3 44 16 2 350 150 0 0 82.5 72.0
222 intel-xeon-performance-6517p 3.2 16 2 190 0 0 72.0
223 intel-xeon-performance-6520p 2.4 24 2 210 0 0 144.0
224 intel-xeon-performance-6527p 3.0 24 2 250 0 0 144.0
225 intel-xeon-performance-6714p 4.0 8 2 165 0 0 48.0
226 intel-xeon-performance-6724p 3.6 16 2 210 0 0 72.0
227 intel-xeon-performance-6730p 2.5 32 2 250 0 0 288.0
228 intel-xeon-performance-6737p 2.9 32 2 270 0 0 144.0
229 intel-xeon-performance-6740p 2.1 48 2 270 0 0 288.0
230 intel-xeon-performance-6747p 2.7 48 2 350 0 0 288.0
231 intel-xeon-performance-6767p 2.4 64 2 350 0 0 336.0
232 intel-xeon-performance-6787p 2.0 86 2 350 0 0 336.0
233 intel-xeon-platinum-8444h 2.9 16 2 270 0 0 45.0
234 intel-xeon-platinum-8452y 2.0 36 2 300 0 0 67.5
235 intel-xeon-platinum-8458p 2.7 44 2 350 0 0 82.5
236 intel-xeon-platinum-8460y+ 2.0 40 2 300 0 0 105.0
237 intel-xeon-platinum-8462y+ 2.8 32 2 300 0 0 60.0
238 intel-xeon-platinum-8468 2.1 48 2 350 0 0 105.0

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
files in this directory:
- [./20251020 - Cat2 Conf 2-2-07_ Dell Poweredge R670.html] : obtained while saving [https://www.dell.com/premier/std-configs/fr/fr/rc1809648] on 20/10/2025

View File

@ -6,6 +6,7 @@ import numpy
import math
from pathlib import Path
import copy
import logging
ItemUid = str # eg 'dell-poweredge-c640'
CpuId = str # eg 'intel-xeon-gold-6140'
@ -126,6 +127,8 @@ class Cpu(Item):
proc_id = self.uid
if re.match('intel-core-i[357]-8[0-9][0-9][0-9][ktbuh]', proc_id):
return 'coffeelake'
elif re.match('intel-xeon-performance-[0-9][57][0-9][0-9]', proc_id):
return 'granite rapids'
elif re.match('intel-xeon-bronze-[0-9]4[0-9][0-9]', proc_id):
return 'sapphire rapids'
elif re.match('intel-xeon-silver-[0-9]4[0-9][0-9]', proc_id):
@ -186,12 +189,12 @@ class Cpu(Item):
proc_arch = self.architecture
dp_flops_per_cycle_per_core = -1
# from https://stackoverflow.com/questions/15655835/flops-per-cycle-for-sandy-bridge-and-haswell-sse2-avx-avx2
# Intel Core 2 and Nehalem:
#
# 4 DP FLOPs/cycle: 2-wide SSE2 addition + 2-wide SSE2 multiplication
# 8 SP FLOPs/cycle: 4-wide SSE addition + 4-wide SSE multiplication
#
# from https://stackoverflow.com/questions/15655835/flops-per-cycle-for-sandy-bridge-and-haswell-sse2-avx-avx2
# Intel Core 2 and Nehalem:
#
# 4 DP FLOPs/cycle: 2-wide SSE2 addition + 2-wide SSE2 multiplication
# 8 SP FLOPs/cycle: 4-wide SSE addition + 4-wide SSE multiplication
#
if proc_arch in ['sandy bridge', 'ivy bridge']:
@ -246,6 +249,9 @@ class Cpu(Item):
dp_flops_per_cycle_per_core = 32
# cpus_may2023_v3.pdf
if proc_arch in ['granite rapids']:
dp_flops_per_cycle_per_core = 32 # just a guess, I still don't know if there were some changes compared to sapphire rapids
if proc_arch == 'rome': # zen2
# from https://www.microway.com/knowledge-center-articles/detailed-specifications-of-the-amd-epyc-rome-cpus/:
# - Full support for 256-bit AVX2 instructions with two 256-bit FMA units per CPU core. The previous “Naples” architecture split 256-bit instructions into two separate 128-bit operations
@ -284,6 +290,7 @@ class Cpu(Item):
'cascadelake': 6,
'icelake': 8,
'sapphire rapids': 8,
'granite rapids': 8,
'rome': 8,
'milan': 8,
'siena': 6,
@ -330,6 +337,7 @@ def get_simd_id(proc_arch: CpuArchitecture) -> SimdId:
'cascadelake': 'avx-512',
'icelake': 'avx-512',
'sapphire rapids': 'avx-512',
'granite rapids': 'avx-512',
'coffeelake': 'avx2',
# from https://www.microway.com/knowledge-center-articles/detailed-specifications-of-the-amd-epyc-rome-cpus/:
# - Full support for 256-bit AVX2 instructions with two 256-bit FMA units per CPU core. The previous “Naples” architecture split 256-bit instructions into two separate 128-bit operations
@ -499,6 +507,7 @@ class Config():
dimm = mem_channel.dimms[dimm_slot_index]
if dimm is not None:
dimm_price = self.configurator.get_item_price(dimm.uid)
assert dimm_price is not None, 'failed to get price for dimm %s' % dimm.uid
ram_price += self.num_servers * dimm_price
return ram_price
@ -606,8 +615,9 @@ class Configurator():
if item_uid in module.options:
return module.options[item_uid].item
def get_item_price(self, item_uid: ItemUid) -> Price:
def get_item_price(self, item_uid: ItemUid) -> Optional[Price]:
for module in self.modules.values():
logging.debug(f'items in module {module.name}: {list(module.options.keys())}')
if item_uid in module.options:
return module.options[item_uid].price

View File

@ -8,6 +8,7 @@ from concho.config import Config
from concho.config import Chassis
from concho.config import Cpu, Dimm, Price, MemSizeGb, MemSize, CpuId, SdramChip, PmmChip
from concho.config import IHtmlConfiguratorParser
import numpy
from abc import abstractmethod
# from xml.dom.minidom import parse
@ -15,6 +16,7 @@ from lxml.html import parse as html_parse
from lxml.html import HtmlElement
import re
import copy
import logging
def clean_string(string):
@ -386,51 +388,107 @@ class DellPowerEdgeC6420(TableBasedConfigurator):
class DellConfiguratorParser(IHtmlConfiguratorParser):
use_additional_cpus_module: bool # whether to use the additional CPUs module to parse CPU change options
def __init__(self):
pass
def __init__(self, use_additional_cpus_module: bool):
self.use_additional_cpus_module = use_additional_cpus_module
def _get_module(self, root_element: HtmlElement, section_label: str) -> HtmlElement:
modules_element = root_element.xpath(self.get_xpath_filter('root_to_modules_element'))[0]
modules_elements = root_element.xpath(self.get_xpath_filter('root_to_modules_element'))
assert len(modules_elements) > 0, 'unable to find modules element'
modules_element = modules_elements[0]
# print(modules_element)
for module_root in modules_element.xpath(self.get_xpath_filter('modules_element_to_modules')):
# print(module_root)
logging.debug(f'module_root = {module_root}')
# blue modules such as "Processeurs (Passage)"
module_titles = module_root.xpath(self.get_xpath_filter('module_to_blue_title'))
if len(module_titles) > 0:
# print(len(module_title.text))
module_title = module_titles[0]
# print('module_title.text = %s ' % module_title.text)
# print(module_title.text_content())
if module_title.text == section_label:
return module_root
# grey modules such as 'Base'
module_titles = module_root.xpath(self.get_xpath_filter('module_to_grey_title'))
if len(module_titles) > 0:
# print(module_title.text)
# print(len(module_title.text))
module_title = module_titles[0]
# print(module_title.text_content())
if module_title.text == section_label:
return module_root
# assert False, 'failed to find module "%s"' % section_label
for filter_id in ['module_to_blue_title', 'module_to_grey_title']:
module_titles = module_root.xpath(self.get_xpath_filter(filter_id))
if len(module_titles) > 0:
# print(len(module_title.text))
raw_module_title = module_titles[0]
m = re.match(r'^\s*(?P<label>.+?)\s*$', raw_module_title.text_content(), re.DOTALL)
assert m, f'unable to parse module title "{raw_module_title.text_content()}"'
module_title = m.group('label')
logging.debug(f'module_title = {module_title}')
# print(module_title.text_content())
if module_title == section_label:
return module_root
assert False, 'failed to find module "%s"' % section_label
@abstractmethod
def price_str_as_float(self, price_as_str):
assert False
@abstractmethod
def get_module_label(self, module_id):
def get_module_label(self, module_id) -> str:
assert False
@abstractmethod
def get_xpath_filter(self, filter_id):
def get_xpath_filter(self, filter_id) -> str:
assert False
@abstractmethod
def get_base_price(self, html_root: HtmlElement) -> Price:
assert False
def _parse_chassis(self, html_root: HtmlElement) -> str:
module_root_element = self._get_module(html_root, 'Base')
assert module_root_element is not None
# option_root_elements = module_root_element.xpath(".//div[@class='row']")
# assert len(option_root_elements) > 0
# option_root_element = option_root_elements[0]
label_elements = module_root_element.xpath(self.get_xpath_filter('base_module_to_label'))
assert len(label_elements) > 0
label = label_elements[0].text_content().replace('\n', '')
# PowerEdge R640
match = re.match(r'^PowerEdge (?P<chassis_type>[CR][0-9][0-9][0-9][0-9]?).*', label)
assert match, 'unhandled label : %s' % label
# print(match['cpu_class'], match['cpu_number'])
chassis_id = "dell-poweredge-%s" % (match['chassis_type'].lower(), )
return chassis_id
def _parse_cpu_upgrade(self, label: str, price: Price) -> Option:
# print(label, price)
num_cpus = 1
# Passage à processeur Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
match = re.match(r'^Passage à processeur Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', label)
if match:
cpu_class = match['cpu_class'].lower()
if cpu_class == 'platinium':
cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
if match is None:
# Passage à 2 Processeurs Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
match = re.match(r'^passage à 2 processeurs intel xeon (?P<cpu_class>silver|gold|platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][rly]?).*', label.lower())
if match:
num_cpus = 2
cpu_class = match['cpu_class'].lower()
if cpu_class == 'platinium':
cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
if match is None:
# Passage à 2 processeurs AMD EPYC 7642 2.3GHz, 48C/96T, 256M Cache (225W) DDR4-3200
match = re.match(r'^passage à 2 processeurs amd epyc (?P<cpu_number>[0-9][0-9fh][0-9][0-9]).*', label.lower())
if match:
num_cpus = 2
cpu_id = "amd-epyc-%s" % (match['cpu_number'].lower())
if match is None:
# examples from dell 20/10/2025:
# Processeur Intel® Xeon® 6 Performance 6507P 3,5 GHz, 8C/16T, 24 GT/s, 48 Mo de cache, Turbo (150 W), mémoire DDR5-6400
# Intel® Xeon® 6 Performance 6714P 4,0 GHz, 8C/16T, 24 GT/s, 330 Mo de cache, Turbo (165 W), mémoire DDR5-6400
match = re.match(r'^(Processeur\s)?Intel[®]?\sXeon[®]?\s+6\s+(?P<cpu_class>Silver|Gold|Platinium|Performance)\s+(?P<cpu_number>[0-9][0-9][0-9][0-9][PRLYU]?).*', label)
if match:
cpu_class = match['cpu_class'].lower()
if cpu_class == 'platinium':
cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
assert match, 'unhandled label : %s' % label
# print(match['cpu_class'], match['cpu_number'])
option = Option(Cpu(cpu_id), price / num_cpus)
return option
def _parse_proc_change_options(self, html_root: HtmlElement) -> Module:
proc_options = Module('processor-change')
# module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
@ -438,108 +496,97 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
for option_root_element in module_root_element.xpath(self.get_xpath_filter('module_to_options')):
label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
if len(label_elements) > 0:
label = clean_string(label_elements[0].text_content().replace('\n', ''))
label = clean_string(label_elements[0].text_content().replace('\n', '').strip())
price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content())
# print(label, price)
num_cpus = 1
# Passage à processeur Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
match = re.match(r'^Passage à processeur Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', label)
if match:
cpu_class = match['cpu_class'].lower()
if cpu_class == 'platinium':
cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
if match is None:
# Passage à 2 Processeurs Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
match = re.match(r'^passage à 2 processeurs intel xeon (?P<cpu_class>silver|gold|platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][rly]?).*', label.lower())
if match:
num_cpus = 2
cpu_class = match['cpu_class'].lower()
if cpu_class == 'platinium':
cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
if match is None:
# Passage à 2 processeurs AMD EPYC 7642 2.3GHz, 48C/96T, 256M Cache (225W) DDR4-3200
match = re.match(r'^passage à 2 processeurs amd epyc (?P<cpu_number>[0-9][0-9fh][0-9][0-9]).*', label.lower())
if match:
num_cpus = 2
cpu_id = "amd-epyc-%s" % (match['cpu_number'].lower())
assert match, 'unhandled label : %s' % label
# print(match['cpu_class'], match['cpu_number'])
option = Option(Cpu(cpu_id), price / num_cpus)
option = self._parse_cpu_upgrade(label, price)
# print('_parse_proc_change_options : adding cpu %s (price = %f)' % (cpu_id, price / num_cpus))
proc_options.add_option(option)
return proc_options
def _parse_proc_options(self, html_root: HtmlElement) -> Module:
def _parse_additional_cpu(self, label: str, price: Price) -> Option:
# print(label, price)
num_additional_cpus = 1
match = re.match(r'^Processeur additionnel Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLY]?).*', label)
if match is None:
# Ajout de 2 Processeurs Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
match = re.match(r'^ajout de 2 processeurs intel xeon (?P<cpu_class>silver|gold|platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][rly]?).*', label.lower())
assert match, 'unhandled label : %s' % label
num_additional_cpus = 2
# print(match['cpu_class'], match['cpu_number'])
cpu_class = match['cpu_class'].lower()
if cpu_class == 'platinium':
cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
option = Option(Cpu(cpu_id), price / num_additional_cpus)
return option
def _parse_additional_proc_options(self, html_root: HtmlElement) -> Module:
proc_options = Module('processor')
# module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
module_root_element = self._get_module(html_root, self.get_module_label('additional_cpus'))
module_label = self.get_module_label('additional_cpus')
module_root_element = self._get_module(html_root, module_label)
if module_root_element is not None:
logging.debug(f'parsing processor options from module "{module_label}"')
for option_root_element in module_root_element.xpath(self.get_xpath_filter('module_to_options')):
logging.debug(f'option_root_element = {option_root_element}')
label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
if len(label_elements) > 0:
label = label_elements[0].text_content()
label = clean_string(label_elements[0].text_content().replace('\n', '').strip())
price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content())
# print(label, price)
num_additional_cpus = 1
match = re.match(r'^Processeur additionnel Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLY]?).*', label)
if match is None:
# Ajout de 2 Processeurs Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
match = re.match(r'^ajout de 2 processeurs intel xeon (?P<cpu_class>silver|gold|platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][rly]?).*', label.lower())
assert match, 'unhandled label : %s' % label
num_additional_cpus = 2
# print(match['cpu_class'], match['cpu_number'])
cpu_class = match['cpu_class'].lower()
if cpu_class == 'platinium':
cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
option = Option(Cpu(cpu_id), price / num_additional_cpus)
# print('_parse_proc_options : adding cpu %s (price = %f)' % (cpu_id, price / num_additional_cpus))
option = self._parse_additional_cpu(label, price)
proc_options.add_option(option)
else:
assert False
assert len(proc_options.options) > 0
return proc_options
def _parse_ram_option(self, label: str, price: Price) -> Option:
# print(label, price)
# Ajout d'une barette de 128Go 2667 Mhz LRDIMM
ram_option = None
match = re.match(r'^Ajout d\'une barette de (?P<num_gb>[0-9]+)Go (?P<num_mhz>[0-9][0-9][0-9][0-9]) *M[Hh]z (?P<mem_type>LRDIMM|RDIMM)$', label)
if match:
# print(match['num_gb'], match['num_mhz'])
num_gb = int(match['num_gb'])
num_mhz = int(match['num_mhz'])
mem_type = match['mem_type'].lower()
if num_gb == 8 and num_mhz == 2667 and mem_type == 'rdimm':
num_mhz = 2933 # error in r940 configurator : incoherence between 'Mémoire 32 Go DDR4 à 2933MHz (4x8Go)' and 'Ajout d'une barette de 8Go 2667 Mhz RDIMM'
sdram_chip = SdramChip(chip_type='ddr', generation=4, transfer_rate=num_mhz)
dimm = Dimm(num_gb=num_gb, sdram_chip=sdram_chip, cas=None, mem_type=mem_type)
ram_option = Option(dimm, price)
else:
# Optane DC Persistent Memory - 128Go 2666Mhz
match = re.match(r'^Optane DC Persistent Memory - (?P<num_gb>[0-9]+)Go (?P<num_mhz>[0-9][0-9][0-9][0-9])Mhz$', label)
if match:
# print(match['num_gb'], match['num_mhz'])
chip = PmmChip(transfer_rate=int(match['num_mhz']))
dimm = Dimm(num_gb=int(match['num_gb']), sdram_chip=chip, cas=None, mem_type='pmm') # persistent memory module
ram_option = Option(dimm, price)
else:
assert False, 'unhandled label : %s' % label
assert ram_option is not None
return ram_option
def _parse_ram_options(self, html_root: HtmlElement) -> Module:
ram_options = Module('ram')
# module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
module_root_element = self._get_module(html_root, self.get_module_label('ram_additions'))
ram_additions_label = self.get_module_label('ram_additions')
module_root_element = self._get_module(html_root, ram_additions_label)
for option_root_element in module_root_element.xpath(self.get_xpath_filter('module_to_options')):
label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
if len(label_elements) > 0:
label = label_elements[0].text_content()
price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content())
# print(label, price)
# Ajout d'une barette de 128Go 2667 Mhz LRDIMM
match = re.match(r'^Ajout d\'une barette de (?P<num_gb>[0-9]+)Go (?P<num_mhz>[0-9][0-9][0-9][0-9]) *M[Hh]z (?P<mem_type>LRDIMM|RDIMM)$', label)
if match:
ram_option = self._parse_ram_option(label, price)
ram_options.add_option(ram_option)
# print(match['num_gb'], match['num_mhz'])
num_gb = int(match['num_gb'])
num_mhz = int(match['num_mhz'])
mem_type = match['mem_type'].lower()
if num_gb == 8 and num_mhz == 2667 and mem_type == 'rdimm':
num_mhz = 2933 # error in r940 configurator : incoherence between 'Mémoire 32 Go DDR4 à 2933MHz (4x8Go)' and 'Ajout d'une barette de 8Go 2667 Mhz RDIMM'
sdram_chip = SdramChip(chip_type='ddr', generation=4, transfer_rate=num_mhz)
dimm = Dimm(num_gb=num_gb, sdram_chip=sdram_chip, cas=None, mem_type=mem_type)
option = Option(dimm, price)
ram_options.add_option(option)
else:
# Optane DC Persistent Memory - 128Go 2666Mhz
match = re.match(r'^Optane DC Persistent Memory - (?P<num_gb>[0-9]+)Go (?P<num_mhz>[0-9][0-9][0-9][0-9])Mhz$', label)
if match:
# print(match['num_gb'], match['num_mhz'])
chip = PmmChip(transfer_rate=int(match['num_mhz']))
dimm = Dimm(num_gb=int(match['num_gb']), sdram_chip=chip, cas=None, mem_type='pmm') # persistent memory module
option = Option(dimm, price)
ram_options.add_option(option)
else:
assert False, 'unhandled label : %s' % label
assert len(ram_options.options) > 0
return ram_options
@abstractmethod
def _get_module_default_item(self, module_label, html_root: HtmlElement):
def _get_module_default_item_label(self, module_label, html_root: HtmlElement):
assert False
def _parse_base_config(self, html_root: HtmlElement, configurator: Configurator) -> Config:
@ -548,7 +595,7 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
base_config.num_cpu_per_server = 1
# initialize cpu
item_label = self._get_module_default_item('Processeurs (Passage)', html_root)
item_label = self._get_module_default_item_label('Processeurs (Passage)', html_root)
# Processeur Intel Xeon Silver 4208 2.1GHz,11M Cache,9.60GT/s, 2UPI,No Turbo, HT,8C/16T (85W) - DDR4-2400
match = re.match(r'^Processeur Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', item_label)
if match:
@ -572,7 +619,7 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
base_config.set_cpu(Cpu(cpu_id))
# initialize the default ram dimms
item_label = self._get_module_default_item(self.get_module_label('ram_change'), html_root)
item_label = self._get_module_default_item_label(self.get_module_label('ram_change'), html_root)
# Mémoire 16 Go DDR4 à 2933MHz (1x16Go)
match = re.match(r'^Mémoire (?P<num_gb>[0-9]+) Go DDR[\-]?4 à (?P<num_mhz>[0-9]+)MHz \((?P<num_dimms>[0-9]+)x(?P<num_gb_per_dimm>[0-9]+)Go\)', item_label.replace('Mémoire de base : ', '').replace('De base ', ''))
assert match, 'unhandled label : %s' % item_label
@ -629,6 +676,80 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
return base_cpu_price
@staticmethod
def _estimate_base_cpu_price_from_public_prices(cpu_uid: CpuId) -> Price:
# estimation of xeon 6505p price on matinfo 6:
# matinfo 6 prices:
# - upgrade from 2 x 6505p to 2 x 6507p: 186.0 €
# - upgrade from 2 x 6505p to 2 x 6515p: 242.0 €
# - upgrade from 2 x 6505p to 2 x 6737p: 6465.0 €
# retail prices [https://en.wikipedia.org/wiki/Granite_Rapids#Granite_Rapids-SP]:
public_prices_usd = {
'intel-xeon-performance-6505p': 563.0,
'intel-xeon-performance-6507p': 765.0,
'intel-xeon-performance-6515p': 740.0,
'intel-xeon-performance-6517p': 1195.0,
# 'intel-xeon-performance-6730p': 2234.0,
# 'intel-xeon-performance-6737p': 4995.0,
# 'intel-xeon-performance-6740p': 4650.0,
# 'intel-xeon-performance-6767p': 9595.0,
# 'intel-xeon-performance-6787p': 10400.0,
}
matinfo6_upgrade_prices_eur = {
'intel-xeon-performance-6507p': 186.0,
'intel-xeon-performance-6515p': 242.0,
'intel-xeon-performance-6517p': 902.0,
# 'intel-xeon-performance-6730p': 3674.0,
# 'intel-xeon-performance-6737p': 6465.0,
# 'intel-xeon-performance-6740p': 6724.0,
# 'intel-xeon-performance-6767p': 9672.0,
# 'intel-xeon-performance-6787p': 10775.0,
}
# let p_x be the public price of cpu x in usd (known)
# let m_x be the matinfo6 price of cpu x in eur (unknown)
# we assume m_x = p_x * r, where the unknown r encompasses currency conversion usd->eur and reseller discount
# we have:
# u_x = 2 m_x - 2 m_base_cpu (1) (because the upgrades are for 2 cpus)
# we want to find m_base_cpu
# => m_base_cpu = m_x - u_x / 2 (2) (from (1))
# but m_x is unknown, so we use (2) and m_x = p_x * r
# => m_base_cpu = p_x * r - u_x / 2
# we can do this for each upgrade cpu and average the results to eliminate r
# so we have a system of equations with 2 unknowns (m_base_cpu and r) and multiple equations:
# => 2 * m_base_cpu = 2 * p_x * r - u_x
# => u_x = 2 * p_x * r - 2 * m_base_cpu
# in matrix form:
# [ u_1 ] = [ 2*p_1 -2 ] * [ r ]
# [ u_2 ] [ 2*p_2 -2 ] [ m_base_cpu ]
# [ ... ] [ ... ... ]
# [ u_n ] [ 2*p_n -2 ]
# we can solve this system using least squares to find m_base_cpu
# U = A * X
# where U is the vector of upgrade prices u_x
# A is the matrix with rows [2*p_x -2]
# X is the vector [r, m_base_cpu] to find
# => X = (A^T A)^-1 A^T U
upgrade_keys = list(matinfo6_upgrade_prices_eur.keys())
A = numpy.zeros((len(matinfo6_upgrade_prices_eur), 2))
U = numpy.zeros((len(matinfo6_upgrade_prices_eur), 1))
P = numpy.array([public_prices_usd[cpu_uid] for cpu_uid in upgrade_keys])
A[:, 0] = 2.0 * P
A[:, 1] = -2.0
U[:, 0] = numpy.array([matinfo6_upgrade_prices_eur[cpu_uid] for cpu_uid in upgrade_keys])
AtA_inv = numpy.linalg.inv(numpy.matmul(A.T, A))
AtU = numpy.matmul(A.T, U)
X = numpy.matmul(AtA_inv, AtU)
r_estimated = X[0, 0]
base_cpu_price = X[1, 0]
logging.info('estimated currency conversion and discount rate r : %f' % r_estimated)
logging.info('estimated price of base cpu %s from linear system : %f' % (cpu_uid, base_cpu_price))
estimated_prices = P * r_estimated
estimated_u = 2 * estimated_prices - 2 * base_cpu_price
for i, cpu_uid in enumerate(matinfo6_upgrade_prices_eur.keys()):
logging.info('estimated price of cpu %s : %f €, matinfo6 upgrade price : %f €, estimated upgrade price : %f' % (cpu_uid, estimated_prices[i], matinfo6_upgrade_prices_eur[cpu_uid], estimated_u[i]))
return base_cpu_price
def parse(self, dell_configurator_html_file_path: Path, configurator: Configurator):
html_root = html_parse(dell_configurator_html_file_path).getroot()
@ -645,27 +766,19 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
# modules_element = body.xpath("//div[@class='col-md-10']")
module_root_element = self._get_module(html_root, 'Base')
assert module_root_element is not None
# option_root_elements = module_root_element.xpath(".//div[@class='row']")
# assert len(option_root_elements) > 0
# option_root_element = option_root_elements[0]
label_elements = module_root_element.xpath(self.get_xpath_filter('base_module_to_label'))
assert len(label_elements) > 0
label = label_elements[0].text_content().replace('\n', '')
# PowerEdge R640
match = re.match(r'^PowerEdge (?P<chassis_type>[CR][0-9][0-9][0-9][0-9]?).*', label)
assert match, 'unhandled label : %s' % label
# print(match['cpu_class'], match['cpu_number'])
chassis_id = "dell-poweredge-%s" % (match['chassis_type'].lower(), )
chassis_id = self._parse_chassis(html_root)
configurator.chassis = Option(Chassis(chassis_id), 0.0)
configurator.base_config = self._parse_base_config(html_root, configurator)
proc_change_module = self._parse_proc_change_options(html_root)
configurator.add_module(proc_change_module)
configurator.add_module(self._parse_proc_options(html_root))
if self.use_additional_cpus_module:
proc_add_module = self._parse_additional_proc_options(html_root)
else:
proc_add_module = Module('processor')
configurator.add_module(proc_add_module)
# if self.get_module_label('ram_additions') != '':
configurator.add_module(self._parse_ram_options(html_root))
# compute the price of the base config cpu because for example in r940 configurator, the xeon gold 5215 appears in the basic config but not in additional cpus
@ -675,8 +788,10 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
if base_cpu_price is None:
# in the case of r6525, there was no additional processor module, and therefore we have no way to estimate the price of the base processor (amd-epyc-7262)
# so we fallback to an hardcoded estimated price from wikipedia
base_cpu_price = {
'amd-epyc-7262': 550.0,
'intel-xeon-performance-6505p': DellConfiguratorParser._estimate_base_cpu_price_from_public_prices(cpu_uid=base_cpu.uid),
}[base_cpu.uid]
configurator.modules['processor'].add_option(Option(base_cpu, base_cpu_price))
assert configurator.get_item_price(base_cpu.uid) is not None, 'failed to find the price of base cpu %s' % base_cpu.uid
@ -711,7 +826,7 @@ class DellConfiguratorParser2020(DellConfiguratorParser):
def __init__(self):
super().__init__()
def get_module_label(self, module_id):
def get_module_label(self, module_id) -> str:
return {
'cpu_change': 'Processeurs (Passage)',
'additional_cpus': 'Processeurs additionnels',
@ -719,7 +834,7 @@ class DellConfiguratorParser2020(DellConfiguratorParser):
'ram_additions': 'Mémoire: Ajout de barettes additionnelles',
}[module_id]
def get_xpath_filter(self, filter_id):
def get_xpath_filter(self, filter_id) -> str:
return {
'root_to_modules_element': ".//div[@class='col-md-10']",
'modules_element_to_modules': ".//div[@class='col-md-12 module']",
@ -739,7 +854,7 @@ class DellConfiguratorParser2020(DellConfiguratorParser):
price_as_float = float("%s%s" % (match['sign'], match['numbers']))
return price_as_float
def _get_module_default_item(self, module_label, html_root):
def _get_module_default_item_label(self, module_label, html_root) -> str:
module_root_element = self._get_module(html_root, module_label)
assert module_root_element is not None
for option_root_element in module_root_element.xpath(".//div[@class='row']"):
@ -752,7 +867,7 @@ class DellConfiguratorParser2020(DellConfiguratorParser):
return label
assert False, 'failed to find the default item of module %s' % module_label
def get_base_price(self, html_root):
def get_base_price(self, html_root) -> Price:
base_price = None
price_preview_element = html_root.xpath(".//div[@class='price-preview']")[0]
assert price_preview_element is not None
@ -783,7 +898,7 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
def __init__(self):
super().__init__()
def get_module_label(self, module_id):
def get_module_label(self, module_id) -> str:
return {
'cpu_change': 'Processeurs (Passage)',
'additional_cpus': 'Processeurs additionnels',
@ -791,7 +906,7 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
'ram_additions': 'Mémoire: Ajout de barettes additionnelles',
}[module_id]
def get_xpath_filter(self, filter_id):
def get_xpath_filter(self, filter_id) -> str:
return {
'root_to_modules_element': ".//div[@class='modules']",
'modules_element_to_modules': ".//div[@class='product-module-configuration']",
@ -812,7 +927,7 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
price_as_float = float("%s%s" % (match['sign'], match['numbers']))
return price_as_float
def _get_module_default_item(self, module_label, html_root):
def _get_module_default_item_label(self, module_label, html_root) -> str:
module_root_element = self._get_module(html_root, module_label)
assert module_root_element is not None
@ -843,7 +958,7 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
return label
assert False, 'failed to find the default item of module %s' % module_label
def get_base_price(self, html_root):
def get_base_price(self, html_root) -> Price:
base_price = None
price_preview_element = html_root.xpath(".//div[@class='product-info']")[0]
assert price_preview_element is not None
@ -866,28 +981,32 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
return base_price
class DellV2ConfiguratorParser(DellConfiguratorParser):
class DellConfiguratorParser2025(DellConfiguratorParser):
def __init__(self):
super().__init__()
super().__init__(use_additional_cpus_module=False) # no information is available in additional_cpus module. use cpu_change module instead
def get_module_label(self, module_id):
def _parse_chassis(self, html_root: HtmlElement) -> str:
chassis_id = "dell-poweredge-r670"
return chassis_id
def get_module_label(self, module_id) -> str:
return {
'cpu_change': 'Processeurs (Passage)',
'additional_cpus': 'Processeurs additionnels',
'ram_change': 'Mémoires (Passage)',
'ram_additions': 'Mémoire: Ajout de barettes additionnelles',
'cpu_change': 'Processeur',
'additional_cpus': 'Processeur supplémentaires',
'ram_change': 'Capacité de mémoire',
'ram_additions': '', # no ram additions module in 10/2025
}[module_id]
def get_xpath_filter(self, filter_id):
def get_xpath_filter(self, filter_id) -> str:
return {
'root_to_modules_element': ".//div[@id='technicalspecification_section']",
'modules_element_to_modules': ".//div[@class='product-module-configuration']",
'module_to_blue_title': ".//header",
'module_to_grey_title': ".//div[@class='col-md-4 module-title color-808080']",
'module_to_options': ".//div[@class='product-options-configuration-line']",
'option_to_label': ".//div[@class='option-info']",
'option_to_price': ".//div[@class='option-price']",
'modules_element_to_modules': ".//div[@cf-module-rank='0']", # cf-module-rank="0"
'module_to_blue_title': ".//span[@class='dds__subtitle-2']",
'module_to_grey_title': ".//span[@class='dds__subtitle-2']", # no grey titles in v2 ?
'module_to_options': ".//div[@class='cf-input-wrap ']", # <div class="cf-input-wrap ">
'option_to_label': ".//div[@class='dds__option-title1']", # <div class="dds__option-title1" cf-option-modcount="0">
'option_to_price': ".//div[@class='cf-opt-price']", # <div class="cf-opt-price">
'base_module_to_label': ".//div[@class='product-options-configuration-block option-selected']",
}[filter_id]
@ -900,56 +1019,229 @@ class DellV2ConfiguratorParser(DellConfiguratorParser):
price_as_float = float("%s%s" % (match['sign'], match['numbers']))
return price_as_float
def _get_module_default_item(self, module_label, html_root):
def _get_module_selected_items(self, module_root_element: HtmlElement) -> List[HtmlElement]:
logging.debug('getting selected items for module %s' % module_root_element)
selected_option_filter = ".//div[@class='cf-input-wrap selected-dds2 ']"
selected_items = [selected_option_root_element for selected_option_root_element in module_root_element.xpath(selected_option_filter)]
logging.debug('found %d selected items for module %s' % (len(selected_items), module_root_element))
return selected_items
def _get_item_label(self, item_root_element: HtmlElement) -> str:
label_filter = self.get_xpath_filter('option_to_label')
label_elements = item_root_element.xpath(label_filter)
assert label_elements is not None
assert len(label_elements) > 0
label = label_elements[0].text_content().replace('\n', '').strip()
return label
def _get_item_count(self, item_root_element: HtmlElement) -> int:
qty_roller_filter = ".//div[@class='cf-qty-roller-wrap']" # <div class="cf-qty-roller-wrap">
qty_roller_elements = item_root_element.xpath(qty_roller_filter)
if len(qty_roller_elements) > 0:
assert len(qty_roller_elements) == 1, 'unexpected multiple qty roller elements'
# <input type="text" value="2" data-cf-min="2"
# data-cf-max="32" class="cf-s-input"
# aria-label="NumberSpinner "
# data-cf-fixed-duration-json=""
# data-cf-spinner-type=""
# data-cf-selected-year="2"
# data-cf-maximum-term="0"
# data-cf-minimum-term="0"
# data-cf-selected-term="0"
# data-cf-trigger="cf-qty-roller-changed"
# readonly="readonly">
input_elements = qty_roller_elements[0].xpath(".//input[@type='text']")
assert input_elements is not None
assert len(input_elements) > 0
qty_as_str = input_elements[0].get('value')
qty = int(qty_as_str)
else:
qty = 0
return qty
def _get_item_price(self, item_root_element: HtmlElement) -> float:
price_filter = self.get_xpath_filter('option_to_price')
price_elements = item_root_element.xpath(price_filter)
assert price_elements is not None
assert len(price_elements) > 0
price_as_str = price_elements[0].text_content().strip()
# remove potential trailing per unit symbol, such as in '142,00 € /U.'
price_as_str = price_as_str.replace('/U.', '').strip()
logging.debug('item price string : %s' % price_as_str)
match = re.match(r'^Inclus dans le prix$', price_as_str)
logging.debug('item price match : %s' % str(match))
if match is not None:
logging.debug('item price is not defined')
price = 0.0
else:
logging.debug('item price is defined')
price = self.price_str_as_float(price_as_str)
return price
def _get_module_default_item_label(self, module_label, html_root) -> str:
logging.debug('getting default item label for module %s' % module_label)
module_root_element = self._get_module(html_root, module_label)
assert module_root_element is not None
if module_label == self.get_module_label('ram_change'):
# <div
# class="product-options-configuration-block option-selected">
# <header>Mémoire 16 Go DDR4 à 3200MHz (1x16Go)<div
# class="option-selector"><i
# class="fas fa-check "></i></div>
# </header>
# <div class="mt-2 option-price">+ 0,00&nbsp;€</div>
# </div>
selected_option_filter = ".//div[@class='product-options-configuration-block option-selected']"
label_filter = ".//header"
price_filter = ".//div[@class='mt-2 option-price']"
selected_option_root_elements = self._get_module_selected_items(module_root_element)
assert len(selected_option_root_elements) == 1, 'we expect 1 selected items for module %s, not %d' % (module_label, len(selected_option_root_elements))
selected_option_root_element = selected_option_root_elements[0]
label = self._get_item_label(selected_option_root_element)
logging.debug('default item for module %s : %s' % (module_label, label))
price = self._get_item_price(selected_option_root_element)
assert price == 0.0, 'unexpected price for default item (%d €)' % price
return label
def _parse_ram_option(self, label: str, price: Price) -> Option:
# print(label, price)
# Ajout d'une barette de 128Go 2667 Mhz LRDIMM
ram_option = None
# Mémoire 16 Go DDR4 à 2933MHz (1x16Go)
# 32 Go RDIMM, 6 400 MT/s, double rangée
# match = re.match(r'^Mémoire (?P<num_gb>[0-9]+) Go DDR[\-]?4 à (?P<num_mhz>[0-9]+)MHz \((?P<num_dimms>[0-9]+)x(?P<num_gb_per_dimm>[0-9]+)Go\)', item_label.replace('Mémoire de base : ', '').replace('De base ', ''))
# match = re.match(r'^(?P<num_gb>[0-9]+) Go (de mémoire|) RDIMM, (?P<num_mts>[0-9] ?[0-9]+)\s+MT/s, (une|double) rangée', item_label)
match = re.match(r'^(?P<num_gb_per_dimm>[0-9]+)\s+Go(\s+de mémoire|)\s+RDIMM,\s+(?P<num_mts>[0-9]\s?[0-9]+)\s+MT/s,\s+(une|double)\s+rangée', label)
assert match, 'unhandled label : %s' % label
# DDR5 RDIMM 6400 MT/s
num_mts = match['num_mts']
# remove non ascii characters from num_mts
num_mts = re.sub(r'[^0-9]', '', num_mts)
sdram_chip = SdramChip(chip_type='ddr', generation=5, transfer_rate=int(num_mts))
dimm = Dimm(num_gb=int(match['num_gb_per_dimm']), sdram_chip=sdram_chip, cas=None, mem_type='rdimm')
ram_option = Option(dimm, price)
assert ram_option is not None
return ram_option
def _parse_ram_options(self, html_root: HtmlElement) -> Module:
ram_options = Module('ram')
# module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
module_root_element = self._get_module(html_root, self.get_module_label('ram_change'))
for pass_id in ['non_selected_rams', 'selected_rams']:
options_xpath_filter = self.get_xpath_filter('module_to_options')
if pass_id == 'selected_rams':
options_xpath_filter = options_xpath_filter.replace(" ", " selected-dds2 ")
for option_root_element in module_root_element.xpath(options_xpath_filter):
label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
assert len(label_elements) > 0, 'failed to find label for ram option'
label = label_elements[0].text_content().strip()
price = self._get_item_price(option_root_element)
if price == 0.0:
assert pass_id == 'selected_rams', 'unexpected ram option price %f for label %s' % (price, label)
# find the price in the selector widget, which displays as: '[-][2][+] 235,00 € /U.'
# <div class="cf-qty-wrap">
# <div class="cf-qty-roller-wrap">
# <div class="cf-s-box">
# <button class="cf-s-minus stop">-</button>
# <input type="text" value="2" data-cf-min="2"
# data-cf-max="32" class="cf-s-input"
# aria-label="NumberSpinner "
# data-cf-fixed-duration-json=""
# data-cf-spinner-type=""
# data-cf-selected-year="2"
# data-cf-maximum-term="0"
# data-cf-minimum-term="0"
# data-cf-selected-term="0"
# data-cf-trigger="cf-qty-roller-changed"
# readonly="readonly">
# <button class="cf-s-plus ">+</button>
# </div>
# </div>
# <div>235,00 € /U.</div>
# </div>
qty_roller_wrap_elements = option_root_element.xpath(".//div[@class='cf-qty-wrap']")
assert len(qty_roller_wrap_elements) == 1, 'failed to find qty roller wrap for ram option with label %s' % label
price_elements = qty_roller_wrap_elements[0].xpath("./div")
assert len(price_elements) == 2, 'unexpected number of price elements (%d) in qty roller wrap for ram option with label %s' % (len(price_elements), label)
price_as_str = price_elements[1].text_content().strip().replace('/U.', '').strip()
price = self.price_str_as_float(price_as_str)
assert price > 0.0, 'unexpected ram option price %f for label %s' % (price, label)
ram_option = self._parse_ram_option(label, price)
logging.debug('adding ram option : %s with price %f' % (label, price))
ram_options.add_option(ram_option)
assert len(ram_options.options) > 0
return ram_options
def _parse_base_config(self, html_root: HtmlElement, configurator: Configurator) -> Config:
base_config = Config(configurator)
base_config.num_servers = configurator.chassis.item.max_num_servers
base_config.num_cpu_per_server = 1
# initialize cpu
item_label = self._get_module_default_item_label('Processeur', html_root)
# Processeur Intel Xeon Silver 4208 2.1GHz,11M Cache,9.60GT/s, 2UPI,No Turbo, HT,8C/16T (85W) - DDR4-2400
# match = re.match(r'^Processeur Intel[®]? Xeon[®]? 6 (?P<cpu_class>Silver|Gold|Platinium|Performance) (?P<cpu_number>[0-9][0-9][0-9][0-9][PRLYU]?).*', item_label)
match = re.match(r'^Processeur Intel[®]? Xeon[®]?\s+6\s+(?P<cpu_class>Silver|Gold|Platinium|Performance)\s+(?P<cpu_number>[0-9][0-9][0-9][0-9][PRLYU]?).*', item_label)
assert match, 'unhandled label : %s' % item_label
if match:
cpu_id = "intel-xeon-%s-%s" % (match['cpu_class'].lower(), match['cpu_number'].lower())
if match is None:
match = re.match(r'^2 processeurs Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', item_label)
if match:
base_config.num_cpu_per_server = 2
cpu_id = "intel-xeon-%s-%s" % (match['cpu_class'].lower(), match['cpu_number'].lower())
if match is None:
print('item_label=%s' % item_label)
# match = re.match(r'^2 Processeurs AMD EPYC (?P<cpu_number>[0-9][0-9][0-9][0-9]).*', item_label)
match = re.match(r'^2 Processeurs AMD EPYC (?P<cpu_number>[0-9][0-9][0-9][0-9]).*', clean_string(item_label))
if match:
base_config.num_cpu_per_server = 2
cpu_id = "amd-epyc-%s" % (match['cpu_number'].lower())
assert match, 'unhandled label : %s' % item_label
# print(match['cpu_class'], match['cpu_number'])
base_config.set_cpu(Cpu(cpu_id))
# initialize the default ram dimms
ram_module_root_element = self._get_module(html_root, self.get_module_label('ram_change'))
assert ram_module_root_element is not None
selected_ram_items = self._get_module_selected_items(ram_module_root_element)
assert (len(selected_ram_items) == 1), 'unexpected number of selected ram items : %d' % len(selected_ram_items)
selected_ram_item = selected_ram_items[0]
item_label = self._get_item_label(selected_ram_item)
num_dimms = self._get_item_count(selected_ram_item)
ram_option = self._parse_ram_option(item_label, 0.0)
dimm = ram_option.item
if num_dimms == 1:
# print(match['cpu_class'], match['cpu_number'])
cpu_slot_index = 0
mem_channel_index = 0
dimm_slot = 0
base_config.cpu_slots_mem[cpu_slot_index].mem_channels[mem_channel_index].dimms[dimm_slot] = dimm
else:
selected_option_filter = ".//div[@class='product-options-configuration-line option-selected']"
label_filter = ".//div[@class='option-info']"
price_filter = ".//div[@class='option-price']"
# evenly split dimms on channels
assert (num_dimms % base_config.num_cpu_per_server) == 0
num_dimms_per_cpu = num_dimms // base_config.num_cpu_per_server
for cpu_slot_index in range(base_config.num_cpu_per_server):
cpu_slots_mem = base_config.cpu_slots_mem[cpu_slot_index]
assert len(cpu_slots_mem.mem_channels) >= num_dimms_per_cpu
for channel_index in range(num_dimms_per_cpu):
mem_channel = cpu_slots_mem.mem_channels[channel_index]
dimm_slot = 0
mem_channel.dimms[dimm_slot] = dimm
for selected_option_root_element in module_root_element.xpath(selected_option_filter):
label_elements = selected_option_root_element.xpath(label_filter)
assert label_elements is not None
if len(label_elements) > 0:
label = label_elements[0].text_content().replace('\n', '')
price = self.price_str_as_float(selected_option_root_element.xpath(price_filter)[0].text_content())
assert price == 0.0, 'default items are expected to have a price of 0.0 € (%s s price is %f)' % (label, price)
return label
assert False, 'failed to find the default item of module %s' % module_label
return base_config
def get_base_price(self, html_root):
def get_base_price(self, html_root) -> Price:
# <div class="cf-hero-starting-price">
# <div class="cf-disable-tooltip" js-tooltip="" js-is-clickable="">
# <div id="cf-hero-starting-price-with-currency">2672,00 €</div>
# </div>
# </div>
base_price = None
price_preview_element = html_root.xpath(".//div[@class='product-info']")[0]
assert price_preview_element is not None
for price_element in price_preview_element.xpath(".//div[@class='info']"):
price_label_element = price_element.xpath(".//span[@class='info-label']")[0]
# <div class="info"><span class="info-label">Prix de base</span><span
# class="info-value strong">1175 € HT</span></div>
# <hr>
# <div class="info"><span class="info-label">Avec options</span><span
# class="info-value strong">1175 € HT</span></div>
# <hr>
assert price_label_element is not None
label = price_label_element.text_content().replace('\n', '')
if label == 'Prix de base':
price_value_element = price_element.xpath(".//span[@class='info-value strong']")[0]
assert price_value_element is not None
base_price = self.price_str_as_float(price_value_element.text_content().replace(' HT', ''))
price_value_element = html_root.xpath(".//div[@id='cf-hero-starting-price-with-currency']")[0]
assert price_value_element is not None
base_price = self.price_str_as_float(price_value_element.text_content().replace(' HT', ''))
assert base_price is not None
return base_price
@ -1036,5 +1328,3 @@ class DellMatinfoCsvConfigurator(Configurator):
config.cpu_slots_mem = copy.deepcopy(self.base_config.cpu_slots_mem)
return config

View File

@ -145,6 +145,7 @@ def plot_configs(configs: List[Config], xaxis_def: ConfigAxisDef, yaxis_def: Con
'cascadelake': 0.8,
'icelake': 0.9,
'sapphire rapids': 1.0,
'granite rapids': 1.0,
'rome': 0.2,
'milan': 0.4,
'siena': 1.0,
@ -166,6 +167,7 @@ def plot_configs(configs: List[Config], xaxis_def: ConfigAxisDef, yaxis_def: Con
'dell-poweredge-r620': 0.6,
'dell-poweredge-r630': 0.6,
'dell-poweredge-r640': 0.6,
'dell-poweredge-r670': 0.6,
'dell-poweredge-r6525': 0.5,
'dell-poweredge-c4310': 0.6,
'dell-poweredge-r730': 0.4,

32
tests/test_dell_2025.py Normal file
View File

@ -0,0 +1,32 @@
import unittest
from pathlib import Path
from concho.config import HtmlConfigurator
from concho.dell import DellConfiguratorParser2025
from concho.procs_chooser import plot_configurators
from concho.procs_chooser import ConfigPrice
from concho.procs_chooser import ConfigFlopsPerEuro
import logging
class Test(unittest.TestCase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plots_dir = Path('./plots/')
self.plots_dir.mkdir(exist_ok=True, parents=True)
logging.basicConfig(level=logging.DEBUG)
def test_dell2_configs(self):
logging.info('Starting test_dell2_configs')
configurators = [
HtmlConfigurator(Path('catalogs/dell/2025-10/20251020 - Cat2 Conf 2-2-07_ Dell Poweredge R670.html'), DellConfiguratorParser2025()),
]
def config_filter(config):
return True # config.get_price() < 40000.0
plot_configurators(configurators=configurators, ram_per_core=4.0e9, xaxis_def=ConfigPrice(), yaxis_def=ConfigFlopsPerEuro(), plot_title='physmol/dbossion ais configs', config_filter=config_filter, figure_file_path=self.plots_dir / '2025-10-dbossion-ais-configs.pdf')
if __name__ == '__main__':
unittest.main()