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

jlcparts-based parts library #374

Merged
merged 43 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
fc5eaba
Update requirements.txt
ducky64 Aug 10, 2024
c456421
Create JlcPartsBase.py
ducky64 Aug 10, 2024
1446df1
Update JlcPartsBase.py
ducky64 Aug 10, 2024
a225b10
Update JlcPartsBase.py
ducky64 Aug 10, 2024
132f153
Update JlcPartsBase.py
ducky64 Aug 10, 2024
86a50a1
wip
ducky64 Aug 10, 2024
6d764bf
most of the mlcc parser
ducky64 Aug 10, 2024
90466bd
cleaning
ducky64 Aug 11, 2024
8d9ecd9
Update JlcPartsResistorSmd.py
ducky64 Aug 11, 2024
c811348
cleaned up parsing
ducky64 Aug 11, 2024
fd18420
cleaning
ducky64 Aug 11, 2024
29e7ab3
Update JlcPartsBase.py
ducky64 Aug 11, 2024
4109d1f
cleaning
ducky64 Aug 12, 2024
2b1ee41
cleaning
ducky64 Aug 12, 2024
29865e3
Update JlcPartsResistorSmd.py
ducky64 Aug 12, 2024
8dc45dd
wip
ducky64 Aug 13, 2024
e77f494
diodes n stuff
ducky64 Aug 16, 2024
f522f0a
wip
ducky64 Aug 16, 2024
39e1cb2
wip
ducky64 Aug 16, 2024
dcfc156
cleaning + ferrites
ducky64 Aug 16, 2024
d1cd0f9
Update JlcPartsPptcFuse.py
ducky64 Aug 16, 2024
fbbdf05
wip
ducky64 Aug 17, 2024
bff17d8
bjt
ducky64 Aug 17, 2024
fe7342c
wip
ducky64 Aug 17, 2024
34eabf7
wip
ducky64 Aug 17, 2024
455890e
wip
ducky64 Aug 17, 2024
6937d95
Update JlcPartsFet.py
ducky64 Aug 17, 2024
088df29
MLCC
ducky64 Aug 18, 2024
94f689b
cleaning
ducky64 Aug 18, 2024
89bd2aa
zeners
ducky64 Aug 18, 2024
b512534
cleaning
ducky64 Aug 18, 2024
cb81f61
wip
ducky64 Aug 18, 2024
6fad01f
Update JlcPartsInductor.py
ducky64 Aug 18, 2024
d13c20f
wip
ducky64 Aug 19, 2024
ce0fab6
tuning to get stuff to build
ducky64 Aug 19, 2024
0aac1ec
cleaning
ducky64 Aug 19, 2024
f7f0b6f
cleaning
ducky64 Aug 19, 2024
4731354
switchfet
ducky64 Aug 19, 2024
b51581e
automated tests
ducky64 Aug 19, 2024
e220efe
wip mypy is fickle
ducky64 Aug 19, 2024
84d063e
fickle beast part 2
ducky64 Aug 19, 2024
a2ac6ce
add cost
ducky64 Aug 19, 2024
87de9d6
Update JlcPartsBase.py
ducky64 Aug 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/pr-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ jobs:

- name: install mypy
run: |
pip install mypy mypy-protobuf types-protobuf types-Deprecated
pip install -r requirements.txt
pip install mypy mypy-protobuf
mypy --version
- name: mypy
run: mypy --install-types .
Expand Down Expand Up @@ -94,7 +95,8 @@ jobs:

- name: install mypy
run: |
pip install mypy mypy-protobuf types-protobuf types-Deprecated
pip install -r requirements.txt
pip install mypy mypy-protobuf
mypy --version
- name: mypy
run: mypy --install-types .
Expand Down
1 change: 1 addition & 0 deletions edg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .electronics_model import *
from .abstract_parts import *
from .parts import *
from .jlcparts import *

from .BoardTop import BoardTop, SimpleBoardTop, JlcBoardTop

Expand Down
21 changes: 21 additions & 0 deletions edg/abstract_parts/AbstractLed.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from ..electronics_model import *
from .Categories import *
from .AbstractResistor import Resistor
from .PartsTable import PartsTableColumn, PartsTableRow
from .PartsTablePart import PartsTableFootprintSelector
from .StandardFootprint import StandardFootprint

