"""
BJT transistor symbols for JITX Standard Library
This module provides BJT (Bipolar Junction Transistor) symbol definitions
for NPN and PNP transistors.
"""
from __future__ import annotations
import math
from enum import Enum
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, cast
from jitx.shapes import Shape
from jitx.shapes.primitive import Arc, ArcPolyline, Polyline
from jitx.symbol import Direction, Pin
from ..arrow import Arrow, ArrowConfig, ArrowConfigurable
from ..label import LabelConfigurable, LabelledSymbol
if TYPE_CHECKING:
from ..context import SymbolStyleContext
# BJT constants
DEF_BJT_PITCH = 3.0 # Collector to emitter distance
DEF_BJT_WIDTH = 2.0 # Distance from C/E line to base pin
DEF_BJT_PORCH_WIDTH = 0.25
DEF_BJT_BASE_LINE = 2.3
DEF_BJT_OUTLINE = True
DEF_BJT_LINE_WIDTH = 0.1
DEF_BJT_PIN_LENGTH = 2.0
DEF_BJT_PAD_REF_SIZE = 0.75
[docs]
class BJTJunction(Enum):
"""
BJT junction types
Defines the junction type for bipolar junction transistors.
"""
NPN = "npn"
PNP = "pnp"
[docs]
@dataclass
class BJTConfig(LabelConfigurable, ArrowConfigurable):
"""
Configuration for BJT symbols
Defines the geometric and visual parameters for BJT transistor symbols.
"""
pitch: float = DEF_BJT_PITCH
"""Distance between collector and emitter pins"""
width: float = DEF_BJT_WIDTH
"""Distance from collector/emitter line to base pin"""
porch_width: float = DEF_BJT_PORCH_WIDTH
"""Length of porch lines from pins to angled lines"""
base_line_length: float = DEF_BJT_BASE_LINE
"""Length of the vertical base line"""
outline: bool = DEF_BJT_OUTLINE
"""Whether to draw circle outline around transistor"""
line_width: float = DEF_BJT_LINE_WIDTH
"""Width of symbol lines"""
def _compute_circle_from_points(
p1: tuple[float, float],
p2: tuple[float, float],
p3: tuple[float, float],
) -> tuple[tuple[float, float], float]:
"""
Compute center and radius of circle passing through 3 points.
Uses the circumcircle formula.
"""
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
# Calculate the determinant
A = x1 * (y2 - y3) - y1 * (x2 - x3) + x2 * y3 - x3 * y2
if abs(A) < 1e-10:
raise ValueError("Points are collinear, cannot compute circle")
# Calculate center coordinates
B = (
(x1 * x1 + y1 * y1) * (y3 - y2)
+ (x2 * x2 + y2 * y2) * (y1 - y3)
+ (x3 * x3 + y3 * y3) * (y2 - y1)
)
C = (
(x1 * x1 + y1 * y1) * (x2 - x3)
+ (x2 * x2 + y2 * y2) * (x3 - x1)
+ (x3 * x3 + y3 * y3) * (x1 - x2)
)
cx = -B / (2 * A)
cy = -C / (2 * A)
# Calculate radius
radius = math.sqrt((cx - x1) ** 2 + (cy - y1) ** 2)
return ((cx, cy), radius)
[docs]
class BJTSymbol[T: BJTConfig](LabelledSymbol):
"""
BJT transistor symbol with graphics and pins.
The BJT symbol consists of:
- Emitter line with arrow (direction indicates NPN/PNP)
- Collector line
- Base line (vertical)
- Optional circle outline
Pins: 'B' (base), 'C' (collector), 'E' (emitter)
"""
config: T
junction_type: BJTJunction
emitter_arrow: Arrow
collector_line: Shape[Polyline]
top_porch: Shape[Polyline]
bottom_porch: Shape[Polyline]
base_line: Shape[Polyline]
base_conn: Shape[Polyline]
outline_circle: Shape[ArcPolyline] | None
B: Pin # Base
C: Pin # Collector
E: Pin # Emitter
def _symbol_style_config(self, context: SymbolStyleContext | None = None) -> T:
"""Symbol style config for this BJT symbol."""
if context is None:
config = BJTConfig()
else:
config = context.bjt_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,
junction_type: BJTJunction = BJTJunction.NPN,
config: T | None = None,
**kwargs,
):
"""
Initialize BJT symbol.
Args:
junction_type: NPN or PNP transistor type
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.junction_type = junction_type
self._build_bjt_glyphs()
self._build_pins()
self._build_labels(ref=Direction.Right, value=Direction.Right)
def _build_bjt_glyphs(self) -> None:
"""Build the BJT transistor glyphs following Stanza implementation exactly."""
p2 = self.pitch / 2.0
porch = self.porch_width
base_len = self.base_line_length
lw = self.line_width
# Get arrow configuration (Stanza line 113)
arrow_config = self.config.get_arrow_config()
if arrow_config is None:
from ..arrow import ArrowStyle
arrow_config = ArrowConfig(
style=ArrowStyle.OPEN_ARROW,
shaft_length=1.0,
head_dims=(0.3, 0.5),
line_width=lw,
)
# Key measurements (Stanza lines 117-120)
a_offset = p2 - porch
a_len = arrow_config.shaft_length
angle = 45.0
angle_rad = math.radians(angle)
# Construct emitter arrow using Transform (Stanza lines 121-128)
# Arrow is constructed from (0,0) to (a_len, 0), then rotated and translated
if self.junction_type == BJTJunction.NPN:
# loc(0.0, -a_offset) * loc(0.0, 0.0, 180-angle) * arrow
# Arrow at (0, -a_offset), rotated 135°
self.emitter_arrow = Arrow((0.0, -a_offset), 180.0 - angle, arrow_config)
else:
# PNP needs offset adjustment due to rotation
# adjust = loc(-(a_len*sin(angle_rad)), -(a_len*cos(angle_rad)))
# adjust * loc(0.0, a_offset) * loc(0.0, 0.0, angle) * arrow
offset_x = -(a_len * math.sin(angle_rad))
offset_y = -(a_len * math.cos(angle_rad))
self.emitter_arrow = Arrow(
(offset_x, a_offset + offset_y), angle, arrow_config
)
# Construct collector line using Transform (Stanza lines 131-138)
# Line from (0,0) to (a_len, 0), rotated and translated
if self.junction_type == BJTJunction.NPN:
# loc(0.0, a_offset) * loc(0.0, 0.0, -(180-angle)) * line-c
# Line at (0, a_offset), rotated -135°
c_angle_rad = math.radians(-(180.0 - angle))
end_x = a_len * math.cos(c_angle_rad)
end_y = a_len * math.sin(c_angle_rad)
self.collector_line = Polyline(
lw, [(0.0, a_offset), (end_x, a_offset + end_y)]
)
else:
# loc(0.0, -a_offset) * loc(0.0, 0.0, 180-angle) * line-c
# Line at (0, -a_offset), rotated 135°
c_angle_rad = math.radians(180.0 - angle)
end_x = a_len * math.cos(c_angle_rad)
end_y = a_len * math.sin(c_angle_rad)
self.collector_line = Polyline(
lw, [(0.0, -a_offset), (end_x, -a_offset + end_y)]
)
# Construct porches (Stanza lines 140-150)
# Extend porches beyond pins to ensure visual connection
porch_extension = 0.4
# Top porch: from porch endpoint up to pin (and slightly beyond)
self.top_porch = Polyline(lw, [(0.0, p2 - porch), (0.0, p2 + porch_extension)])
# Bottom porch: from porch endpoint down to pin (and slightly beyond)
self.bottom_porch = Polyline(
lw, [(0.0, -p2 + porch), (0.0, -p2 - porch_extension)]
)
# Base line (Stanza lines 153-155)
base_x = -(a_len * math.cos(angle_rad))
self.base_line = Polyline(
lw, [(base_x, base_len / 2.0), (base_x, -base_len / 2.0)]
)
# Base connection (Stanza line 158)
self.base_conn = Polyline(lw, [(base_x, 0.0), (-self.width, 0.0)])
# Optional outline circle
if self.outline:
self._build_outline_circle()
else:
self.outline_circle = None
def _build_outline_circle(self) -> None:
"""Build the optional circle outline around the transistor.
Uses a centered circle that encompasses the symbol bounds.
"""
p2 = self.pitch / 2.0
# Center point: midway between base pin and drain/source pins
center_x = -self.width / 2.0
center_y = 0.0
# Radius to farthest pin
radius_to_base = abs(center_x - (-self.width))
radius_to_ce = math.sqrt(center_x**2 + p2**2)
radius = max(radius_to_base, radius_to_ce)
# Add margin for visual clearance
radius *= 1.2
# Create circle with Arc at the calculated center position
self.outline_circle = ArcPolyline(
self.line_width, [Arc((center_x, center_y), radius, 0.0, 360.0)]
)
def _build_pins(self) -> None:
"""Build 'B', 'C', 'E' symbol pins."""
p2 = self.pitch / 2.0
# Pin positions using exact float values to match porch geometry
# GridPoint type hint expects int, but JITX accepts float (like Stanza)
self.B = Pin(at=(-self.width, 0), direction=Direction.Left) # type: ignore
if self.junction_type == BJTJunction.NPN:
# NPN: Collector at top, Emitter at bottom
self.C = Pin(at=(0, p2), direction=Direction.Up) # type: ignore
self.E = Pin(at=(0, -p2), direction=Direction.Down) # type: ignore
else:
# PNP: Emitter at top, Collector at bottom
self.E = Pin(at=(0, p2), direction=Direction.Up) # type: ignore
self.C = Pin(at=(0, -p2), direction=Direction.Down) # type: ignore
@property
def pitch(self) -> float:
"""See :attr:`~.BJTConfig.pitch`."""
return self.config.pitch
@property
def width(self) -> float:
"""See :attr:`~.BJTConfig.width`."""
return self.config.width
@property
def porch_width(self) -> float:
"""See :attr:`~.BJTConfig.porch_width`."""
return self.config.porch_width
@property
def base_line_length(self) -> float:
"""See :attr:`~.BJTConfig.base_line_length`."""
return self.config.base_line_length
@property
def outline(self) -> bool:
"""See :attr:`~.BJTConfig.outline`."""
return self.config.outline
@property
def line_width(self) -> float:
"""See :attr:`~.BJTConfig.line_width`."""
return self.config.line_width
@property
def label_config(self) -> LabelConfigurable:
"""Configuration object that provides label configuration"""
return self.config