Added excellon format detection

This commit is contained in:
Hamilton Kibbe 2014-10-10 23:07:51 -04:00
parent 76c03a55c9
commit ae3bbff8b0
6 changed files with 231 additions and 32 deletions

View file

@ -15,16 +15,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
gerber.cnc
CAM File
============
**CNC file classes**
**AM file classes**
This module provides common base classes for Excellon/Gerber CNC files
"""
class FileSettings(object):
""" CNC File Settings
""" CAM File Settings
Provides a common representation of gerber/excellon file settings
"""
@ -60,7 +60,7 @@ class FileSettings(object):
raise KeyError()
class CncFile(object):
class CamFile(object):
""" Base class for Gerber/Excellon files.
Provides a common set of settings parameters.
@ -71,7 +71,10 @@ class CncFile(object):
The current file configuration.
filename : string
Name of the file that this CncFile represents.
Name of the file that this CamFile represents.
layer_name : string
Name of the PCB layer that the file represents
Attributes
----------
@ -92,7 +95,8 @@ class CncFile(object):
decimal digits)
"""
def __init__(self, statements=None, settings=None, filename=None):
def __init__(self, statements=None, settings=None, filename=None,
layer_name=None):
if settings is not None:
self.notation = settings['notation']
self.units = settings['units']
@ -105,6 +109,7 @@ class CncFile(object):
self.format = (2, 5)
self.statements = statements if statements is not None else []
self.filename = filename
self.layer_name = layer_name
@property
def settings(self):

View file

@ -24,16 +24,22 @@ This module provides Excellon file classes and parsing utilities
from .excellon_statements import *
from .cnc import CncFile, FileSettings
from .cam import CamFile, FileSettings
import math
def read(filename):
""" Read data from filename and return an ExcellonFile
"""
return ExcellonParser().parse(filename)
detected_settings = detect_excellon_format(filename)
settings = FileSettings(**detected_settings)
zeros = ''
print('Detected %d:%d format with %s zero suppression' %
(settings.format[0], settings.format[1], settings.zero_suppression))
return ExcellonParser(settings).parse(filename)
class ExcellonFile(CncFile):
class ExcellonFile(CamFile):
""" A class representing a single excellon file
The ExcellonFile class represents a single excellon file.
@ -83,8 +89,13 @@ class ExcellonFile(CncFile):
class ExcellonParser(object):
""" Excellon File Parser
Parameters
----------
settings : FileSettings or dict-like
Excellon file settings to use when interpreting the excellon file.
"""
def __init__(self):
def __init__(self, settings=None):
self.notation = 'absolute'
self.units = 'inch'
self.zero_suppression = 'trailing'
@ -95,7 +106,38 @@ class ExcellonParser(object):
self.hits = []
self.active_tool = None
self.pos = [0., 0.]
if settings is not None:
self.units = settings['units']
self.zero_suppression = settings['zero_suppression']
self.notation = settings['notation']
self.format = settings['format']
@property
def coordinates(self):
return [(stmt.x, stmt.y) for stmt in self.statements if isinstance(stmt, CoordinateStmt)]
@property
def bounds(self):
xmin = ymin = 100000000000
xmax = ymax = -100000000000
for x, y in self.coordinates:
if x is not None:
xmin = x if x < xmin else xmin
xmax = x if x > xmax else xmax
if y is not None:
ymin = y if y < ymin else ymin
ymax = y if y > ymax else ymax
return ((xmin, xmax), (ymin, ymax))
@property
def hole_sizes(self):
return [stmt.diameter for stmt in self.statements if isinstance(stmt, ExcellonTool)]
@property
def hole_count(self):
return len(self.hits)
def parse(self, filename):
with open(filename, 'r') as f:
for line in f:
@ -194,3 +236,106 @@ class ExcellonParser(object):
return FileSettings(units=self.units, format=self.format,
zero_suppression=self.zero_suppression,
notation=self.notation)
def detect_excellon_format(filename):
""" Detect excellon file decimal format and zero-suppression settings.
Parameters
----------
filename : string
Name of the file to parse. This does not check if the file is actually
an Excellon file, so do that before calling this.
Returns
-------
settings : dict
Detected excellon file settings. Keys are
- `format`: decimal format as tuple (<int part>, <decimal part>)
- `zero_suppression`: zero suppression, 'leading' or 'trailing'
"""
results = {}
detected_zeros = None
detected_format = None
zs_options = ('leading', 'trailing', )
format_options = ((2, 4), (2, 5), (3, 3),)
# Check for obvious clues:
p = ExcellonParser()
p.parse(filename)
# Get zero_suppression from a unit statement
zero_statements = [stmt.zero_suppression for stmt in p.statements
if isinstance(stmt, UnitStmt)]
# get format from altium comment
format_comment = [stmt.comment for stmt in p.statements
if isinstance(stmt, CommentStmt)
and 'FILE_FORMAT' in stmt.comment]
detected_format = (tuple([int(val) for val in
format_comment[0].split('=')[1].split(':')])
if len(format_comment) == 1 else None)
detected_zeros = zero_statements[0] if len(zero_statements) == 1 else None
# Bail out here if possible
if detected_format is not None and detected_zeros is not None:
return {'format': detected_format, 'zero_suppression': detected_zeros}
# Only look at remaining options
if detected_format is not None:
format_options = (detected_format,)
if detected_zeros is not None:
zs_options = (detected_zeros,)
# Brute force all remaining options, and pick the best looking one...
for zs in zs_options:
for fmt in format_options:
key = (fmt, zs)
settings = FileSettings(zero_suppression=zs, format=fmt)
try:
p = ExcellonParser(settings)
p.parse(filename)
size = tuple([t[1] - t[0] for t in p.bounds])
hole_area = 0.0
for hit in p.hits:
tool = hit[0]
hole_area += math.pow(math.pi * tool.diameter, 2)
results[key] = (size, p.hole_count, hole_area)
except:
pass
# See if any of the dimensions are left with only a single option
formats = set(key[0] for key in results.iterkeys())
zeros = set(key[1] for key in results.iterkeys())
if len(formats) == 1:
detected_format = formats.pop()
if len(zeros) == 1:
detected_zeros = zeros.pop()
# Bail out here if we got everything....
if detected_format is not None and detected_zeros is not None:
return {'format': detected_format, 'zero_suppression': detected_zeros}
# Otherwise score each option and pick the best candidate
else:
scores = {}
for key in results.keys():
size, count, diameter = results[key]
scores[key] = _layer_size_score(size, count, diameter)
minscore = min(scores.values())
for key in scores.iterkeys():
if scores[key] == minscore:
return {'format': key[0], 'zero_suppression': key[1]}
def _layer_size_score(size, hole_count, hole_area):
""" Heuristic used for determining the correct file number interpretation.
Lower is better.
"""
board_area = size[0] * size[1]
hole_percentage = hole_area / board_area
hole_score = (hole_percentage - 0.25) ** 2
size_score = (board_area - 8) **2
return hole_score * size_score

View file

@ -27,7 +27,7 @@ This module provides an RS-274-X class and parser
import re
import json
from .gerber_statements import *
from .cnc import CncFile, FileSettings
from .cam import CamFile, FileSettings
@ -38,7 +38,7 @@ def read(filename):
return GerberParser().parse(filename)
class GerberFile(CncFile):
class GerberFile(CamFile):
""" A class representing a single gerber file
The GerberFile class represents a single gerber file.

