Skip to content

Commit

Permalink
Show component values in list (+ other minor conveniences) (#551)
Browse files Browse the repository at this point in the history
* Make footprint table scrollable

* Make part details dialog resizable

* Default to database priority

Defaulting the setting to board priority means that by default all
work done will be lost if you forget to click the export button.
This is dangerous and confusing, so instead this saves work by default.

It would be better to also warn when the dialog is closed without
exporting, when the setting is the other way.

* Don't erase non-matching board parts

If in database priority mode, set the board's part to the
database's part rather than erasing it.

* Show parameters derived from LCSC description

This adds an "LCSC Params" field to the footprint and part selection lists
that makes it much easier to double-check the assignments or select the
right part. Particularly for passives, LCSC buries the important data (like
the resistance of a resistor) in the middle of the description field. This
pulls those values out where you can see them.

* Add double-click behaviors

Double-clicking a footprint brings up part selection.
Double-clicking a part in the selector assigns the part.

* Improve default search string

Don't search for the existing LCSC value -- if I'm searching, that's
presumably the only part I don't want!

Tweak the search string to make it easier to find passives quickly.

* placehoDLer -> placehoLDer (#553)

Fix for little typo

* ruff auto fix errors

* Fix remaning ruff erorrs

---------

Co-authored-by: Maxime Vincent <maxime@veemax.be>
Co-authored-by: bouni <bouni@owee.de>
  • Loading branch information
3 people authored Dec 13, 2024
1 parent 080c907 commit dfda0c2
Show file tree
Hide file tree
Showing 9 changed files with 261 additions and 18 deletions.
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
44 changes: 32 additions & 12 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 Down Expand Up @@ -856,9 +874,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 @@ -924,8 +943,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

0 comments on commit dfda0c2

Please sign in to comment.