LedColor = str # type alias
Expand All @@ -12,6 +14,7 @@ class Led(DiscreteSemiconductor):
# Common color definitions
Red: LedColor = "red"
Green: LedColor = "green"
GreenYellow: LedColor = "greenyellow" # more a mellow green
Blue: LedColor = "blue"
Yellow: LedColor = "yellow"
White: LedColor = "white"
Expand Down Expand Up @@ -60,6 +63,24 @@ class LedStandardFootprint(Led, StandardFootprint[Led]):
}


@non_library
class TableLed(LedStandardFootprint, PartsTableFootprintSelector):
COLOR = PartsTableColumn(str)

@init_in_parent
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.generator_param(self.color)

def _row_filter(self, row: PartsTableRow) -> bool:
return super()._row_filter(row) and \
(not self.get(self.color) or row[self.COLOR] == self.get(self.color))

def _row_generate(self, row: PartsTableRow) -> None:
super()._row_generate(row)
self.assign(self.actual_color, row[self.COLOR])


@abstract_block
class RgbLedCommonAnode(DiscreteSemiconductor):
def __init__(self):
Expand Down
2 changes: 1 addition & 1 deletion edg/abstract_parts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from .AbstractDiodes import BaseDiode, Diode, BaseDiodeStandardFootprint, TableDiode
from .AbstractDiodes import ZenerDiode, TableZenerDiode, ProtectionZenerDiode, AnalogClampZenerDiode
from .AbstractTvsDiode import TvsDiode, ProtectionTvsDiode, DigitalTvsDiode
from .AbstractLed import Led, LedStandardFootprint, RgbLedCommonAnode, LedColor, LedColorLike
from .AbstractLed import Led, LedStandardFootprint, TableLed, RgbLedCommonAnode, LedColor, LedColorLike
from .AbstractLed import IndicatorLed, IndicatorSinkLed, IndicatorSinkLedResistor, VoltageIndicatorLed, IndicatorSinkRgbLed
from .AbstractLed import IndicatorSinkPackedRgbLed
from .AbstractLed import IndicatorLedArray, IndicatorSinkLedArray
Expand Down
2 changes: 1 addition & 1 deletion edg/electronics_model/PartParserUtil.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def parse_abs_tolerance(cls, value: str, center: float, units: str) -> Range:
elif value.endswith('ppm'):
value = value.removesuffix('ppm').rstrip()
return Range.from_tolerance(center, float(value) * 1e-6)
elif value.endswith(units):
elif units and value.endswith(units):
return Range.from_abs_tolerance(center, cls.parse_value(value, units))

raise cls.ParseError(f"Unknown tolerance '{value}'")
170 changes: 170 additions & 0 deletions edg/jlcparts/JlcPartsBase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import sys
from typing import Any, Optional, Dict, List, TypeVar, Type

from pydantic import BaseModel, RootModel, Field
import gzip
import os

from ..abstract_parts import *
from ..parts.JlcPart import JlcPart

kTableFilenamePostfix = ".json.gz"
kStockFilenamePostfix = ".stock.json"


class JlcPartsFile(BaseModel):
category: str
components: list[list[Any]] # index-matched with schema
jlcpart_schema: list[str] = Field(..., alias='schema')


class JlcPartsAttributeEntry(BaseModel):
# format: Optional[str] = None # unused, no idea why this exists
# primary: Optional[str] = None # unused, no idea why this exists
values: dict[str, tuple[Any, str]]


ParsedType = TypeVar('ParsedType') # can't be inside the class or it gets confused as a pydantic model entry

class JlcPartsAttributes(RootModel):
root: dict[str, JlcPartsAttributeEntry]

def get(self, key: str, expected_type: Type[ParsedType], default: Optional[ParsedType] = None, sub='default') -> ParsedType:
"""Utility function that gets an attribute of the specified name, checking that it is the expected type
or returning some default (if specified)."""
if key not in self.root:
if default is not None:
return default
else:
raise KeyError
value = self.root[key].values[sub][0]
if not isinstance(value, expected_type):
if default is not None:
return default
else:
raise TypeError
return value