View file

@ -133,7 +133,12 @@ class MOParamStmt(ParamStmt):
@classmethod
def from_dict(cls, stmt_dict):
param = stmt_dict.get('param')
mo = 'inch' if stmt_dict.get('mo') == 'IN' else 'metric'
if stmt_dict.get('mo').lower() == 'in':
mo = 'inch'
elif stmt_dict.get('mo').lower() == 'mm':
mo = 'metric'
else:
mo = None
return cls(param, mo)
def __init__(self, param, mo):

View file

@ -3,7 +3,7 @@
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
from ..cnc import CncFile, FileSettings
from ..cam import CamFile, FileSettings
from tests import *
@ -46,5 +46,5 @@ def test_filesettings_assign():
assert_equal(fs.zero_suppression, 'test')
assert_equal(fs.format, 'test')
def test_smoke_cncfile():
pass
def test_smoke_camfile():
cf = CamFile

View file

@ -8,7 +8,7 @@ from ..gerber_statements import *
def test_FSParamStmt_factory():
""" Test FSParamStruct factory correctly handles parameters
""" Test FSParamStruct factory
"""
stmt = {'param': 'FS', 'zero': 'L', 'notation': 'A', 'x': '27'}
fs = FSParamStmt.from_dict(stmt)
@ -24,6 +24,18 @@ def test_FSParamStmt_factory():
assert_equal(fs.notation, 'incremental')
assert_equal(fs.format, (2, 7))
def test_FSParamStmt():
""" Test FSParamStmt initialization
"""
param = 'FS'
zeros = 'trailing'
notation = 'absolute'
fmt = (2, 5)
stmt = FSParamStmt(param, zeros, notation, fmt)
assert_equal(stmt.param, param)
assert_equal(stmt.zero_suppression, zeros)
assert_equal(stmt.notation, notation)
assert_equal(stmt.format, fmt)
def test_FSParamStmt_dump():
""" Test FSParamStmt to_gerber()
@ -38,17 +50,31 @@ def test_FSParamStmt_dump():
def test_MOParamStmt_factory():
""" Test MOParamStruct factory correctly handles parameters
""" Test MOParamStruct factory
"""
stmt = {'param': 'MO', 'mo': 'IN'}
mo = MOParamStmt.from_dict(stmt)
assert_equal(mo.param, 'MO')
assert_equal(mo.mode, 'inch')
stmts = [{'param': 'MO', 'mo': 'IN'}, {'param': 'MO', 'mo': 'in'}, ]
for stmt in stmts:
mo = MOParamStmt.from_dict(stmt)
assert_equal(mo.param, 'MO')
assert_equal(mo.mode, 'inch')
stmt = {'param': 'MO', 'mo': 'MM'}
mo = MOParamStmt.from_dict(stmt)
assert_equal(mo.param, 'MO')
assert_equal(mo.mode, 'metric')
stmts = [{'param': 'MO', 'mo': 'MM'}, {'param': 'MO', 'mo': 'mm'}, ]
for stmt in stmts:
mo = MOParamStmt.from_dict(stmt)
assert_equal(mo.param, 'MO')
assert_equal(mo.mode, 'metric')
def test_MOParamStmt():
""" Test MOParamStmt initialization
"""
param = 'MO'
mode = 'inch'
stmt = MOParamStmt(param, mode)
assert_equal(stmt.param, param)
for mode in ['inch', 'metric']:
stmt = MOParamStmt(param, mode)
assert_equal(stmt.mode, mode)
def test_MOParamStmt_dump():
@ -64,7 +90,7 @@ def test_MOParamStmt_dump():
def test_IPParamStmt_factory():
""" Test IPParamStruct factory correctly handles parameters
""" Test IPParamStruct factory
"""
stmt = {'param': 'IP', 'ip': 'POS'}
ip = IPParamStmt.from_dict(stmt)
@ -74,6 +100,15 @@ def test_IPParamStmt_factory():
ip = IPParamStmt.from_dict(stmt)
assert_equal(ip.ip, 'negative')
def test_IPParamStmt():
""" Test IPParamStmt initialization
"""
param = 'IP'
for ip in ['positive', 'negative']:
stmt = IPParamStmt(param, ip)
assert_equal(stmt.param, param)
assert_equal(stmt.ip, ip)
def test_IPParamStmt_dump():
""" Test IPParamStmt to_gerber()
@ -88,14 +123,23 @@ def test_IPParamStmt_dump():
def test_OFParamStmt_factory():
""" Test OFParamStmt factory correctly handles parameters
""" Test OFParamStmt factory
"""
stmt = {'param': 'OF', 'a': '0.1234567', 'b': '0.1234567'}
of = OFParamStmt.from_dict(stmt)
assert_equal(of.a, 0.1234567)
assert_equal(of.b, 0.1234567)
def test_OFParamStmt():
""" Test IPParamStmt initialization
"""
param = 'OF'
for val in [0.0, -3.4567]:
stmt = OFParamStmt(param, val, val)
assert_equal(stmt.param, param)
assert_equal(stmt.a, val)
assert_equal(stmt.b, val)
def test_OFParamStmt_dump():
""" Test OFParamStmt to_gerber()
"""
@ -105,7 +149,7 @@ def test_OFParamStmt_dump():
def test_LPParamStmt_factory():
""" Test LPParamStmt factory correctly handles parameters
""" Test LPParamStmt factory
"""
stmt = {'param': 'LP', 'lp': 'C'}
lp = LPParamStmt.from_dict(stmt)
@ -128,7 +172,7 @@ def test_LPParamStmt_dump():
def test_INParamStmt_factory():
""" Test INParamStmt factory correctly handles parameters
""" Test INParamStmt factory
"""
stmt = {'param': 'IN', 'name': 'test'}
inp = INParamStmt.from_dict(stmt)
@ -143,7 +187,7 @@ def test_INParamStmt_dump():
def test_LNParamStmt_factory():
""" Test LNParamStmt factory correctly handles parameters
""" Test LNParamStmt factory
"""
stmt = {'param': 'LN', 'name': 'test'}
lnp = LNParamStmt.from_dict(stmt)