Fix IPC-356 tests

This commit is contained in:
jaseg 2022-01-29 21:05:31 +01:00
parent 1304847afe
commit d5bbfade80
4 changed files with 230 additions and 49 deletions

View file

@ -80,12 +80,16 @@ class FileSettings:
def format_ipc_number(self, value, digits, key='', sign=False):
if value is None:
return ' ' * (digits + 1 + len(key))
return ' ' * (digits + int(bool(sign)) + len(key))
if isinstance(value, Enum):
value = value.value
num = format(round(value), f'{"+" if sign else ""}0{digits+int(bool(sign))}d')
return key + format(round(value), f'{"+" if sign else ""}0{digits+1}d')
if len(num) > digits + int(bool(sign)):
raise ValueError('Error: Number {num} to wide for IPC-356 field of width {digits}')
return key + num
def format_ipc_length(self, value, digits, key='', unit=None, sign=False):
if value is not None:

View file

@ -39,21 +39,28 @@ class Netlist(CamFile):
self.adjacency = adjacency or {}
self.params = params or {}
def merge(self, other, our_net_prefix=None, their_net_prefix=None):
def merge(self, other, our_prefix=None, their_prefix=None):
''' Merge other netlist into this netlist. The respective net names are prefixed with the given prefixes
(default: None). Garbles other. '''
if not isinstance(other, Netlist):
raise TypeError(f'Can only merge Netlist with other Netlist, not {type(other)}')
self.prefix_nets(our_net_prefix)
other.prefix_nets(our_net_prefix)
self.prefix_nets(our_prefix)
other.prefix_nets(our_prefix)
self.test_records.extend(other.test_records)
self.conductors.extend(other.conductors)
self.outlines.extend(other.outlines)
self.comments.extend(other.comments)
self.adjacency.update(other.adjacency)
self.params.extend(other.params)
self.params.update(other.params)
self.params['JOB'] = 'Gerbonara IPC-356 merge'
self.params['TITLE'] = 'Gerbonara IPC-356 merge'
for key in 'CODE', 'NUM', 'REV', 'VER':
if key in self.params:
del self.params[key]
def prefix_nets(self, prefix):
if not prefix:
@ -68,10 +75,18 @@ class Netlist(CamFile):
conductor.net_name = prefix + conductor.net_name
new_adjacency = {}
for key in adjacency:
new_adjacency[prefix + key] = [ prefix + name for name in adjacency[key] ]
for key in self.adjacency:
new_adjacency[prefix + key] = [ prefix + name for name in self.adjacency[key] ]
self.adjacency = new_adjacency
def offset(self, dx=0, dy=0, unit=MM):
# FIXME
pass
def rotate(self, angle:'radian', center=(0,0), unit=MM):
# FIXME
pass
@property
def objects(self):
yield from self.test_records
@ -117,18 +132,18 @@ class Netlist(CamFile):
yield f'P {name} {value!s}'
net_name_map = {
f'NNAME{i}': name for i, name in enumerate(
name: f'NNAME{i}' for i, name in enumerate(
name for name in self.net_names() if len(name) > 14
) }
yield 'C'
yield 'C Net name mapping:'
yield 'C'
for name, value in net_name_map.items():
yield f'P {name} {value!s}'
for name, alias in net_name_map.items():
yield f'P {alias} {name}'
yield 'C'
yield 'C Test records:'
yield 'C Test records:'
yield 'C'
for record in self.test_records:
@ -136,21 +151,21 @@ class Netlist(CamFile):
if self.conductors:
yield 'C'
yield 'C Conductors:'
yield 'C Conductors:'
yield 'C'
for conductor in self.conductors:
yield from conductor.format(settings, net_name_map)
if self.outlines:
yield 'C'
yield 'C Outlines:'
yield 'C Outlines:'
yield 'C'
for outline in self.outlines:
yield from outline.format(settings)
if self.adjacency:
yield 'C'
yield 'C Adjacency data:'
yield 'C Adjacency data:'
yield 'C'
done = set()
for net, others in self.adjacency.items():
@ -217,6 +232,7 @@ class NetlistParser(object):
self.adjacency = {}
self.outlines = []
self.eof = False
self.generator = None
def warn(self, msg, kls=SyntaxWarning):
warnings.warn(f'{self.filename}:{self.start_line}: {msg}', kls)
@ -255,16 +271,34 @@ class NetlistParser(object):
return
if self.eof:
warnings.warn('Data following IPC-356 End Of File marker')
self.warn('Data following IPC-356 End Of File marker')
if line[0] == 'C':
line = line[2:].strip()
# +-- sic!
# v
if 'Ouptut' in line and 'Allegro' in line:
self.generator = 'allegro'
elif 'Ouptut' not in line and 'Allegro' in line:
self.warn('This seems to be a file generated by a newer allegro version. Please raise an issue on our '
'issue tracker with your Allegro version and if possible please provide an example file '
'so we can improve Gerbonara!')
elif 'EAGLE' in line and 'CadSoft' in line:
self.generator = 'eagle'
if line.strip().startswith('NNAME'):
name, *value = line.strip().split()
value = ' '.join(value)
warnings.warn('File contains non-standard Allegro-style net name alias definitions in comments.')
self.net_names[name] = value
self.warn('File contains non-standard Allegro-style net name alias definitions in comments.')
if self.generator == 'allegro':
# it's amazing how allegro always seems to have found a way to do the same thing everyone else is
# doing just in a different, slightly more messed up, completely incompatible way.
self.net_names[name] = value[5:] # strip NNAME because Allegro
else:
self.net_names[name] = value
else:
self.comments.append(line)
@ -294,7 +328,11 @@ class NetlistParser(object):
raise SyntaxError(f'Unsupported IPC-356 netlist unit specification "{line}"')
elif name.startswith('NNAME'):
self.net_names[name] = value
if self.generator == 'allegro':
self.net_names[name] = value[5:]
else:
self.net_names[name] = value
else:
self.params[name] = value
@ -319,10 +357,10 @@ class NetlistParser(object):
elif line[0:3] == '389':
self.assert_unit()
self.outlines.append(Outline.parse(line, self.settings))
self.outlines.extend(Outline.parse(line, self.settings))
else:
warnings.warn(f'Unknown IPC-356 record type {line[0:3]}')
self.warn(f'Unknown IPC-356 record type {line[0:3]}')
class PadType(Enum):
@ -345,6 +383,7 @@ class TestRecord:
__test__ = False # tell pytest to ignore this class
pad_type : PadType = None
net_name : str = None
is_connected : bool = True # None, True or False.
ref_des : str = None # part reference designator, e.g. "C1" or "U69"
is_via : bool = False
pin_num : int = None
@ -356,10 +395,11 @@ class TestRecord:
y : float = None
w : float = None
h : float = None
rotation : float = None
rotation : float = 0
solder_mask : SoldermaskInfo = None
lefover : str = None
unit: KW_ONLY = None
_ : KW_ONLY
unit : LengthUnit = None
def __str__(self):
x = self.unit.format(self.x)
@ -373,9 +413,23 @@ class TestRecord:
obj.unit = settings.unit
obj.pad_type = PadType(int(line[1]))
net_name = line[3:17].strip() or None
obj.net_name = net_name_map.get(net_name, net_name)
obj.ref_des = line[20:26].strip() or None
if net_name == 'N/C':
obj.net_name = None
obj.is_connected = False
else:
obj.net_name = net_name_map.get(net_name, net_name)
obj.is_connected = True
ref_des = line[20:26].strip() or None
if ref_des == 'VIA':
obj.is_via = True
obj.ref_des = None
else:
obj.is_via = False
obj.ref_des = ref_des
obj.pin = line[27:31].strip() or None
if line[31] == 'M':
@ -395,21 +449,26 @@ class TestRecord:
if line[62] == 'Y':
obj.h = settings.parse_ipc_length(line[63:67])
if line[67] == 'R':
obj.h = math.radians(int(line[68:71]))
obj.rotation = math.radians(int(line[68:71]))
else:
obj.rotation = 0
if line[72] == 'S':
obj.solder_mask = SoldermaskInfo(int(line[73]))
obj.leftover = line[74:].strip() or None
return obj
def format(self, settings, net_name_map):
def format(self, settings, net_name_map={}):
x = settings.unit(self.x, self.unit)
y = settings.unit(self.y, self.unit)
w = settings.unit(self.w, self.unit)
h = settings.unit(self.h, self.unit)
# TODO: raise warning if any string is too long
ref_des = 'VIA' if self.is_via else (self.ref_des or '')
net_name = net_name_map.get(self.net_name, self.net_name)
if self.is_connected:
net_name = net_name_map.get(self.net_name, self.net_name)
else:
net_name = 'N/C'
yield ''.join((
'3',
@ -427,7 +486,7 @@ class TestRecord:
settings.format_ipc_length(self.x, 6, 'X', self.unit, sign=True),
settings.format_ipc_length(self.y, 6, 'Y', self.unit, sign=True),
settings.format_ipc_length(self.w, 4, 'X', self.unit),
settings.format_ipc_length(self.y, 4, 'Y', self.unit),
settings.format_ipc_length(self.h, 4, 'Y', self.unit),
settings.format_ipc_number(math.degrees(self.rotation) if self.rotation is not None else None, 3, 'R'),
' ',
settings.format_ipc_number(self.solder_mask, 1, 'S'),
@ -445,7 +504,7 @@ def parse_coord_chain(line, settings):
for segment in line.split('*'):
coords = []
for coord in segment.strip().split():
if not (m := re.match(r'(X[+-]?[0-9]+)?(Y[+-]?[0-9]+)?', coord)):
if not (match := re.match(r'(X[+-]?[0-9]+)?(Y[+-]?[0-9]+)?', coord)):
raise SyntaxError(f'Invalid IPC-356 coordinate {coord}')
x = settings.parse_ipc_length(match[1], x)
@ -457,17 +516,17 @@ def parse_coord_chain(line, settings):
coords.append((x, y))
yield coords
def format_coord_chain(line, settings, coords, cont):
def format_coord_chain(line, settings, coords, cont, unit):
for x, y in coords:
coord = settings.format_ipc_length(x, 6, 'X', unit=self.unit, sign=True)
coord += settings.format_ipc_length(y, 6, 'Y', unit=self.unit, sign=True)
coord = settings.format_ipc_length(x, 6, 'X', unit=unit, sign=True)
coord += settings.format_ipc_length(y, 6, 'Y', unit=unit, sign=True)
if len(line) + len(coord) <= 80:
line += coord
line = (line + coord + ' ')[:80]
else:
yield line
line = f'{cont} {coord}'
line = f'{cont} {coord} '
yield line
@ -475,17 +534,20 @@ def format_coord_chain(line, settings, coords, cont):
class Outline:
outline_type : OutlineType
outline : [(float,)]
unit : KW_ONLY
_ : KW_ONLY
unit : LengthUnit = None
@classmethod
def parse(kls, line, settings):
print('parsing outline', line)
outline_type = OutlineType[line[3:17].strip()]
for outline in parse_coord_chain(line[22:], settings):
print(' ->', outline)
yield kls(outline_type, outline, unit=settings.unit)
def format(self, settings):
line = f'389{self.outline_type.name:<14} '
yield from format_coord_chain(line, settings, self.outline, '089')
yield from format_coord_chain(line, settings, self.outline, '089', self.unit)
def __str__(self):
return f'<IPC-356 {self.outline_type.name} outline with {len(self.outline)} points>'
@ -497,7 +559,8 @@ class Conductor:
layer : int
aperture : (float,)
coords : [(float,)]
unit : KW_ONLY
_ : KW_ONLY
unit : LengthUnit = None
@classmethod
def parse(kls, line, settings, net_name_map={}):
@ -520,7 +583,7 @@ class Conductor:
net_name = net_name_map.get(self.net_name, self.net_name)
net_name = f'{net_name:<14}[:14]'
line = f'378{net_name} L{self.layer:02d} '
yield from format_coord_chain(line, settings, self.outline, '078')
yield from format_coord_chain(line, settings, self.outline, '078', self.unit)
def __str__(self):
return f'<IPC-356 conductor {self.net_name} with {len(self.coords)} points>'

View file

@ -32,10 +32,18 @@ def test_idempotence(reference, tmpfile):
tmp_1 = tmpfile('First generation output', '.ipc')
tmp_2 = tmpfile('Second generation output', '.ipc')
Netlist.open(reference).save(tmp_1)
Netlist.open(tmp_1).save(tmp_2)
a = Netlist.open(reference)
a.save(tmp_1)
b = Netlist.open(tmp_1)
b.save(tmp_2)
assert tmp_1.read_text() == tmp_2.read_text()
print(f'{a.outlines=}')
print(f'{b.outlines=}')
res = tmp_1.read_text() == tmp_2.read_text()
# Confuse pytest so it doesn't try to print out a diff. pytest's potato diff algorithm is wayyyy to slow and would
# hang for several minutes.
assert res
@filter_syntax_warnings
@pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True)
@ -50,12 +58,118 @@ def test_bells_and_whistles(reference):
netlist.conductors_by_layer(0)
@filter_syntax_warnings
@pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True)
@pytest.mark.parametrize('other', REFERENCE_FILES, indirect=True)
def test_merge(reference, other):
other = reference_path(other)
a = Netlist.open(reference)
b = Netlist.open(other)
@pytest.mark.parametrize('a', REFERENCE_FILES)
@pytest.mark.parametrize('b', REFERENCE_FILES)
def test_merge(a, b):
a, b = reference_path(a), reference_path(b)
print('File A:', a)
print('File B:', a)
a = Netlist.open(a)
b = Netlist.open(b)
a.merge(b, our_prefix='A')
# FIXME asserts
def test_record_semantics():
settings = FileSettings()
r = TestRecord.parse('327m0002 CPU1 -AY30 A01X+020114Y+014930X0120Y R090 S1 ', settings)
assert r.pad_type == PadType.SMD_PAD
assert r.net_name == 'm0002'
assert r.is_connected
assert r.ref_des == 'CPU1'
assert not r.is_via
assert r.pin == 'AY30'
assert not r.is_middle
assert r.hole_dia is None
assert r.is_plated is None
assert r.access_layer == 1
assert math.isclose(r.x, 20114/1000) and math.isclose(r.y, 14930/1000)
assert math.isclose(r.w, 120/1000) and r.h is None
assert math.isclose(r.rotation, math.pi/2)
assert r.solder_mask == SoldermaskInfo.PRIMARY
assert r.unit == settings.unit
r = TestRecord.parse('327m0002 U15 -D3 A01X+011545Y+003447X0090Y R090 S1 ', settings)
assert r.pad_type == PadType.SMD_PAD
assert r.net_name == 'm0002'
assert r.is_connected
assert r.ref_des == 'U15'
assert r.pin == 'D3'
assert not r.is_middle
assert r.hole_dia is None
assert r.is_plated is None
assert r.access_layer == 1
assert math.isclose(r.w, 90/1000) and r.h is None
r = TestRecord.parse('327VSUMPG C39 -2 M A01X+013050Y+020050X0350Y0320R270 S1 ', settings)
assert r.pad_type == PadType.SMD_PAD
assert r.net_name == 'VSUMPG'
assert r.is_connected
assert r.ref_des == 'C39'
assert r.pin == '2'
assert r.is_middle
assert r.hole_dia is None
assert r.is_plated is None
assert r.access_layer == 1
assert math.isclose(r.w, 350/1000) and math.isclose(r.h, 320/1000)
assert math.isclose(r.rotation, math.pi*3/2)
r = TestRecord.parse('327N/C CPU1 -AD2 A01X+023191Y+020393X0110Y R090 S1 ', settings)
assert r.pad_type == PadType.SMD_PAD
assert r.net_name == None
assert not r.is_connected
assert r.ref_des == 'CPU1'
assert r.pin == 'AD2'
assert r.hole_dia is None
assert r.is_plated is None
assert r.access_layer == 1
assert math.isclose(r.w, 110/1000) and r.h is None
r = TestRecord.parse('317m0002 VIA - MD0080PA00X+011900Y+004000X0160Y S3 ', settings)
assert r.pad_type == PadType.THROUGH_HOLE
assert r.net_name == 'm0002'
assert r.is_connected
assert r.ref_des is None
assert r.is_via
assert r.pin is None
assert r.is_middle
assert r.hole_dia == 80/1000
assert r.is_plated
assert r.access_layer == 0
assert math.isclose(r.w, 160/1000) and r.h is None
assert r.rotation == 0
assert r.solder_mask == SoldermaskInfo.BOTH
r = TestRecord.parse('317GND VIA - MD0080PA00X+023800Y+010100X0160Y S0 ', settings)
assert r.pad_type == PadType.THROUGH_HOLE
assert r.net_name == 'GND'
assert r.is_connected
assert r.is_via
assert r.pin is None
assert r.hole_dia == 80/1000
assert r.is_plated
assert r.access_layer == 0
assert r.solder_mask == SoldermaskInfo.NONE
def test_record_idempotence():
records = [
'327m0002 CPU1 -AY30 A01X+020114Y+014930X0120Y R090 S1 ',
'327m0002 U15 -D3 A01X+011545Y+003447X0090Y R090 S1 ',
'327VSUMPG C39 -2 M A01X+013050Y+020050X0350Y0320R270 S1 ',
'317m0002 VIA - MD0080PA00X+011900Y+004000X0160Y S3 ',
'317GND VIA - MD0080PA00X+023800Y+010100X0160Y S0 ',]
for unit in MM, Inch:
settings = FileSettings(unit=unit)
for record in records:
ra = TestRecord.parse(record, settings)
a = list(ra.format(settings))[0]
rb = TestRecord.parse(a, settings)
b = list(rb.format(settings))[0]
print('ra', ra)
print('rb', rb)
print('0', record)
print('a', a)
print('b', b)
assert a == b

View file

@ -76,7 +76,7 @@ class LengthUnit:
return unit.convert_from(self, value)
def format(self, value):
return f'{value:.3f}{self.shorthand}'
return f'{value:.3f}{self.shorthand}' if value is not None else ''
def __call__(self, value, unit):
return self.convert_from(unit, value)