def __contains__(self, key: str) -> bool:
return key in self.root


class JlcPartsPriceEntry(BaseModel):
price: float
qFrom: int
qTo: Optional[int] # None = top bucket


class JlcPartsPrice(RootModel):
root: list[JlcPartsPriceEntry]

def for_min_qty(self) -> float:
min_seen_price = (sys.maxsize, float(sys.maxsize)) # return ridiculously high if not specified

for bucket in self.root:
if bucket.qFrom <= 1 or bucket.qFrom is None: # short circuit for qty=1
return bucket.price
if bucket.qFrom < min_seen_price[0]:
min_seen_price = (bucket.qFrom, bucket.price)
return min_seen_price[1]


class JlcPartsStockFile(RootModel):
root: dict[str, int] # LCSC to stock level


class JlcPartsBase(JlcPart, PartsTableSelector, PartsTableFootprint):
"""Base class parsing parts from https://github.com/yaqwsx/jlcparts"""
_config_parts_root_dir: Optional[str] = None
_config_min_stock: int = 250

# overrides from PartsTableBase
PART_NUMBER_COL = PartsTableColumn(str)
MANUFACTURER_COL = PartsTableColumn(str)
DESCRIPTION_COL = PartsTableColumn(str)
DATASHEET_COL = PartsTableColumn(str)

# new columns here
LCSC_COL = PartsTableColumn(str)
BASIC_PART_COL = PartsTableColumn(bool)
COST_COL = PartsTableColumn(str)

@staticmethod
def config_root_dir(root_dir: str):
"""Configures the root dir that contains the data files from jlcparts, eg
CapacitorsMultilayer_Ceramic_Capacitors_MLCC___SMDakaSMT.json.gz
This setting is on a JlcPartsBase-wide basis."""
assert JlcPartsBase._config_parts_root_dir is None, \
f"attempted to reassign configure_root_dir, was {JlcPartsBase._config_parts_root_dir}, new {root_dir}"
JlcPartsBase._config_parts_root_dir = root_dir

_JLC_PARTS_FILE_NAMES: List[str] # set by subclass
_cached_table: Optional[PartsTable] = None # set on a per-class basis

@classmethod
def _make_table(cls) -> PartsTable:
"""Return the table, cached if possible"""
if cls._cached_table is None:
cls._cached_table = cls._parse_table()
return cls._cached_table

@classmethod
def _entry_to_table_row(cls, row_dict: Dict[PartsTableColumn, Any], filename: str, package: str, attributes: JlcPartsAttributes)\
-> Optional[Dict[PartsTableColumn, Any]]:
"""Given an entry from jlcparts and row pre-populated with metadata, adds category-specific data to the row
(in-place), and returns the row (or None, if it failed to parse and the row should be discarded)."""
raise NotImplementedError

@classmethod
def _parse_table(cls) -> PartsTable:
"""Parses the file to a PartsTable"""
assert cls._config_parts_root_dir is not None, "must configure_root_dir with jlcparts data folder"

rows: List[PartsTableRow] = []

for filename in cls._JLC_PARTS_FILE_NAMES:
with gzip.open(os.path.join(cls._config_parts_root_dir, filename + kTableFilenamePostfix), 'r') as f:
data = JlcPartsFile.model_validate_json(f.read())
with open(os.path.join(cls._config_parts_root_dir, filename + kStockFilenamePostfix), 'r') as f:
stocking = JlcPartsStockFile.model_validate_json(f.read())

lcsc_index = data.jlcpart_schema.index("lcsc")
part_number_index = data.jlcpart_schema.index("mfr")
description_index = data.jlcpart_schema.index("description")
datasheet_index = data.jlcpart_schema.index("datasheet")
attributes_index = data.jlcpart_schema.index("attributes")
price_index = data.jlcpart_schema.index("price")

for component in data.components:
row_dict: Dict[PartsTableColumn, Any] = {}

row_dict[cls.LCSC_COL] = lcsc = component[lcsc_index]
if stocking.root.get(lcsc, 0) < cls._config_min_stock:
continue

