"""
Crystal symbols for JITX Standard Library
This module provides crystal/resonator symbol definitions with optional case pins.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, cast
from jitx.shapes import Shape
from jitx.shapes.primitive import Polyline
from jitx.symbol import Direction, Pin
from jitx.units import percent, Quantity
from ..label import LabelConfigurable, LabelledSymbol
if TYPE_CHECKING:
from ..context import SymbolStyleContext
# Crystal constants
DEF_CRYSTAL_PITCH = 2.0
DEF_CRYSTAL_LINE_WIDTH = 0.05
DEF_RESONATOR_LINE_LEN = 100 * percent # 100% of body width
DEF_RESONATOR_LINE_OFFSET = 0.1
DEF_CRYSTAL_BODY = (0.9, 0.3) # (width, height)
def _resolve_percentage(value: float | Quantity, reference: float) -> float:
"""Resolve a value that could be absolute or percentage."""
if isinstance(value, Quantity):
if str(value.units) == "percent":
return value.magnitude * reference / 100.0
return float(value.magnitude)
return float(value)
[docs]
@dataclass
class CrystalConfig(LabelConfigurable):
"""
Configuration for crystal symbols
Defines the geometric and visual parameters for crystal/resonator symbols.
"""
pitch: float = DEF_CRYSTAL_PITCH
"""Distance between pin points"""
line_width: float = DEF_CRYSTAL_LINE_WIDTH
"""Width of the crystal lines"""
resonator_line_len: float = DEF_RESONATOR_LINE_LEN
"""Length of resonator lines (absolute or percentage of body width)"""
resonator_line_offset: float = DEF_RESONATOR_LINE_OFFSET
"""Offset from body to resonator lines"""
crystal_body: tuple[float, float] = DEF_CRYSTAL_BODY
"""Body dimensions as (width, height)"""
crystal_case: tuple[float, float] | None = None
"""Optional case dimensions as (width, height), None for no case"""
case_ports: int = 0
"""Number of case connection pins (0 for none)"""
[docs]
class CrystalSymbol[T: CrystalConfig](LabelledSymbol):
"""
Crystal/resonator symbol with graphics and pins.
The crystal symbol consists of a rectangular body with resonator lines
on either side, and optional case outline with case pins.
Pins: 'p[1]', 'p[2]' (non-polarized), optional 'case[n]' pins
"""
config: T
front_porch: Shape[Polyline]
back_porch: Shape[Polyline]
resonator_line_top: Shape[Polyline]
resonator_line_bottom: Shape[Polyline]
body: Shape[Polyline]
case_outline: tuple[Shape[Polyline], Shape[Polyline]] | None
p: dict[int, Pin]
case: dict[int, Pin] | None
def _symbol_style_config(self, context: SymbolStyleContext | None = None) -> T:
"""Symbol style config for this crystal symbol."""
if context is None:
config = CrystalConfig()
else:
config = context.crystal_config
return cast(T, config)
def _lookup_config(
self, config: T | None = None, context: SymbolStyleContext | None = None
) -> T:
"""Lookup the config for this symbol."""
if config is None:
return self._symbol_style_config(context)
return config
def __init__(self, config: T | None = None, **kwargs):
"""
Initialize crystal symbol.
Args:
config: Config object, or None to use defaults
**kwargs: Individual parameters to override defaults
"""
from ..context import SymbolStyleContext
context = SymbolStyleContext.get()
config = self._lookup_config(config, context)
self.config = replace(config, **kwargs)
self._build_crystal_glyphs()
self._build_pins()
self._build_labels(ref=Direction.Right, value=Direction.Right)
def _build_crystal_glyphs(self) -> None:
"""Build the crystal body, resonator lines, and porch lines."""
p2 = self.pitch / 2.0
body_w, body_h = self.crystal_body
body_w2 = body_w / 2.0
body_h2 = body_h / 2.0
# Resolve resonator line length
res_len = _resolve_percentage(self.resonator_line_len, body_w)
res_len_2 = res_len / 2.0
# Calculate porch end (where resonator line starts)
porch_end = body_h2 + self.resonator_line_offset
# Validate that total width fits within pitch
total_width = (2 * self.resonator_line_offset) + self.line_width + body_h
if total_width >= self.pitch:
raise ValueError(
f"Crystal body and resonator lines ({total_width}) exceed pitch ({self.pitch})"
)
# Front porch (top side)
self.front_porch = Polyline(self.line_width, [(0.0, p2), (0.0, porch_end)])
# Top resonator line
self.resonator_line_top = Polyline(
self.line_width, [(-res_len_2, porch_end), (res_len_2, porch_end)]
)
# Crystal body (rectangle outline)
body_pts = [
(-body_w2, body_h2),
(body_w2, body_h2),
(body_w2, -body_h2),
(-body_w2, -body_h2),
(-body_w2, body_h2),
]
self.body = Polyline(self.line_width, body_pts)
# Bottom resonator line
self.resonator_line_bottom = Polyline(
self.line_width, [(-res_len_2, -porch_end), (res_len_2, -porch_end)]
)
# Back porch (bottom side)
self.back_porch = Polyline(self.line_width, [(0.0, -porch_end), (0.0, -p2)])
# Optional case outline
if self.crystal_case is not None:
self._build_case_outline()
else:
self.case_outline = None
def _build_case_outline(self) -> None:
"""Build the optional case outline (two vertical C-shaped lines)."""
if self.crystal_case is None:
self.case_outline = None
return
case_w, case_h = self.crystal_case
case_w2 = case_w / 2.0
case_h2 = case_h / 2.0
body_w2 = self.crystal_body[0] / 2.0
# Calculate case position (outside the body)
case_x = body_w2 + self.resonator_line_offset + (case_w / 2.0)
# Left case line (C-shape facing right)
left_case_pts = [
(-case_x, case_h2),
(-case_x - case_w2, case_h2),
(-case_x - case_w2, -case_h2),
(-case_x, -case_h2),
]
# Right case line (C-shape facing left)
right_case_pts = [
(case_x, case_h2),
(case_x + case_w2, case_h2),
(case_x + case_w2, -case_h2),
(case_x, -case_h2),
]
self.case_outline = (
Polyline(self.line_width, left_case_pts),
Polyline(self.line_width, right_case_pts),
)
def _build_pins(self) -> None:
"""Build 'p[1]', 'p[2]' pins and optional case pins."""
w = math.floor(self.pitch / 2)
self.p = {
1: Pin(at=(0, w), direction=Direction.Up),
2: Pin(at=(0, -w), direction=Direction.Down),
}
# Build case pins if specified
if self.case_ports > 0:
self._build_case_pins()
else:
self.case = None
def _build_case_pins(self) -> None:
"""Build case pins on the left side."""
if self.case_ports <= 0:
self.case = None
return
body_w2 = self.crystal_body[0] / 2.0
case_x = -(body_w2 + 1.0) # Position to the left of body
if self.case_ports == 1:
# Single case pin at center
self.case = {1: Pin(at=(math.floor(case_x), 0), direction=Direction.Left)}
else:
# Multiple case pins distributed vertically
pin_pitch = 1.0
pin_span = (self.case_ports - 1) * pin_pitch
start_y = pin_span / 2.0
self.case = {}
for i in range(self.case_ports):
y = start_y - (i * pin_pitch)
self.case[i + 1] = Pin(
at=(math.floor(case_x), math.floor(y)), direction=Direction.Left
)
@property
def pitch(self) -> float:
"""See :attr:`~.CrystalConfig.pitch`."""
return self.config.pitch
@property
def line_width(self) -> float:
"""See :attr:`~.CrystalConfig.line_width`."""
return self.config.line_width
@property
def resonator_line_len(self) -> float:
"""See :attr:`~.CrystalConfig.resonator_line_len`."""
return self.config.resonator_line_len
@property
def resonator_line_offset(self) -> float:
"""See :attr:`~.CrystalConfig.resonator_line_offset`."""
return self.config.resonator_line_offset
@property
def crystal_body(self) -> tuple[float, float]:
"""See :attr:`~.CrystalConfig.crystal_body`."""
return self.config.crystal_body
@property
def crystal_case(self) -> tuple[float, float] | None:
"""See :attr:`~.CrystalConfig.crystal_case`."""
return self.config.crystal_case
@property
def case_ports(self) -> int:
"""See :attr:`~.CrystalConfig.case_ports`."""
return self.config.case_ports
@property
def label_config(self) -> LabelConfigurable:
"""Configuration object that provides label configuration"""
return self.config