Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show component values in list (+ other minor conveniences) #551

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
6 changes: 5 additions & 1 deletion datamodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(self, scale_factor):
"POS_COL": 7,
"ROT_COL": 8,
"SIDE_COL": 9,
"PARAMS_COL": 10,
}

self.bom_pos_icons = [
Expand Down Expand Up @@ -74,6 +75,7 @@ def GetColumnType(self, col): # noqa: DC04
"wxDataViewIconText",
"string",
"wxDataViewIconText",
"string",
)
return columntypes[col]

Expand Down Expand Up @@ -193,14 +195,15 @@ def select_alike(self, item):
alike.append(self.ObjectToItem(data))
return alike

def set_lcsc(self, ref, lcsc, type, stock):
def set_lcsc(self, ref, lcsc, type, stock, params):
"""Set an lcsc number, type and stock for given reference."""
if (index := self.find_index(ref)) is None:
return
item = self.data[index]
item[self.columns["LCSC_COL"]] = lcsc
item[self.columns["TYPE_COL"]] = type
item[self.columns["STOCK_COL"]] = stock
item[self.columns["PARAMS_COL"]] = params
self.ItemChanged(self.ObjectToItem(item))

def remove_lcsc_number(self, item):
Expand All @@ -209,6 +212,7 @@ def remove_lcsc_number(self, item):
obj[self.columns["LCSC_COL"]] = ""
obj[self.columns["TYPE_COL"]] = ""
obj[self.columns["STOCK_COL"]] = ""
item[self.columns["PARAMS_COL"]] = ""
self.ItemChanged(self.ObjectToItem(obj))