row_dict[cls.PART_NUMBER_COL] = component[part_number_index]
row_dict[cls.DESCRIPTION_COL] = component[description_index]
row_dict[cls.DATASHEET_COL] = component[datasheet_index]
row_dict[cls.COST_COL] = JlcPartsPrice(component[price_index]).for_min_qty()

attributes = JlcPartsAttributes(**component[attributes_index])
if attributes.get("Status", str) in ["Discontinued"]:
continue
row_dict[cls.BASIC_PART_COL] = attributes.get("Basic/Extended", str) == "Basic"
row_dict[cls.MANUFACTURER_COL] = attributes.get("Manufacturer", str)

package = attributes.get("Package", str)
row_dict_opt = cls._entry_to_table_row(row_dict, filename, package, attributes)
if row_dict_opt is not None:
rows.append(PartsTableRow(row_dict_opt))

return PartsTable(rows)

@classmethod
def _row_sort_by(cls, row: PartsTableRow) -> Any:
return [row[cls.BASIC_PART_COL], row[cls.KICAD_FOOTPRINT], row[cls.COST_COL]]

def _row_generate(self, row: PartsTableRow) -> None:
super()._row_generate(row)
self.assign(self.lcsc_part, row[self.LCSC_COL])
self.assign(self.actual_basic_part, row[self.BASIC_PART_COL])
32 changes: 32 additions & 0 deletions edg/jlcparts/JlcPartsBjt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import Any, Optional, Dict
from ..abstract_parts import *
from ..parts.JlcBjt import JlcBjt
from .JlcPartsBase import JlcPartsBase, JlcPartsAttributes


class JlcPartsBjt(TableBjt, JlcPartsBase):
_JLC_PARTS_FILE_NAMES = ["TransistorsBipolar_Transistors___BJT"]
_CHANNEL_MAP = {
'NPN': 'NPN',
'PNP': 'PNP',
}

@classmethod
def _entry_to_table_row(cls, row_dict: Dict[PartsTableColumn, Any], filename: str, package: str, attributes: JlcPartsAttributes) \
-> Optional[Dict[PartsTableColumn, Any]]:
try:
row_dict[cls.KICAD_FOOTPRINT] = JlcBjt.PACKAGE_FOOTPRINT_MAP[package]

row_dict[cls.CHANNEL] = cls._CHANNEL_MAP[attributes.get("Transistor type", str)]
row_dict[cls.VCE_RATING] = Range.zero_to_upper(PartParserUtil.parse_value(
attributes.get("Collector-emitter breakdown voltage (vceo)", str), 'V'))
row_dict[cls.ICE_RATING] = Range.zero_to_upper(PartParserUtil.parse_value(
attributes.get("Collector current (ic)", str), 'A'))
row_dict[cls.GAIN] = Range.exact(PartParserUtil.parse_value(
attributes.get("Dc current gain (hfe@ic,vce)", str).split('@')[0], ''))
row_dict[cls.POWER_RATING] = Range.zero_to_upper(
attributes.get("Power dissipation (pd)", float, sub='power'))

return row_dict
except (KeyError, TypeError, PartParserUtil.ParseError):
return None
31 changes: 31 additions & 0 deletions edg/jlcparts/JlcPartsBoardTop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from ..parts import *

from .JlcPartsResistorSmd import JlcPartsResistorSmd
from .JlcPartsMlcc import JlcPartsMlcc
from .JlcPartsInductor import JlcPartsInductor
from .JlcPartsFerriteBead import JlcPartsFerriteBead
from .JlcPartsDiode import JlcPartsDiode, JlcPartsZenerDiode
from .JlcPartsLed import JlcPartsLed
from .JlcPartsBjt import JlcPartsBjt
from .JlcPartsFet import JlcPartsFet, JlcPartsSwitchFet
from .JlcPartsPptcFuse import JlcPartsPptcFuse


