mirror of
https://gitlab.com/kicad/code/kicad.git
synced 2025-09-14 10:13:19 +02:00
400 lines
12 KiB
Python
400 lines
12 KiB
Python
|
#
|
||
|
# This program source code file is part of KiCad, a free EDA CAD application.
|
||
|
#
|
||
|
# Copyright (C) 2025 KiCad Developers
|
||
|
#
|
||
|
# This program is free software; you can redistribute it and/or modify
|
||
|
# it under the terms of the GNU General Public License as published by
|
||
|
# the Free Software Foundation; either version 2 of the License, or
|
||
|
# (at your option) any later version.
|
||
|
#
|
||
|
# This program is distributed in the hope that it will be useful,
|
||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
|
# GNU General Public License for more details.
|
||
|
#
|
||
|
# You should have received a copy of the GNU General Public License
|
||
|
# along with this program; if not, write to the Free Software
|
||
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
||
|
# MA 02110-1301, USA.
|
||
|
#
|
||
|
import logging
|
||
|
import re
|
||
|
from pathlib import Path
|
||
|
|
||
|
logger = logging.getLogger("cli_odbpp")
|
||
|
|
||
|
def is_date(string):
|
||
|
pattern = r"^# \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$"
|
||
|
return bool(re.match(pattern, string))
|
||
|
|
||
|
|
||
|
def contains_version_header(line):
|
||
|
return line.startswith("HDR KiCad EDA")
|
||
|
|
||
|
|
||
|
def is_float(value):
|
||
|
try:
|
||
|
float_value = float(value)
|
||
|
return not float_value.is_integer()
|
||
|
except ValueError:
|
||
|
return False
|
||
|
|
||
|
|
||
|
def compare_lines(line1, line2, allowed_deviation):
|
||
|
"""Compare two lines word by word.
|
||
|
Ignore lines with a date.
|
||
|
Ignore lines with a KiCad version header.
|
||
|
Allow certain deviation for floating point values.
|
||
|
"""
|
||
|
|
||
|
if is_date(line1) and is_date(line2):
|
||
|
logger.debug(f"ignoring line '{line1}'")
|
||
|
return True
|
||
|
|
||
|
if contains_version_header( line1 ) and contains_version_header( line2 ):
|
||
|
logger.debug( f"ignoring line '{line1}'" )
|
||
|
return True
|
||
|
|
||
|
words1 = line1.split()
|
||
|
words2 = line2.split()
|
||
|
|
||
|
if len(words1) != len(words2):
|
||
|
return False
|
||
|
|
||
|
for word1, word2 in zip(words1, words2):
|
||
|
|
||
|
if is_float(word1) and is_float(word2):
|
||
|
|
||
|
difference = abs( float( word1 ) - float( word2 ))
|
||
|
|
||
|
if difference > allowed_deviation:
|
||
|
return False
|
||
|
|
||
|
elif word1 != word2:
|
||
|
return False
|
||
|
|
||
|
return True
|
||
|
|
||
|
|
||
|
def compare_files(file1_path, file2_path, allowed_deviation, allowed_swaps = 2):
|
||
|
""" Compare the content of two files.
|
||
|
Dates and KiCad version headers are ignored.
|
||
|
floating values can have a certain deviation.
|
||
|
lines can be swapped in the features section, but not the header section.
|
||
|
"""
|
||
|
logger.debug( file1_path )
|
||
|
unmatched_lines_a = []
|
||
|
unmatched_lines_b = []
|
||
|
|
||
|
in_header = True
|
||
|
|
||
|
content_begin = ["#Feature attribute text strings",
|
||
|
"#Layer features"]
|
||
|
|
||
|
with open(file1_path, 'r') as file1, open(file2_path, 'r') as file2:
|
||
|
while True:
|
||
|
line1 = file1.readline()
|
||
|
line2 = file2.readline()
|
||
|
|
||
|
if not line1 and not line2:
|
||
|
break
|
||
|
|
||
|
line1 = line1.strip() if line1 else None
|
||
|
line2 = line2.strip() if line2 else None
|
||
|
|
||
|
if line1 is None:
|
||
|
logger.error(f"unexpected EOF in file {file1}")
|
||
|
return False
|
||
|
|
||
|
if line2 is None:
|
||
|
logger.error(f"unexpected EOF in file {file2}")
|
||
|
return False
|
||
|
|
||
|
if not compare_lines(line1, line2, allowed_deviation):
|
||
|
if in_header:
|
||
|
logger.error(f"{line1} != {line2}")
|
||
|
return False
|
||
|
# reordering of lines is allowed in content section.
|
||
|
unmatched_lines_a.append( line1 )
|
||
|
unmatched_lines_b.append( line2 )
|
||
|
|
||
|
if line1 in content_begin:
|
||
|
in_header = False
|
||
|
|
||
|
if len(unmatched_lines_a) == 0:
|
||
|
return True
|
||
|
|
||
|
num_swaps = 0
|
||
|
|
||
|
for line1, line2 in zip(sorted(unmatched_lines_a), sorted(unmatched_lines_b)):
|
||
|
if not compare_lines(line1, line2, allowed_deviation):
|
||
|
logger.error(f"{line1} != {line2}")
|
||
|
return False
|
||
|
|
||
|
num_swaps += 1
|
||
|
logger.warning( f"{line1} was swapped." )
|
||
|
|
||
|
return (num_swaps / 2) < allowed_swaps
|
||
|
|
||
|
|
||
|
class ODBPP_Matrix:
|
||
|
def __init__(self):
|
||
|
self.step = {}
|
||
|
self.layers = {}
|
||
|
|
||
|
|
||
|
def clear(self):
|
||
|
self.step = {}
|
||
|
self.layers = {}
|
||
|
|
||
|
|
||
|
def add_layer(self, data):
|
||
|
"""Adds a layer to the matrix.
|
||
|
"""
|
||
|
|
||
|
layer_name = data["NAME"]
|
||
|
|
||
|
if self.layers is not None and layer_name in self.layers:
|
||
|
logger.error(f"Duplicate layer name {layer_name}")
|
||
|
return False
|
||
|
|
||
|
self.layers[layer_name] = data
|
||
|
|
||
|
return True
|
||
|
|
||
|
|
||
|
def is_equal(self, other):
|
||
|
"""Checks this matrix for equality with a different matrix file.
|
||
|
The order of layers does not matter.
|
||
|
"""
|
||
|
|
||
|
if self.step != other.step:
|
||
|
return False
|
||
|
|
||
|
for layer_ours,layer_theirs in zip(sorted(self.layers), sorted(other.layers)):
|
||
|
|
||
|
if layer_ours != layer_theirs:
|
||
|
return False
|
||
|
|
||
|
return True
|
||
|
|
||
|
|
||
|
class ODBPP_Files:
|
||
|
def __init__(self, basepath):
|
||
|
self.reset(basepath)
|
||
|
|
||
|
|
||
|
def reset(self, basepath):
|
||
|
self.basepath = Path(basepath)
|
||
|
|
||
|
self.features = []
|
||
|
self.components = []
|
||
|
self.profile = self.basepath / "steps" / "pcb" / "profile"
|
||
|
self.stephdr = self.basepath / "steps" / "pcb" / "stephdr"
|
||
|
self.matrix = self.basepath / "matrix" / "matrix"
|
||
|
self.eda_data = self.basepath / "steps" / "pcb" / "eda" / "data"
|
||
|
|
||
|
self.layers_dir = self.basepath / "steps" / "pcb" / "layers"
|
||
|
|
||
|
|
||
|
def all(self):
|
||
|
"""Get all files as one list.
|
||
|
"""
|
||
|
return self.features + self.components + [self.profile, self.stephdr, self.matrix, self.eda_data]
|
||
|
|
||
|
|
||
|
def add_feature(self, layer_name):
|
||
|
"""Add a features layer files.
|
||
|
Some lines can be swapped in the features section, since this would generally not alter outputs.
|
||
|
"""
|
||
|
self.features.append(self.layers_dir / layer_name.lower() / "features")
|
||
|
|
||
|
|
||
|
def add_component(self, layer_name):
|
||
|
"""Add a component layer file.
|
||
|
They are distinct from feature files, since they should not allow any swapped lines.
|
||
|
"""
|
||
|
self.features.append(self.layers_dir / layer_name.lower() / "components")
|
||
|
|
||
|
|
||
|
def is_equal(self, other, allowed_deviation = 0.01):
|
||
|
"""Checks whether the set of files is equal to another set of files.
|
||
|
Checks for the correct number of files, and the contents of feature files.
|
||
|
A certain deviation is allowed, some files can have a certain number of swapped lines in the features section
|
||
|
to still be counted as equal.
|
||
|
"""
|
||
|
|
||
|
if len(self.features) != len(other.features):
|
||
|
return False
|
||
|
|
||
|
if len(self.components) != len(other.components):
|
||
|
return False
|
||
|
|
||
|
if not compare_files( self.profile, other.profile, allowed_deviation, 4 ):
|
||
|
return False
|
||
|
|
||
|
if not compare_files( self.stephdr, other.stephdr, allowed_deviation, 0 ):
|
||
|
return False
|
||
|
|
||
|
if not compare_files( self.eda_data, other.eda_data, allowed_deviation, 0 ):
|
||
|
return False
|
||
|
|
||
|
for ours, theirs in zip( self.components, other.components ):
|
||
|
if not compare_files( ours, theirs, allowed_deviation, 0 ):
|
||
|
return False
|
||
|
|
||
|
for ours, theirs in zip( self.features, other.features ):
|
||
|
if not compare_files( ours, theirs, allowed_deviation, 4 ):
|
||
|
return False
|
||
|
|
||
|
return True
|
||
|
|
||
|
|
||
|
|
||
|
class ODBPP:
|
||
|
def __init__(self, basepath):
|
||
|
self.basepath = Path(basepath)
|
||
|
self.matrix = ODBPP_Matrix()
|
||
|
self.files = ODBPP_Files( basepath )
|
||
|
|
||
|
|
||
|
def read_matrix(self):
|
||
|
"""Read the matrix file.
|
||
|
This function does rudimentary checks on the matrix file, like double layers etc.
|
||
|
"""
|
||
|
|
||
|
if not self.files.matrix.exists():
|
||
|
logger.error(f"Matrix file does not exist: {self.files.matrix}")
|
||
|
return False
|
||
|
|
||
|
self.matrix.clear()
|
||
|
|
||
|
current_type = None
|
||
|
current_fields = {}
|
||
|
step_at_line = -1
|
||
|
|
||
|
with open(self.files.matrix, "r") as f:
|
||
|
current_line = 0
|
||
|
|
||
|
for line in f:
|
||
|
current_line = current_line + 1
|
||
|
|
||
|
line = line.strip()
|
||
|
|
||
|
if current_type is None:
|
||
|
|
||
|
if line.startswith("STEP {"):
|
||
|
if step_at_line > 0:
|
||
|
logger.error(f"Unexpected STEP section at {current_line}. First STEP section at {step_at_line}.")
|
||
|
return False
|
||
|
|
||
|
current_type = "STEP"
|
||
|
|
||
|
elif line.startswith("LAYER {"):
|
||
|
current_type = "LAYER"
|
||
|
|
||
|
elif line.startswith("}"):
|
||
|
if current_type == "STEP":
|
||
|
self.matrix.step = current_fields
|
||
|
|
||
|
else:
|
||
|
if "NAME" not in current_fields:
|
||
|
logger.error(f"No Name defined for layer in matrix on line {current_line}")
|
||
|
return False
|
||
|
|
||
|
if not self.matrix.add_layer( current_fields ):
|
||
|
logger.error(f"Duplicate layer name found on line {current_line}")
|
||
|
return False
|
||
|
|
||
|
current_type = None
|
||
|
current_fields = {}
|
||
|
|
||
|
else:
|
||
|
key, value = map(str.strip, line.split("=", 1))
|
||
|
current_fields[key] = value
|
||
|
|
||
|
if current_type is not None:
|
||
|
logger.error("Unexpected EOF.")
|
||
|
return False
|
||
|
|
||
|
|
||
|
def build_file_list(self):
|
||
|
"""Build the list of files included in the ODB++ export.
|
||
|
"""
|
||
|
|
||
|
self.files.reset(self.basepath)
|
||
|
|
||
|
if self.matrix.step is None or self.matrix.layers is None:
|
||
|
if not self.files.matrix.exists():
|
||
|
logger.error(f"Matrix file does not exist: {self.matrix_file}")
|
||
|
return False
|
||
|
|
||
|
self.read_matrix()
|
||
|
|
||
|
for layer_name, layer_data in self.matrix.layers.items():
|
||
|
self.files.add_feature( layer_name )
|
||
|
|
||
|
if layer_data["TYPE"] == "COMPONENT":
|
||
|
self.files.add_component( layer_name )
|
||
|
|
||
|
|
||
|
def check_files_exist(self):
|
||
|
"""Checks if all files in the ODB++ directory structure exist.
|
||
|
"""
|
||
|
missing_files = []
|
||
|
|
||
|
for file_path in self.files.all():
|
||
|
if not file_path.exists():
|
||
|
missing_files.append(file_path)
|
||
|
|
||
|
if missing_files:
|
||
|
logger.warning("The following files are missing: %s", ", ".join(str(f) for f in missing_files))
|
||
|
return False
|
||
|
|
||
|
logger.info("All required files exist.")
|
||
|
return True
|
||
|
|
||
|
|
||
|
def is_equal(self, other):
|
||
|
"""Checks if this ODB++ export is equal to a differen ODB++ export.
|
||
|
"""
|
||
|
if not self.matrix.is_equal( other.matrix ):
|
||
|
return False
|
||
|
|
||
|
if not self.files.is_equal( other.files ):
|
||
|
return False
|
||
|
|
||
|
return True
|
||
|
|
||
|
|
||
|
def compare_exports(location_under_test, reference_location):
|
||
|
"""Compare an ODB++ export to a reference ODB++ export.
|
||
|
|
||
|
This does not validate the reference ODB++ export.
|
||
|
"""
|
||
|
|
||
|
reference = ODBPP( reference_location )
|
||
|
|
||
|
reference.read_matrix()
|
||
|
|
||
|
reference.build_file_list()
|
||
|
|
||
|
reference.check_files_exist()
|
||
|
|
||
|
|
||
|
lut = ODBPP( location_under_test )
|
||
|
|
||
|
if not lut.read_matrix():
|
||
|
return False
|
||
|
|
||
|
if not lut.build_file_list():
|
||
|
return False
|
||
|
|
||
|
if not lut.check_files_exist():
|
||
|
return False
|
||
|
|
||
|
return reference.is_equal( lut )
|
||
|
|
||
|
|