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-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-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-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-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-8452y 2.0 36 2 300 0 0 67.5
intel-xeon-platinum-8458p 2.7 44 2 350 0 0 82.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 import math
from pathlib import Path from pathlib import Path
import copy import copy
import logging
ItemUid = str # eg 'dell-poweredge-c640' ItemUid = str # eg 'dell-poweredge-c640'
CpuId = str # eg 'intel-xeon-gold-6140' CpuId = str # eg 'intel-xeon-gold-6140'
@ -126,6 +127,8 @@ class Cpu(Item):
proc_id = self.uid proc_id = self.uid
if re.match('intel-core-i[357]-8[0-9][0-9][0-9][ktbuh]', proc_id): if re.match('intel-core-i[357]-8[0-9][0-9][0-9][ktbuh]', proc_id):
return 'coffeelake' 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): elif re.match('intel-xeon-bronze-[0-9]4[0-9][0-9]', proc_id):
return 'sapphire rapids' return 'sapphire rapids'
elif re.match('intel-xeon-silver-[0-9]4[0-9][0-9]', proc_id): elif re.match('intel-xeon-silver-[0-9]4[0-9][0-9]', proc_id):
@ -246,6 +249,9 @@ class Cpu(Item):
dp_flops_per_cycle_per_core = 32 dp_flops_per_cycle_per_core = 32
# cpus_may2023_v3.pdf # 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 if proc_arch == 'rome': # zen2
# from https://www.microway.com/knowledge-center-articles/detailed-specifications-of-the-amd-epyc-rome-cpus/: # 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 # - 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, 'cascadelake': 6,
'icelake': 8, 'icelake': 8,
'sapphire rapids': 8, 'sapphire rapids': 8,
'granite rapids': 8,
'rome': 8, 'rome': 8,
'milan': 8, 'milan': 8,
'siena': 6, 'siena': 6,
@ -330,6 +337,7 @@ def get_simd_id(proc_arch: CpuArchitecture) -> SimdId:
'cascadelake': 'avx-512', 'cascadelake': 'avx-512',
'icelake': 'avx-512', 'icelake': 'avx-512',
'sapphire rapids': 'avx-512', 'sapphire rapids': 'avx-512',
'granite rapids': 'avx-512',
'coffeelake': 'avx2', 'coffeelake': 'avx2',
# from https://www.microway.com/knowledge-center-articles/detailed-specifications-of-the-amd-epyc-rome-cpus/: # 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 # - 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] dimm = mem_channel.dimms[dimm_slot_index]
if dimm is not None: if dimm is not None:
dimm_price = self.configurator.get_item_price(dimm.uid) 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 ram_price += self.num_servers * dimm_price
return ram_price return ram_price
@ -606,8 +615,9 @@ class Configurator():
if item_uid in module.options: if item_uid in module.options:
return module.options[item_uid].item 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(): for module in self.modules.values():
logging.debug(f'items in module {module.name}: {list(module.options.keys())}')
if item_uid in module.options: if item_uid in module.options:
return module.options[item_uid].price 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 Chassis
from concho.config import Cpu, Dimm, Price, MemSizeGb, MemSize, CpuId, SdramChip, PmmChip from concho.config import Cpu, Dimm, Price, MemSizeGb, MemSize, CpuId, SdramChip, PmmChip
from concho.config import IHtmlConfiguratorParser from concho.config import IHtmlConfiguratorParser
import numpy
from abc import abstractmethod from abc import abstractmethod
# from xml.dom.minidom import parse # from xml.dom.minidom import parse
@ -15,6 +16,7 @@ from lxml.html import parse as html_parse
from lxml.html import HtmlElement from lxml.html import HtmlElement
import re import re
import copy import copy
import logging
def clean_string(string): def clean_string(string):
@ -386,60 +388,68 @@ class DellPowerEdgeC6420(TableBasedConfigurator):
class DellConfiguratorParser(IHtmlConfiguratorParser): class DellConfiguratorParser(IHtmlConfiguratorParser):
use_additional_cpus_module: bool # whether to use the additional CPUs module to parse CPU change options
def __init__(self): def __init__(self, use_additional_cpus_module: bool):
pass self.use_additional_cpus_module = use_additional_cpus_module
def _get_module(self, root_element: HtmlElement, section_label: str) -> HtmlElement: 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) # print(modules_element)
for module_root in modules_element.xpath(self.get_xpath_filter('modules_element_to_modules')): 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)" # 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' # grey modules such as 'Base'
module_titles = module_root.xpath(self.get_xpath_filter('module_to_grey_title')) 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: if len(module_titles) > 0:
# print(module_title.text)
# print(len(module_title.text)) # print(len(module_title.text))
module_title = module_titles[0] 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()) # print(module_title.text_content())
if module_title.text == section_label: if module_title == section_label:
return module_root return module_root
# assert False, 'failed to find module "%s"' % section_label assert False, 'failed to find module "%s"' % section_label
@abstractmethod @abstractmethod
def price_str_as_float(self, price_as_str): def price_str_as_float(self, price_as_str):
assert False assert False
@abstractmethod @abstractmethod
def get_module_label(self, module_id): def get_module_label(self, module_id) -> str:
assert False assert False
@abstractmethod @abstractmethod
def get_xpath_filter(self, filter_id): def get_xpath_filter(self, filter_id) -> str:
assert False assert False
@abstractmethod @abstractmethod
def get_base_price(self, html_root: HtmlElement) -> Price: def get_base_price(self, html_root: HtmlElement) -> Price:
assert False assert False
def _parse_proc_change_options(self, html_root: HtmlElement) -> Module: def _parse_chassis(self, html_root: HtmlElement) -> str:
proc_options = Module('processor-change') module_root_element = self._get_module(html_root, 'Base')
# module_root_element = self._get_module(html_root, 'Processeurs (Passage)') assert module_root_element is not None
module_root_element = self._get_module(html_root, self.get_module_label('cpu_change')) # option_root_elements = module_root_element.xpath(".//div[@class='row']")
for option_root_element in module_root_element.xpath(self.get_xpath_filter('module_to_options')): # assert len(option_root_elements) > 0
label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
if len(label_elements) > 0: # option_root_element = option_root_elements[0]
label = clean_string(label_elements[0].text_content().replace('\n', '')) label_elements = module_root_element.xpath(self.get_xpath_filter('base_module_to_label'))
price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content()) 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) # print(label, price)
num_cpus = 1 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 # Passage à processeur Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
@ -464,23 +474,36 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
if match: if match:
num_cpus = 2 num_cpus = 2
cpu_id = "amd-epyc-%s" % (match['cpu_number'].lower()) 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 assert match, 'unhandled label : %s' % label
# print(match['cpu_class'], match['cpu_number']) # print(match['cpu_class'], match['cpu_number'])
option = Option(Cpu(cpu_id), price / num_cpus) 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)')
module_root_element = self._get_module(html_root, self.get_module_label('cpu_change'))
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', '').strip())
price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content())
option = self._parse_cpu_upgrade(label, price)
# print('_parse_proc_change_options : adding cpu %s (price = %f)' % (cpu_id, price / num_cpus)) # print('_parse_proc_change_options : adding cpu %s (price = %f)' % (cpu_id, price / num_cpus))
proc_options.add_option(option) proc_options.add_option(option)
return proc_options return proc_options
def _parse_proc_options(self, html_root: HtmlElement) -> Module: def _parse_additional_cpu(self, label: str, price: Price) -> Option:
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'))
if module_root_element is not None:
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) # print(label, price)
num_additional_cpus = 1 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) 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)
@ -495,22 +518,32 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
cpu_class = 'platinum' cpu_class = 'platinum'
cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower()) cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
option = Option(Cpu(cpu_id), price / num_additional_cpus) option = Option(Cpu(cpu_id), price / num_additional_cpus)
# print('_parse_proc_options : adding cpu %s (price = %f)' % (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_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 = 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())
option = self._parse_additional_cpu(label, price)
proc_options.add_option(option) proc_options.add_option(option)
else:
assert False
assert len(proc_options.options) > 0 assert len(proc_options.options) > 0
return proc_options return proc_options
def _parse_ram_options(self, html_root: HtmlElement) -> Module: def _parse_ram_option(self, label: str, price: Price) -> Option:
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'))
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) # print(label, price)
# Ajout d'une barette de 128Go 2667 Mhz LRDIMM # 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) 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: if match:
@ -522,8 +555,7 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
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' 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) 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) dimm = Dimm(num_gb=num_gb, sdram_chip=sdram_chip, cas=None, mem_type=mem_type)
option = Option(dimm, price) ram_option = Option(dimm, price)
ram_options.add_option(option)
else: else:
# Optane DC Persistent Memory - 128Go 2666Mhz # 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) 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)
@ -531,15 +563,30 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
# print(match['num_gb'], match['num_mhz']) # print(match['num_gb'], match['num_mhz'])
chip = PmmChip(transfer_rate=int(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 dimm = Dimm(num_gb=int(match['num_gb']), sdram_chip=chip, cas=None, mem_type='pmm') # persistent memory module
option = Option(dimm, price) ram_option = Option(dimm, price)
ram_options.add_option(option)
else: else:
assert False, 'unhandled label : %s' % label 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)')
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())
ram_option = self._parse_ram_option(label, price)
ram_options.add_option(ram_option)
assert len(ram_options.options) > 0 assert len(ram_options.options) > 0
return ram_options return ram_options
@abstractmethod @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 assert False
def _parse_base_config(self, html_root: HtmlElement, configurator: Configurator) -> Config: 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 base_config.num_cpu_per_server = 1
# initialize cpu # 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 # 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) 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: if match:
@ -572,7 +619,7 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
base_config.set_cpu(Cpu(cpu_id)) base_config.set_cpu(Cpu(cpu_id))
# initialize the default ram dimms # 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) # 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 ', '')) 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 assert match, 'unhandled label : %s' % item_label
@ -629,6 +676,80 @@ class DellConfiguratorParser(IHtmlConfiguratorParser):
return base_cpu_price 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): def parse(self, dell_configurator_html_file_path: Path, configurator: Configurator):
html_root = html_parse(dell_configurator_html_file_path).getroot() 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']") # modules_element = body.xpath("//div[@class='col-md-10']")
module_root_element = self._get_module(html_root, 'Base') chassis_id = self._parse_chassis(html_root)
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(), )
configurator.chassis = Option(Chassis(chassis_id), 0.0) configurator.chassis = Option(Chassis(chassis_id), 0.0)
configurator.base_config = self._parse_base_config(html_root, configurator) configurator.base_config = self._parse_base_config(html_root, configurator)
proc_change_module = self._parse_proc_change_options(html_root) proc_change_module = self._parse_proc_change_options(html_root)
configurator.add_module(proc_change_module) 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)) 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 # 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: 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) # 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 # so we fallback to an hardcoded estimated price from wikipedia
base_cpu_price = { base_cpu_price = {
'amd-epyc-7262': 550.0, '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] }[base_cpu.uid]
configurator.modules['processor'].add_option(Option(base_cpu, base_cpu_price)) 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 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): def __init__(self):
super().__init__() super().__init__()
def get_module_label(self, module_id): def get_module_label(self, module_id) -> str:
return { return {
'cpu_change': 'Processeurs (Passage)', 'cpu_change': 'Processeurs (Passage)',
'additional_cpus': 'Processeurs additionnels', 'additional_cpus': 'Processeurs additionnels',
@ -719,7 +834,7 @@ class DellConfiguratorParser2020(DellConfiguratorParser):
'ram_additions': 'Mémoire: Ajout de barettes additionnelles', 'ram_additions': 'Mémoire: Ajout de barettes additionnelles',
}[module_id] }[module_id]
def get_xpath_filter(self, filter_id): def get_xpath_filter(self, filter_id) -> str:
return { return {
'root_to_modules_element': ".//div[@class='col-md-10']", 'root_to_modules_element': ".//div[@class='col-md-10']",
'modules_element_to_modules': ".//div[@class='col-md-12 module']", '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'])) price_as_float = float("%s%s" % (match['sign'], match['numbers']))
return price_as_float 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) module_root_element = self._get_module(html_root, module_label)
assert module_root_element is not None assert module_root_element is not None
for option_root_element in module_root_element.xpath(".//div[@class='row']"): for option_root_element in module_root_element.xpath(".//div[@class='row']"):
@ -752,7 +867,7 @@ class DellConfiguratorParser2020(DellConfiguratorParser):
return label return label
assert False, 'failed to find the default item of module %s' % module_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 base_price = None
price_preview_element = html_root.xpath(".//div[@class='price-preview']")[0] price_preview_element = html_root.xpath(".//div[@class='price-preview']")[0]
assert price_preview_element is not None assert price_preview_element is not None
@ -783,7 +898,7 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
def get_module_label(self, module_id): def get_module_label(self, module_id) -> str:
return { return {
'cpu_change': 'Processeurs (Passage)', 'cpu_change': 'Processeurs (Passage)',
'additional_cpus': 'Processeurs additionnels', 'additional_cpus': 'Processeurs additionnels',
@ -791,7 +906,7 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
'ram_additions': 'Mémoire: Ajout de barettes additionnelles', 'ram_additions': 'Mémoire: Ajout de barettes additionnelles',
}[module_id] }[module_id]
def get_xpath_filter(self, filter_id): def get_xpath_filter(self, filter_id) -> str:
return { return {
'root_to_modules_element': ".//div[@class='modules']", 'root_to_modules_element': ".//div[@class='modules']",
'modules_element_to_modules': ".//div[@class='product-module-configuration']", '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'])) price_as_float = float("%s%s" % (match['sign'], match['numbers']))
return price_as_float 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) module_root_element = self._get_module(html_root, module_label)
assert module_root_element is not None assert module_root_element is not None
@ -843,7 +958,7 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
return label return label
assert False, 'failed to find the default item of module %s' % module_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 base_price = None
price_preview_element = html_root.xpath(".//div[@class='product-info']")[0] price_preview_element = html_root.xpath(".//div[@class='product-info']")[0]
assert price_preview_element is not None assert price_preview_element is not None
@ -866,28 +981,32 @@ class DellConfiguratorParser2021(DellConfiguratorParser):
return base_price return base_price
class DellV2ConfiguratorParser(DellConfiguratorParser): class DellConfiguratorParser2025(DellConfiguratorParser):
def __init__(self): 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 { return {
'cpu_change': 'Processeurs (Passage)', 'cpu_change': 'Processeur',
'additional_cpus': 'Processeurs additionnels', 'additional_cpus': 'Processeur supplémentaires',
'ram_change': 'Mémoires (Passage)', 'ram_change': 'Capacité de mémoire',
'ram_additions': 'Mémoire: Ajout de barettes additionnelles', 'ram_additions': '', # no ram additions module in 10/2025
}[module_id] }[module_id]
def get_xpath_filter(self, filter_id): def get_xpath_filter(self, filter_id) -> str:
return { return {
'root_to_modules_element': ".//div[@id='technicalspecification_section']", 'root_to_modules_element': ".//div[@id='technicalspecification_section']",
'modules_element_to_modules': ".//div[@class='product-module-configuration']", 'modules_element_to_modules': ".//div[@cf-module-rank='0']", # cf-module-rank="0"
'module_to_blue_title': ".//header", 'module_to_blue_title': ".//span[@class='dds__subtitle-2']",
'module_to_grey_title': ".//div[@class='col-md-4 module-title color-808080']", 'module_to_grey_title': ".//span[@class='dds__subtitle-2']", # no grey titles in v2 ?
'module_to_options': ".//div[@class='product-options-configuration-line']", 'module_to_options': ".//div[@class='cf-input-wrap ']", # <div class="cf-input-wrap ">
'option_to_label': ".//div[@class='option-info']", 'option_to_label': ".//div[@class='dds__option-title1']", # <div class="dds__option-title1" cf-option-modcount="0">
'option_to_price': ".//div[@class='option-price']", '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']", 'base_module_to_label': ".//div[@class='product-options-configuration-block option-selected']",
}[filter_id] }[filter_id]
@ -900,54 +1019,227 @@ class DellV2ConfiguratorParser(DellConfiguratorParser):
price_as_float = float("%s%s" % (match['sign'], match['numbers'])) price_as_float = float("%s%s" % (match['sign'], match['numbers']))
return price_as_float 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) module_root_element = self._get_module(html_root, module_label)
assert module_root_element is not None assert module_root_element is not None
if module_label == self.get_module_label('ram_change'): selected_option_root_elements = self._get_module_selected_items(module_root_element)
# <div assert len(selected_option_root_elements) == 1, 'we expect 1 selected items for module %s, not %d' % (module_label, len(selected_option_root_elements))
# class="product-options-configuration-block option-selected"> selected_option_root_element = selected_option_root_elements[0]
# <header>Mémoire 16 Go DDR4 à 3200MHz (1x16Go)<div label = self._get_item_label(selected_option_root_element)
# class="option-selector"><i logging.debug('default item for module %s : %s' % (module_label, label))
# class="fas fa-check "></i></div> price = self._get_item_price(selected_option_root_element)
# </header> assert price == 0.0, 'unexpected price for default item (%d €)' % price
# <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']"
else:
selected_option_filter = ".//div[@class='product-options-configuration-line option-selected']"
label_filter = ".//div[@class='option-info']"
price_filter = ".//div[@class='option-price']"
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 return label
assert False, 'failed to find the default item of module %s' % module_label
def get_base_price(self, html_root): 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:
# 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
return base_config
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 base_price = None
price_preview_element = html_root.xpath(".//div[@class='product-info']")[0] price_value_element = html_root.xpath(".//div[@id='cf-hero-starting-price-with-currency']")[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 assert price_value_element is not None
base_price = self.price_str_as_float(price_value_element.text_content().replace(' HT', '')) base_price = self.price_str_as_float(price_value_element.text_content().replace(' HT', ''))
assert base_price is not None assert base_price is not None
@ -1036,5 +1328,3 @@ class DellMatinfoCsvConfigurator(Configurator):
config.cpu_slots_mem = copy.deepcopy(self.base_config.cpu_slots_mem) config.cpu_slots_mem = copy.deepcopy(self.base_config.cpu_slots_mem)
return config return config

View File

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