class JlcPartsRefinements(DesignTop):
"""List of refinements that use JlcParts - mix this into a BoardTop"""
def refinements(self) -> Refinements:
return super().refinements() + Refinements(
class_refinements=[
(Resistor, JlcPartsResistorSmd),
(Capacitor, JlcPartsMlcc),
(Inductor, JlcPartsInductor),
(Diode, JlcPartsDiode),
(ZenerDiode, JlcPartsZenerDiode),
(Led, JlcPartsLed),
(Bjt, JlcPartsBjt),
(Fet, JlcPartsFet),
(SwitchFet, JlcPartsSwitchFet),
(PptcFuse, JlcPartsPptcFuse),
(FerriteBead, JlcPartsFerriteBead)
]
)
70 changes: 70 additions & 0 deletions edg/jlcparts/JlcPartsDiode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Any, Optional, Dict
from ..abstract_parts import *
from ..parts.JlcDiode import JlcDiode
from .JlcPartsBase import JlcPartsBase, JlcPartsAttributes


class JlcPartsDiode(TableDiode, JlcPartsBase):
_JLC_PARTS_FILE_NAMES = [
"DiodesSchottky_Barrier_Diodes__SBD_",
"DiodesDiodes___Fast_Recovery_Rectifiers",
"DiodesDiodes___General_Purpose",
"DiodesSwitching_Diode",
]

@classmethod
def _entry_to_table_row(cls, row_dict: Dict[PartsTableColumn, Any], filename: str, package: str, attributes: JlcPartsAttributes) \
-> Optional[Dict[PartsTableColumn, Any]]:
try:
row_dict[cls.KICAD_FOOTPRINT] = JlcDiode.PACKAGE_FOOTPRINT_MAP[package]

row_dict[cls.VOLTAGE_RATING] = Range.zero_to_upper(PartParserUtil.parse_value(
attributes.get("Reverse voltage (vr)", str), 'V'))
row_dict[cls.CURRENT_RATING] = Range.zero_to_upper(PartParserUtil.parse_value(
attributes.get("Average rectified current (io)", str), 'A'))
row_dict[cls.FORWARD_VOLTAGE] = Range.zero_to_upper(PartParserUtil.parse_value(
attributes.get("Forward voltage (vf@if)", str).split('@')[0], 'V'))

try: # sometimes '-'
reverse_recovery = Range.exact(PartParserUtil.parse_value(
attributes.get("Reverse recovery time (trr)", str), 's'))
except (KeyError, PartParserUtil.ParseError):
if filename == "DiodesDiodes___Fast_Recovery_Rectifiers":
reverse_recovery = Range(0, 500e-9) # arbitrary <500ns
else:
reverse_recovery = Range.all()
row_dict[cls.REVERSE_RECOVERY] = reverse_recovery

return row_dict
except (KeyError, TypeError, PartParserUtil.ParseError):
return None


class JlcPartsZenerDiode(TableZenerDiode, JlcPartsBase):
_JLC_PARTS_FILE_NAMES = ["DiodesZener_Diodes"]

@classmethod
def _entry_to_table_row(cls, row_dict: Dict[PartsTableColumn, Any], filename: str, package: str, attributes: JlcPartsAttributes) \
-> Optional[Dict[PartsTableColumn, Any]]:
try:
row_dict[cls.KICAD_FOOTPRINT] = JlcDiode.PACKAGE_FOOTPRINT_MAP[package]

if "Zener voltage (range)" in attributes: # note, some devices have range='-'
zener_voltage_split = attributes.get("Zener voltage (range)", str).split('~')
zener_voltage = Range(
PartParserUtil.parse_value(zener_voltage_split[0], 'V'),
PartParserUtil.parse_value(zener_voltage_split[1], 'V')
)
else: # explicit tolerance
zener_voltage = PartParserUtil.parse_abs_tolerance(
attributes.get("Tolerance", str),
PartParserUtil.parse_value(attributes.get("Zener voltage (nom)", str), 'V'),
'')
row_dict[cls.ZENER_VOLTAGE] = zener_voltage

row_dict[cls.POWER_RATING] = Range.zero_to_upper(PartParserUtil.parse_value(
attributes.get("Power dissipation", str), 'W'))

return row_dict
except (KeyError, TypeError, PartParserUtil.ParseError):
return None
Loading
Loading