def toggle_bom(self, item):
Expand Down
171 changes: 171 additions & 0 deletions derive_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Derive parameters from the part description and package."""

# LCSC hides the critical parameters (like resistor values) in the middle of the
# description field, which makes them invisible in the footprint list. This
# makes it very tedious to double-check the part mappings, as the part details
# dialog has to be opened for each one.
#
# This function uses heuristics to extract a human-readable summary of the most
# significant parameters of the parts from the LCSC data so they can be displayed
# separately in the footprint list.

import logging
import re

logger = logging.getLogger()
logging.basicConfig(encoding="utf-8", level=logging.DEBUG)


def params_for_part(part) -> str:
"""Derive parameters from the part description and package."""
description = part.get("description", "")
category = part.get("category", "")
part_no = part.get("part_no", "")
package = part.get("package", "")

result = []

# These are heuristic regexes to pull out parameters like resistance,
# capacitance, voltage, current, etc. from the description field. As LCSC
# makes random changes to the format of the descriptions, this function will
# need to follow along.
#
# The package size isn't always in the description, but will be added by the
# caller.

# For passives, focus on generic values like resistance, capacitance, voltage

if "Resistors" in category:
result.extend(re.findall(r"([.\d]+[mkM]?Ω)", description))
result.extend(re.findall(r"(±[.\d]+%)", description))
elif "Capacitors" in category:
result.extend(re.findall(r"([.\d]+[pnmuμ]?F)", description))
result.extend(re.findall(r"([.\d]+[mkM]?V)", description))
elif "Inductors" in category:
result.extend(re.findall(r"([.\d]+[nuμm]?H)", description))
result.extend(re.findall(r"([.\d]+m?A)", description))

# For diodes, we may be looking for specific part or generic I/V specs

elif "Diodes" in category:
if part_no:
result.append(part_no)
result.extend(re.findall(r"(?<!@)\b([.\d]+[mkM]?[AW])\b", description))
result.extend(
re.findall(r"(?<!@)\b([.\d]+[mk]?V(?:~[.\d]+[mk]?V)?)(?!@)", description)
)
result.extend(re.findall(r"Schottky|Fast|Dual", description))

# For LEDs, check the color

elif "Optoelectronics" in category:
result.extend(
re.findall(
r"(red|green|blue|amber|emerald|white|yellow)",
description,
re.IGNORECASE,
)
)

# For other types, just show the part number

elif part_no:
result.append(part_no)

if package != "":
result.append(package)

return " ".join(result)


# Test cases from actual LCSC data
#
# Generate random samples from the parts DB with:
#
# SELECT "LCSC Part", "Description", "First Category", "Second Category"
# FROM parts
# WHERE ROWID IN (
# SELECT ROWID FROM parts
# where "First Category" match "Transistors"
# ORDER BY RANDOM() LIMIT 10
# )


def test_params_for_part():
"""Test cases from actual LCSC data."""
test_cases = {
"Resistors": [
("250mW Thin Film Resistor 200V ±0.1% ±25ppm/℃ 284kΩ", "284kΩ ±0.1%"),
("Metal Film Resistors 357kΩ 400mW ±50ppm/℃ ±1%", "357kΩ ±1%"),
("Wirewound Resistors 800Ω 13W ±30ppm/℃ ±5%", "800Ω ±5%"),
("7W ±75ppm/℃ ±1% 200mΩ", "200mΩ ±1%"),
("500mW Thick Film Resistors ±100ppm/℃ ±1% 365Ω", "365Ω ±1%"),
("250mW ±0.1% ±100ppm/℃ 6.04kΩ", "6.04kΩ ±0.1%"),
("±20% 250mW 1kΩ Potentiometers, Variable Resistors", "1kΩ ±20%"),
("Carbon Resister 3.3kΩ 2W -500ppm/℃~0ppm/℃ ±10%", "3.3kΩ ±10%"),
("47.04kΩ ±50ppm/℃ ±1%", "47.04kΩ ±1%"),
("2 ±5% 4.3kΩ 62.5mW ±200ppm/℃", "4.3kΩ ±5%"),
],
"Capacitors": [
("16V 68nF X7R ±20%", "68nF 16V"),
("1kV 33pF null ±10%", "33pF 1kV"),
("25V 100nF ±5%", "100nF 25V"),
("150V 8.2pF", "8.2pF 150V"),
("±10% 1.5nF R 2kV Through Hole Ceramic Capacitors", "1.5nF 2kV"),
("100V 120pF NP0 ±2%", "120pF 100V"),
("10V 22uF X6S ±20%", "22uF 10V"),
("100uF 15V 180mΩ ±10%", "100uF 15V"),
],
"Inductors": [
("3A 18.5nH ±5%", "18.5nH 3A"),
("175mA 12uH ±5%", "12uH 175mA"),
("600mA 1.4nH 150mΩ", "1.4nH 600mA"),
("6.4A 6uH ±25% 15A", "6uH 6.4A 15A"),
],
"Diodes": [
("1W 82V", "1W 82V"),
("500mW 8.2V", "500mW 8.2V"),
(
"16V 1 pair of common cathodes 1V@35mA 75mA Schottky Diodes",
"75mA 16V Schottky",
),
("150V 875mV@1A 25ns 1A s", "1A 150V"),
(
"100uA@100V 100V Dual Common Cathode 950mV@20A 20A TO-220AB",
"20A 100V Dual",
),
("45V 15A 580mV@15A Schottky Diodes", "15A 45V Schottky"),
("35V 100mA 300mV@10mA Schottky Diodes", "100mA 35V Schottky"),
(
"1.7V@2A 100ns 2A 1kV Fast Recovery / High Efficiency Diodes",
"2A 1kV Fast",
),
("40V Independent Type 450mV@3A 3A Schottky Diodes", "3A 40V Schottky"),
("Independent Type 5.8V~6.6V 300mW 6.2V", "300mW 5.8V~6.6V 6.2V"),
("6.2V~6.6V 200mW 5.8V", "200mW 6.2V~6.6V 5.8V"),
],
"Optoelectronics": [
("Blue LED Indication - Discrete", "Blue"),
("Emerald,Blue LED Indication - Discrete", "Emerald Blue"),
("350mA 7000K White 125° 2.73V", "White"),
],
"Other": [
("doesn't matter", ""),
],
}

for category, tests in test_cases.items():
for description, parsed_params in tests:
result = params_for_part(
{"description": description, "category": category, "package": "thepkg"}
)
expected = f"{parsed_params} thepkg" if parsed_params else "thepkg"
assert (
result == expected
), f"For {description}: expected {expected}, got {result}"
logger.info("All tests passed.")


# Run the tests if this file was run as a script
if __name__ == "__main__":
test_params_for_part()
6 changes: 5 additions & 1 deletion library.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def search(self, parameters):
"Manufacturer",
"Description",
"Price",
"First Category"
]
s = ",".join(f'"{c}"' for c in columns)
query = f"SELECT {s} FROM parts WHERE "
Expand Down Expand Up @@ -364,7 +365,10 @@ def get_part_details(self, number: str) -> dict:
with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con:
con.row_factory = dict_factory # noqa: DC05
cur = con.cursor()
query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type FROM parts WHERE parts MATCH :number"""
query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type,
"MFR.Part" as part_no, "Description" as description, "Package" as package,
"First Category" as category
FROM parts WHERE parts MATCH :number"""
cur.execute(query, {"number": number})
return next((n for n in cur.fetchall() if n["lcsc"] == number), {})

Expand Down
46 changes: 33 additions & 13 deletions mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import wx.dataview as dv # pylint: disable=import-error

from .datamodel import PartListDataModel
from .derive_params import params_for_part
from .events import (
EVT_ASSIGN_PARTS_EVENT,
EVT_LOGBOX_APPEND_EVENT,
Expand Down Expand Up @@ -360,16 +361,19 @@ def __init__(self, parent, kicad_provider=KicadProvider()):
table_sizer = wx.BoxSizer(wx.HORIZONTAL)
table_sizer.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 600)))

table_scroller = wx.ScrolledWindow(self, style=wx.HSCROLL | wx.VSCROLL)
table_scroller.SetScrollRate(20, 20)

self.footprint_list = dv.DataViewCtrl(
self,
table_scroller,
style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_MULTIPLE,
)

reference = self.footprint_list.AppendTextColumn(
"Reference", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
"Ref", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
)
value = self.footprint_list.AppendTextColumn(
"Value", 1, width=250, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
"Value", 1, width=150, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
)
footprint = self.footprint_list.AppendTextColumn(
"Footprint",
Expand All @@ -378,6 +382,9 @@ def __init__(self, parent, kicad_provider=KicadProvider()):
mode=dv.DATAVIEW_CELL_INERT,
align=wx.ALIGN_CENTER,
)
params = self.footprint_list.AppendTextColumn(
"LCSC Params", 10, width=150, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
)
lcsc = self.footprint_list.AppendTextColumn(
"LCSC", 3, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
)
Expand Down Expand Up @@ -410,13 +417,20 @@ def __init__(self, parent, kicad_provider=KicadProvider()):
pos.SetSortable(False)
rotation.SetSortable(True)
side.SetSortable(True)
params.SetSortable(True)

scrolled_sizer = wx.BoxSizer(wx.VERTICAL)
scrolled_sizer.Add(self.footprint_list, 1, wx.EXPAND)
table_scroller.SetSizer(scrolled_sizer)

table_sizer.Add(self.footprint_list, 20, wx.ALL | wx.EXPAND, 5)
table_sizer.Add(table_scroller, 20, wx.ALL | wx.EXPAND, 5)

self.footprint_list.Bind(
dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnFootprintSelected
)

self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_ACTIVATED, self.select_part)

self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnRightDown)

table_sizer.Add(self.right_toolbar, 1, wx.EXPAND, 5)
Expand Down Expand Up @@ -529,7 +543,8 @@ def assign_parts(self, e):
board = self.pcbnew.GetBoard()
fp = board.FindFootprintByReference(reference)
set_lcsc_value(fp, e.lcsc)
self.partlist_data_model.set_lcsc(reference, e.lcsc, e.type, e.stock)
params = params_for_part(self.library.get_part_details(e.lcsc))
self.partlist_data_model.set_lcsc(reference, e.lcsc, e.type, e.stock, params)

def display_message(self, e):
"""Dispaly a message with the data from the event."""
Expand Down Expand Up @@ -582,6 +597,7 @@ def populate_footprint_list(self, *_):
part["exclude_from_pos"],
str(self.get_correction(part, corrections)),
str(fp.GetLayer()),
params_for_part(details.get(part["lcsc"], {})),
]
)

Expand Down Expand Up @@ -794,12 +810,14 @@ def select_part(self, *_):
selection = {}
for item in self.footprint_list.GetSelections():
ref = self.partlist_data_model.get_reference(item)
lcsc = self.partlist_data_model.get_lcsc(item)
value = self.partlist_data_model.get_value(item)
if lcsc != "":
selection[ref] = lcsc
else:
selection[ref] = value
footprint = self.partlist_data_model.get_footprint(item)
if ref.startswith("R"):
value += "Ω"
m = re.search(r"_(\d+)_\d+Metric", footprint)
if m:
value += f" {m.group(1)}"
selection[ref] = value
PartSelectorDialog(self, selection).ShowModal()

def check_order_number(self):
Expand All @@ -815,7 +833,7 @@ def generate_fabrication_data(self, *_):
and not self.check_order_number()
):
result = wx.MessageBox(
"JLC order number placehodler not present! Continue?",
"JLC order number placeholder not present! Continue?",
"JLC order number placeholder",
wx.OK | wx.CANCEL | wx.CENTER,
)
Expand Down Expand Up @@ -852,9 +870,10 @@ def paste_part_lcsc(self, *_):
if (lcsc := self.sanitize_lcsc(text_data.GetText())) != "":
for item in self.footprint_list.GetSelections():
details = self.library.get_part_details(lcsc)
params = params_for_part(details)
reference = self.partlist_data_model.get_reference(item)
self.partlist_data_model.set_lcsc(
reference, lcsc, details["type"], details["stock"]
reference, lcsc, details["type"], details["stock"], params
)
self.store.set_lcsc(reference, lcsc)

Expand Down Expand Up @@ -920,8 +939,9 @@ def search_foot_mapping(self, *_):
self.store.set_lcsc(reference, lcsc)
self.logger.info("Found %s", lcsc)
details = self.library.get_part_details(lcsc)
params = params_for_part(self.library.get_part_details(lcsc))
self.partlist_data_model.set_lcsc(
reference, lcsc, details["type"], details["stock"]
reference, lcsc, details["type"], details["stock"], params
)

def sanitize_lcsc(self, lcsc_PN):
Expand Down
2 changes: 1 addition & 1 deletion partdetails.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def __init__(self, parent, part):
title="JLCPCB Part Details",
pos=wx.DefaultPosition,
size=HighResWxSize(parent.window, wx.Size(1000, 800)),
style=wx.DEFAULT_DIALOG_STYLE | wx.STAY_ON_TOP,
style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.STAY_ON_TOP,
)

self.logger = logging.getLogger(__name__)
Expand Down
Loading