Parts and Providers#
When we design a filter, we usually know what each passive must do long before we know which part it will be: 10 kΩ at 1%, or 100 nF rated for at least 10 V. Before the board is ordered, each needs a real part, ideally one your company prefers and keeps in stock. JITX lets the design say only what each passive must do and leaves the choice of part to the project’s configuration. Here we look at how that choice is made, starting from a CSV of your parts and later from your parts database, and at how to check a design before ordering.
Asking for a part by what it must do#
Resistor, Capacitor, PolarizedCapacitor and Inductor are ordinary components whose ports are declared but whose part is not yet chosen. They come from the jitxlib-parts package, which also supplies everything else on this page, so the project lists it in dependencies in pyproject.toml:
[project]
dependencies = [
# the jitx and jitxlib-standard entries already here, then:
"jitxlib-parts",
]
This Circuit asks for two resistors and a capacitor:
class InputFilter(Circuit):
def __init__(self):
...
self.r = Resistor(resistance=10 * kohm, tolerance=AtMost(1 * pct))
self.c = Capacitor(capacitance=100 * nF, rated_voltage_dc=AtLeast(10 * V))
self.pullup = Resistor(resistance=4.7 * kohm)
Each set of keywords is a query. Values carry units from jitx.units, and AtLeast and AtMost, from jitxlib.parts, turn a value into a bound. Because JITX compares units, 10 * kohm and 10000 * ohm are the same query, and a bare AtLeast(10) for a voltage raises DimensionMismatch against a part rated 16 V. Defaults for the whole design go on the Design, as Building the Design shows.
JITX answers the queries together once the whole design has been constructed in Python, or earlier if code reads a part’s value or mpn.
Providers and the parts chain#
A provider answers queries with real parts: a CSV file of your parts is one, and your parts database can be another. Because the design asks providers instead of naming parts, you can change where parts come from without touching a circuit.
The parts chain is the ordered list of providers a project uses. JITX asks them in order, and each answers a query in one of four ways: it offers matching parts, best first; it reports that nothing matches; it declines because the query uses a key it has no data for; or, as a provider enforcing a company policy might, it refuses with a Deny, which stops the chain and fails the build. The first provider to offer parts wins, and its best part is used. If none does, the build stops with no part found. JITX then records each choice in a lockfile, so later builds keep it.
Your parts as a CSV#
The quickest provider to start with is a CSV file of your preferred, in-stock passives, much like a list exported from your PLM system. Here is a small one:
category,mpn,manufacturer,resistance,capacitance,case,tolerance,rated_voltage_dc,temperature_coefficient_code
resistor,RC0402FR-0710KL,Yageo,10 kohm,,0402,0.01,,
capacitor,GRM155R71C104KA88D,Murata,,100 nF,0402,0.1,16 V,X7R
The project names it in its chain in pyproject.toml, with a path relative to that file:
[tool.jitx.parts]
chain = ["csv:parts/passives.csv"]
The CsvParts provider reads the header as what the file can answer. mpn and manufacturer identify the part, category is resistor, capacitor or inductor, and datasheet is copied onto the component but not queryable. Every other column some row fills in is an attribute a query can filter or rank on. A cell can carry its unit, as 100 nF does; a bare number in a value or rating column is read in ohms, farads, henries, volts, watts or amps. A tolerance cell is a fraction, as 0.01 is, or carries %, as in 1%; a bare 1 reads as 100%.
A blank cell means the row lacks that attribute, so it never matches a query that filters on it. A key with no column makes the provider decline: our two-row file has no max_power column, so it declines any query for a power rating. A misspelled key, such as tolerence=, is passed on as written, so the CSV declines it for want of a column, even though the file holds a 10 kΩ 1% part, and the query goes to the next provider. Of several matching rows, the first in the file wins unless the query ranks them with rank=. One case is silent: a path that names no file reads as an empty file, with no message, so every query is declined.
The jitxexamples package ships a starter list as the resource parts/example-passives.csv, so to use it, add jitxexamples to the same dependencies list. This copies it into a project, where you can trim it to the parts you use:
from importlib.resources import files
from pathlib import Path
starter = files("jitxexamples") / "parts" / "example-passives.csv"
Path("parts").mkdir(exist_ok=True)
Path("parts/passives.csv").write_text(starter.read_text())
Building before every part is chosen#
With only the CSV in the chain, our 4.7 kΩ pull-up has no row, and the build stops:
my_board.main.Board:
design: my_board.main.Board
errors:
translation failed:
no part found for resistor(resistance=4.7 kiloohm)
csv:passives.csv: 0 of 1 resistor rows matched resistor(resistance=4.7 kiloohm)
hints:
Relax a filter, or call `explain(query)` to see each provider's reason
Early in a design, though, we want to lay out the board before every part is settled. The ideal provider, IdealPassives, looks nothing up and makes a part at the requested value. Placed after the CSV, it is asked only when the CSV has no answer:
[tool.jitx.parts]
chain = ["csv:parts/passives.csv", "ideal"]
An ideal part takes the tolerance the query sets exactly, or 1% if it sets none, and its value snaps to the nearest standard value for that tolerance: E24 or E96 at 1%, E24 alone at 5%. Both the snapped value and that tolerance must still match the query:
# 1%, and 31.6 kΩ is an E96 value: IDEAL-R-31600-0402
self.r1 = Resistor(resistance=31.6 * kohm)
# 5%, the nearest E24 value is 33 kΩ, which does not match: no part
self.r2 = Resistor(resistance=31.6 * kohm, tolerance=5 * pct)
# a bound sets no tolerance, and 1% is not within 0.1%: no part
self.r3 = Resistor(resistance=31.6 * kohm, tolerance=AtMost(0.1 * pct))
Set exactly, as tolerance=0.1 * pct, the same tolerance gets a part, snapped to a finer series. A value given as a span, such as a Range of 9 kΩ to 11 kΩ, is declined, since there is no single value to make. Besides the value it honors only case (0402 if unset), tolerance and polarized, so a query for 50 V or C0G gets a part that says nothing about either. Ideal parts have part numbers such as IDEAL-R-4700-0402 and no manufacturer, because there is nothing to buy.
So when the CSV has no answer, the ideal provider supplies a part without any error, as long as it can make the value and a tolerance the query accepts: when no row matches, when the file lacks a column for one of the query’s keys (a misspelled key among them), when it has no rows of that category, and when a PolarizedCapacitor meets a file with no polarized column (a file marks its polarized capacitors with true, yes, y or 1 in that column). A query it cannot make either, such as the 5% query or the 0.1% bound above, or a span, ends in no part found. If the path names no file, every query goes to the ideal provider.
Seeing which provider answered#
explain shows what each provider says about one query. It takes a ResistorQuery, CapacitorQuery or InductorQuery, with the components’ keywords:
from jitx.units import kohm
from jitxlib.parts import ResistorQuery, explain
print(explain(ResistorQuery(resistance=4.7 * kohm)))
Run with a working directory inside the project, it reads the project’s chain:
chain from project config
1 csv:passives.csv 0 of 1 resistor rows matched resistor(resistance=4.7 kiloohm)
2 ideal 1 candidate(s)
The CSV has every column the query names but no 4.7 kΩ row, so the ideal provider answered. A query the CSV declines for want of a column reads unsupported: instead, with the file’s path and has no such column; compare the query’s keys with the header, and check the printed path exists. explain shows what the chain would answer now, which may differ from what a build already chose.
Setting the chain in Python#
A test or a variant board can set the chain in code as a PartSource, assigned to parts on the Design or used as a with block around some circuits:
from jitxlib.parts import PartSource
from jitxlib.parts.providers import CsvParts, IdealPassives
class Board(Design):
parts = PartSource([CsvParts("parts/passives.csv"), IdealPassives()])
A path given here is resolved against the directory the build starts in. When several settings exist, the closest wins: a with block, then the Design attribute, then pyproject.toml, then a chain published by an installed package, and last, only with discover = true under [tool.jitx.parts], one assembled from the installed providers. With none, the build fails with no parts provider chain configured (NoChainConfigured).
Without source=, explain reads the project’s chain, from pyproject.toml, a published chain or discovery, and never a parts attribute or a with block, so it can describe a different chain from the one your build uses. To explore a chain set in Python, pass it as source=:
chain = PartSource([CsvParts("parts/passives.csv"), IdealPassives()])
print(explain(ResistorQuery(resistance=4.7 * kohm), source=chain))
The first line of the output names the chain it asked:
chain from explicit source (csv:passives.csv, ideal)
1 csv:passives.csv 0 of 1 resistor rows matched resistor(resistance=4.7 kiloohm)
2 ideal 1 candidate(s)
Why a build keeps its parts#
Once a part has been placed and routed, we want it to stay put, even as rows are added to the CSV or removed, or the database behind it is updated. If every build chose afresh, any of those could quietly change the board.
The lockfile that holds each choice is parts.lock.json, beside pyproject.toml. A successful build writes each new choice to it, and so does a jitx.test.TestCase run; commit it so other machines build with the same parts. A part recorded there is pinned: later builds keep it instead of choosing again. Each entry is keyed on what the query means, the chain, and an optional lock_scope= that pins identical queries separately. An entry stays until you remove it or an edited query adopts it, as the next paragraph describes.
Because an entry is keyed on the query and the chain, not on what the CSV holds, editing the CSV moves nothing already chosen, even a part whose row is deleted. The chain, though, is part of each entry’s key, so under a new chain no entry matches and every query resolves again; the old chain’s entries stay, and switching back restores its choices. An edited query misses its own entry, so the build first looks for a leftover entry under the same chain and lock_scope whose part still satisfies it. This lets a part already placed survive an edit such as a tightened tolerance: when exactly one leftover entry fits, its part is kept and the entry moves to the edited query; when none fits, or two fit equally well, the query resolves again.
So ideal parts pinned while the CSV path named no file stay pinned after the file is created, because the chain is unchanged. To have parts chosen afresh, delete parts.lock.json, or the entries concerned, and build again. lock-mode under [tool.jitx.parts] selects another LockMode, such as off, which ignores the file.
Checking before you order#
Before ordering, two kinds of part need attention: ideal parts, which cannot be bought, and pinned parts your source would no longer give. The parts check finds the second kind, including an ideal part your source can now supply:
jitx design export parts-check my_board.main.Board --fail all
Like a build, it needs the project’s JITX runtime running (from a terminal, jitx runtime start --background). It resolves every query from scratch, compares the result with what the build used, and with --fail all fails on any FailOn category. A design that passes reports:
Parts check: my_board.main.Board
3 pinned
This passing design still holds the ideal 4.7 kΩ pull-up, IDEAL-R-4700-0402. An ideal part your source still cannot answer passes, because the clean run picks an ideal part for it too. To list these parts, test the design itself: an ideal part’s mpn begins with IDEAL-, and a TestCase can walk the design’s Component objects with visit and fail on any it finds. Here it is as test_parts.py, beside pyproject.toml:
from jitx import Component
from jitx.inspect import visit
from jitx.test import TestCase
from my_board.main import Board
class NoIdealParts(TestCase):
def test_no_ideal_parts(self):
ideal = [
f"{trace.path}: {part.mpn}"
for trace, part in visit(Board(), Component)
if (part.mpn or "").startswith("IDEAL-")
]
self.assertEqual(ideal, [])
Run from the project root with python -m unittest, it fails on our design and names where the part sits:
AssertionError: Lists differ: ['circuit.pullup: IDEAL-R-4700-0402'] != []
It reads the parts on the design’s own components, under the current chain, so entries an old chain left in the lockfile do not show up.
Because the check compares against the choices in the lockfile, it has nothing to compare with when run before any build, or on a clone without the committed lockfile: it reports 3 unlocked, one line per query such as unlocked: resistor(resistance=4.7 kiloohm), and fails. Commit parts.lock.json, and build before you check.
A part that differs is listed under differs, with the file and line that constructed it, what was built and what a clean run would choose. Delete the 10 kΩ row and it reports built: RC0402FR-0710KL (Yageo) against clean run: IDEAL-R-10000-0402; add a 4.7 kΩ row and the pinned ideal pull-up shows built: IDEAL-R-4700-0402 against clean run: RC0402FR-074K7L (Yageo).
A build whose chain ends at your own source, without ideal, is another way to find ideal parts: it stops with no part found at the first part it cannot find, so you fix that part and build again.
To resolve what you find, add the part to your source, change the query to match a part you have, or change the chain so another provider supplies it. A pinned part stays pinned when the source changes, so after adding a row, delete the old entry, then build and check again.
Attaching your company’s parts database#
The CSV gets a project going quickly; in production you add a provider for your company’s own parts database to the chain, and the design code stays the same. Provider is the contract every provider follows, and CsvParts is the worked reference for writing one. A provider package registers under the jitxlib-parts-provider entry-point group:
[project.entry-points.jitxlib-parts-provider]
acme = "acme_parts.provider:AcmeParts"
Once it is installed, a chain names it like csv and ideal:
[tool.jitx.parts]
chain = ["acme", "csv:parts/passives.csv", "ideal"]
The database is asked first; only what it cannot answer reaches the CSV and the ideal provider. Adding it changes the chain, and a changed chain resolves every query again, so parts placed from the CSV can change on the first build after. The parts check cannot warn you beforehand, since under the new chain it reports every query as unlocked until that build. So keep the old chain until the design is ready for new parts; its entries stay in the lockfile, and building with it again restores its choices.