"""
IdealPassives
=============
Synthesize a passive at whatever value was asked for, snapped to an E-series.
Name it last in a chain, as `"ideal"` or as `IdealPassives()`, so a real
catalogue is always asked first and a design still elaborates before anyone has
approved real parts::
[tool.jitx.parts]
chain = ["csv:parts/approved-parts.csv", "ideal"]
By default the series follows the tolerance of the part it synthesizes: a part
is available at the values of every series whose nominal tolerance is at least
as loose as its own, so a 1% part snaps to E24 or E96 and a 5% part to E24
only. The synthesized tolerance is the one the query pins, else 1%.
Name a series explicitly as `"ideal:E96"` (or `IdealPassives(series="E96")`),
or join several with `+`, as in `"ideal:E24+E96"`, to snap to those regardless
of tolerance. Known series are E6, E12, E24, E48, E96 and E192.
Its parts are deliberately not orderable: `manufacturer` is None, which is the
signal that an identity is synthesized rather than purchasable. Drop it from the
chain and an unapproved value becomes an error naming what every provider said
about it.
It answers only a query whose value constraint pins one value -- `Exactly`, or a
`Tolerance` or `Range` with an unambiguous typical -- and declines anything else
per query rather than guessing at a value. Of the other keys it honors `case`,
`tolerance` and `polarized`; any key beyond those is ignored, so the part it
synthesizes says nothing about it. A case code with no landpattern is declined.
A generator rather than a catalogue, which makes it the smallest example of a
provider with no rows to search: nothing to filter, nothing to rank, and
`distinct` left at the default that says it does not enumerate.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from collections.abc import Callable
from typing import Any
from jitx.units import PlainQuantity, registry
from ..error import StalePayload
from ..provider import (
CanonicalQuery,
CategoryProvider,
NoMatch,
Outcome,
Resolved,
Unsupported,
apply_fields,
decode_payload,
encode_payload,
satisfies_or_true,
)
from ..record import JSON, PartIdentity, ResolvedPart
E24 = (
1.0,
1.1,
1.2,
1.3,
1.5,
1.6,
1.8,
2.0,
2.2,
2.4,
2.7,
3.0,
3.3,
3.6,
3.9,
4.3,
4.7,
5.1,
5.6,
6.2,
6.8,
7.5,
8.2,
9.1,
)
E96 = (
1.00, 1.02, 1.05, 1.07, 1.10, 1.13, 1.15, 1.18, 1.21, 1.24, 1.27, 1.30,
1.33, 1.37, 1.40, 1.43, 1.47, 1.50, 1.54, 1.58, 1.62, 1.65, 1.69, 1.74,
1.78, 1.82, 1.87, 1.91, 1.96, 2.00, 2.05, 2.10, 2.15, 2.21, 2.26, 2.32,
2.37, 2.43, 2.49, 2.55, 2.61, 2.67, 2.74, 2.80, 2.87, 2.94, 3.01, 3.09,
3.16, 3.24, 3.32, 3.40, 3.48, 3.57, 3.65, 3.74, 3.83, 3.92, 4.02, 4.12,
4.22, 4.32, 4.42, 4.53, 4.64, 4.75, 4.87, 4.99, 5.11, 5.23, 5.36, 5.49,
5.62, 5.76, 5.90, 6.04, 6.19, 6.34, 6.49, 6.65, 6.81, 6.98, 7.15, 7.32,
7.50, 7.68, 7.87, 8.06, 8.25, 8.45, 8.66, 8.87, 9.09, 9.31, 9.53, 9.76,
) # fmt: skip
E192 = (
1.00, 1.01, 1.02, 1.04, 1.05, 1.06, 1.07, 1.09, 1.10, 1.11, 1.13, 1.14,
1.15, 1.17, 1.18, 1.20, 1.21, 1.23, 1.24, 1.26, 1.27, 1.29, 1.30, 1.32,
1.33, 1.35, 1.37, 1.38, 1.40, 1.42, 1.43, 1.45, 1.47, 1.49, 1.50, 1.52,
1.54, 1.56, 1.58, 1.60, 1.62, 1.64, 1.65, 1.67, 1.69, 1.72, 1.74, 1.76,
1.78, 1.80, 1.82, 1.84, 1.87, 1.89, 1.91, 1.93, 1.96, 1.98, 2.00, 2.03,
2.05, 2.08, 2.10, 2.13, 2.15, 2.18, 2.21, 2.23, 2.26, 2.29, 2.32, 2.34,
2.37, 2.40, 2.43, 2.46, 2.49, 2.52, 2.55, 2.58, 2.61, 2.64, 2.67, 2.71,
2.74, 2.77, 2.80, 2.84, 2.87, 2.91, 2.94, 2.98, 3.01, 3.05, 3.09, 3.12,
3.16, 3.20, 3.24, 3.28, 3.32, 3.36, 3.40, 3.44, 3.48, 3.52, 3.57, 3.61,
3.65, 3.70, 3.74, 3.79, 3.83, 3.88, 3.92, 3.97, 4.02, 4.07, 4.12, 4.17,
4.22, 4.27, 4.32, 4.37, 4.42, 4.48, 4.53, 4.59, 4.64, 4.70, 4.75, 4.81,
4.87, 4.93, 4.99, 5.05, 5.11, 5.17, 5.23, 5.30, 5.36, 5.42, 5.49, 5.56,
5.62, 5.69, 5.76, 5.83, 5.90, 5.97, 6.04, 6.12, 6.19, 6.26, 6.34, 6.42,
6.49, 6.57, 6.65, 6.73, 6.81, 6.90, 6.98, 7.06, 7.15, 7.23, 7.32, 7.41,
7.50, 7.59, 7.68, 7.77, 7.87, 7.96, 8.06, 8.16, 8.25, 8.35, 8.45, 8.56,
8.66, 8.76, 8.87, 8.98, 9.09, 9.20, 9.31, 9.42, 9.53, 9.65, 9.76, 9.88,
) # fmt: skip
SERIES: dict[str, tuple[float, ...]] = {
"E6": E24[::4],
"E12": E24[::2],
"E24": E24,
"E48": E96[::2],
"E96": E96,
"E192": E192,
}
# Nominal tolerance of each series, the loosest part it is meant for.
_SERIES_TOLERANCE = {
"E6": 0.20,
"E12": 0.10,
"E24": 0.05,
"E48": 0.02,
"E96": 0.01,
"E192": 0.005,
}
AUTO = "auto"
[docs]
def series_for_tolerance(tolerance: float) -> tuple[float, ...]:
"""Mantissas a part of relative `tolerance` is available in.
The union of every series whose nominal tolerance is at least `tolerance`;
a tolerance looser than any series' gets E6.
"""
# Tighter parts are also sold at the coarser series' values, hence a union.
# Relative slack so 0.01 matches E96's 0.01 despite float noise.
names = [n for n, t in _SERIES_TOLERANCE.items() if t >= tolerance * (1 - 1e-9)]
return tuple(sorted({v for n in names or ["E6"] for v in SERIES[n]}))
_UNIT_FOR = {"resistor": "ohm", "capacitor": "F", "inductor": "H"}
DEFAULT_CASE = "0402"
DEFAULT_TOLERANCE = 0.01
[docs]
def nearest_e_series(value: float, series: tuple[float, ...] = E24) -> float:
"""The closest E-series value to `value`, preserving its decade."""
if value <= 0:
return value
import math
decade = math.floor(math.log10(value))
mantissa = value / (10.0**decade)
# 10 stands in for the next decade's first value, so 9.9 snaps up to 10
# rather than down to the series' last mantissa.
best = min((*series, 10.0), key=lambda s: abs(math.log10(s) - math.log10(mantissa)))
# Rounded to the series' own precision, so 4.99 * 10 is 49.9 and not
# 49.900000000000006, which would fail an exact-value query.
return float(f"{best * 10.0**decade:.3g}")
[docs]
@dataclass(frozen=True)
class IdealPart:
"""A synthesized part. Implements ResolvedPart."""
identity: PartIdentity
payload: dict[str, JSON]
[docs]
def materialize(self, component: Any) -> None:
from .csv_parts import _attribute_text
from .geometry import passive_geometry
# Off the identity, which carries all three already.
identity = self.identity
landpattern, symbol = passive_geometry(
identity.category,
_attribute_text(identity, "case"),
bool(identity.attributes.get("polarized")),
)
component.landpattern = landpattern
component.symbol = symbol
# Nothing to report today, since this provider synthesizes rather than
# looks anything up. Spelled the same way regardless, so a payload that
# grows a reported field needs no change here.
fields = decode_payload(self.payload)
apply_fields(component, {k: v for k, v in fields.items() if k != "v"})
[docs]
@dataclass(frozen=True)
class IdealPassives(CategoryProvider):
"""Synthesize an ideal passive at the requested value.
Only serves a query whose value constraint pins one value: `Exactly`, or a
`Tolerance`/`Range` whose typical is unambiguous. A query asking for "anything
between 1k and 10k" has no single answer to synthesize, so it is declined
rather than guessed at.
`series` is `"auto"` (the default) to pick values by the synthesized part's
tolerance, or names the E-series values snap to, several joined with `+`
(`"E24+E96"`). An unknown name raises ValueError.
"""
series: str = AUTO
name: str = field(init=False, default="ideal", compare=False)
# Last in a discovered chain: a real catalogue should always be asked first.
# Kept out of __init__ and out of __eq__, since it cannot change which part
# gets picked.
discovery_priority: int = field(init=False, default=-100, compare=False)
def __post_init__(self) -> None:
if self.series == AUTO:
return
unknown = [name for name in self.series.split("+") if name not in SERIES]
if unknown:
known = ", ".join(SERIES)
raise ValueError(
f"unknown E-series {', '.join(unknown)} in {self.series!r}; "
f"known: {known}, joined with '+', or {AUTO!r}"
)
def _values(self, tolerance: Any) -> tuple[float, ...]:
if self.series != AUTO:
return tuple(
sorted({v for name in self.series.split("+") for v in SERIES[name]})
)
if isinstance(tolerance, PlainQuantity):
tolerance = tolerance.m_as("dimensionless")
if not isinstance(tolerance, int | float) or tolerance <= 0:
tolerance = DEFAULT_TOLERANCE
return series_for_tolerance(float(tolerance))
[docs]
def config(self) -> dict[str, JSON]:
return {"series": self.series}
[docs]
def resolve_one(self, query: CanonicalQuery) -> Outcome:
from .geometry import known_case
key = query.value_key
if key is None:
return Unsupported((), "no category value to synthesize")
target = _target_value(query.filters.get(key))
if target is None:
return Unsupported(
(key,), f"{key} does not pin a single value to synthesize"
)
unit = _UNIT_FOR.get(query.category)
if unit is None:
return Unsupported((), f"cannot synthesize a {query.category}")
case = _pick(query.filters.get("case"), DEFAULT_CASE, known_case)
tolerance = _pick(query.filters.get("tolerance"), DEFAULT_TOLERANCE)
magnitude = nearest_e_series(
float(target.to(unit).magnitude), self._values(tolerance)
)
value = registry.Quantity(magnitude, unit)
polarized = _pick(query.filters.get("polarized"), None)
attributes: dict[str, Any] = {
key: value,
"case": case,
"tolerance": tolerance,
}
checks: list[tuple[str, Any]] = [
("case", case),
("tolerance", tolerance),
(key, value),
]
# Recorded only when asked for, so a plain part's identity is unchanged.
if polarized is not None:
if not isinstance(polarized, bool):
return Unsupported(("polarized",), f"polarized={polarized!r}")
if polarized and query.category != "capacitor":
return Unsupported(
("polarized",), f"cannot synthesize a polarized {query.category}"
)
attributes["polarized"] = polarized
checks.append(("polarized", polarized))
for name, expected in checks:
if not satisfies_or_true(query.filters.get(name), expected, name):
return NoMatch(f"synthesized {name}={expected!r} out of range")
if not isinstance(case, str) or not known_case(case):
return NoMatch(f"no landpattern for case {case!r}")
return Resolved(
(
IdealPart(
identity=PartIdentity(
# value is a quantity, so format the magnitude in the
# attribute's base unit: an f-string on the quantity
# itself would put "10000 ohm" inside the MPN, space and
# all.
mpn=f"IDEAL-{query.category[:1].upper()}"
f"-{value.to_base_units().magnitude:g}-{case}"
# Same value and case, different footprint.
f"{'-P' if polarized else ''}",
manufacturer=None, # unqualified: not an orderable part
category=query.category,
provider=self.name,
attributes=attributes,
),
# Nothing but the version: everything this part needs in
# order to rebuild itself is in the identity.
payload=encode_payload({"v": 1}),
),
)
)
[docs]
def rehydrate(
self, identity: PartIdentity, payload: dict[str, JSON]
) -> ResolvedPart:
if payload.get("v") != 1:
raise StalePayload(
f"{self.name}: unknown payload version {payload.get('v')!r}"
)
from .csv_parts import _attribute_text
from .geometry import known_case
# A lock entry is hand-editable; refuse what resolve would have declined
# so the query is resolved again rather than failing in materialize.
case = _attribute_text(identity, "case")
if not known_case(case):
raise StalePayload(f"{self.name}: no landpattern for case {case!r}")
return IdealPart(identity=identity, payload=dict(payload))
def _target_value(predicate: Any) -> PlainQuantity | None:
"""The single value a predicate pins, or None if it pins none."""
from ..query import Exactly, Range, Tolerance
if isinstance(predicate, Exactly):
return predicate.value if isinstance(predicate.value, PlainQuantity) else None
if isinstance(predicate, Tolerance):
return predicate.typ if isinstance(predicate.typ, PlainQuantity) else None
if isinstance(predicate, Range):
if predicate.min is not None and predicate.min == predicate.max:
return predicate.min if isinstance(predicate.min, PlainQuantity) else None
return None
def _pick(
predicate: Any, default: Any, usable: Callable[[Any], bool] = lambda _: True
) -> Any:
"""The value a predicate asks for, or `default` when it does not pin one.
Of a `OneOf`, the first value `usable` accepts, else its first value.
"""
from ..query import Exactly, OneOf
if isinstance(predicate, Exactly):
return predicate.value
if isinstance(predicate, OneOf) and predicate.values:
return next((v for v in predicate.values if usable(v)), predicate.values[0])
return default