Skip to content

Commit 24e13c5

Browse files
implement first component renderer classes - Resistors
1 parent b56db42 commit 24e13c5

12 files changed

+233
-112
lines changed

schemascii/annoline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]:
8585
seen_points.update(line.points)
8686
return all_lines
8787

88-
def render(self, data, **options) -> str:
88+
def render(self, **options) -> str:
8989
# copy-pasted from wire.py except class changed at bottom
9090
# create lines for all of the neighbor pairs
9191
links = []

schemascii/annotation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class Annotation(_dc.DataConsumer, namespaces=(":annotation",)):
1515

1616
options = [
1717
("scale",),
18-
_dc.Option("font", str, "Text font", "monospace"),
18+
_dc.Option("font", str, "Text font", "sans-serif"),
1919
]
2020

2121
position: complex
@@ -34,7 +34,7 @@ def find_all(cls, grid: _grid.Grid):
3434
out.append(cls(complex(x, y), text))
3535
return out
3636

37-
def render(self, data, scale, font) -> str:
37+
def render(self, scale, font, **options) -> str:
3838
return _utils.XML.text(
3939
html.escape(self.content),
4040
x=self.position.real * scale,

schemascii/component.py

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@
1717
class Component(_dc.DataConsumer, namespaces=(":component",)):
1818
"""An icon representing a single electronic component."""
1919
all_components: typing.ClassVar[dict[str, type[Component]]] = {}
20-
human_name: typing.ClassVar[str] = ""
20+
21+
options = [
22+
"inherit",
23+
_dc.Option("offset_scale", float,
24+
"How far to offset the label from the center of the "
25+
"component. Relative to the global scale option.", 1),
26+
_dc.Option("font", str, "Text font for labels", "monospace"),
27+
28+
]
2129

2230
rd: _rd.RefDes
2331
blobs: list[list[complex]] # to support multiple parts.
@@ -113,10 +121,13 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component:
113121
# done
114122
return cls(rd, blobs, terminals)
115123

116-
def __init_subclass__(
117-
cls, ids: list[str], id_letters: str | None = None, **kwargs):
124+
def __init_subclass__(cls, ids: tuple[str, ...] = None,
125+
namespaces: tuple[str, ...] = None, **kwargs):
118126
"""Register the component subclass in the component registry."""
119-
super().__init_subclass__(**kwargs)
127+
super().__init_subclass__(namespaces=(namespaces or ()), **kwargs)
128+
if not ids:
129+
# allow anonymous helper classes
130+
return
120131
for id_letters in ids:
121132
if not (id_letters.isalpha() and id_letters.upper() == id_letters):
122133
raise ValueError(
@@ -125,7 +136,6 @@ def __init_subclass__(
125136
raise ValueError(
126137
f"duplicate reference designator letters: {id_letters!r}")
127138
cls.all_components[id_letters] = cls
128-
cls.human_name = id_letters or cls.__name__
129139

130140
@property
131141
def css_class(self) -> str:
@@ -134,25 +144,34 @@ def css_class(self) -> str:
134144
@classmethod
135145
def process_nets(self, nets: list[_net.Net]):
136146
"""Hook method called to do stuff with the nets that this
137-
component type connects to. By itself it does nothing.
147+
component type connects to. By default it does nothing.
138148
139149
If a subclass implements this method to do something, it should
140-
mutate the list in-place and return None.
150+
mutate the list in-place (the return value is ignored).
141151
"""
142152
pass
143153

154+
def get_terminals(
155+
self, *flags_names: str) -> list[_utils.Terminal]:
156+
"""Return the component's terminals sorted so that the terminals with
157+
the specified flags appear first in the order specified and the
158+
remaining terminals come after.
144159
145-
if __name__ == '__main__':
146-
class FooComponent(Component, ids=["U", "FOO"]):
147-
pass
148-
print(Component.all_components)
149-
testgrid = _grid.Grid("test_data/stresstest.txt")
150-
# this will erroneously search the DATA section too but that's OK
151-
# for this test
152-
for rd in _rd.RefDes.find_all(testgrid):
153-
c = Component.from_rd(rd, testgrid)
154-
print(c)
155-
for blob in c.blobs:
156-
testgrid.spark(*blob)
157-
testgrid.spark(*(t.pt for t in c.terminals))
158-
Component(None, None, None)
160+
Raises an error if a terminal with the specified flag could not be
161+
found, or there were multiple terminals with the requested flag
162+
(ambiguous).
163+
"""
164+
out = []
165+
for flag in flags_names:
166+
matching_terminals = [t for t in self.terminals if t.flag == flag]
167+
if len(matching_terminals) > 1:
168+
raise _errors.TerminalsError(
169+
f"{self.rd.name}: multiple terminals with the "
170+
f"same flag {flag!r}")
171+
if len(matching_terminals) == 0:
172+
raise _errors.TerminalsError(
173+
f"{self.rd.name}: need a terminal with flag {flag!r}")
174+
out.append(matching_terminals[0])
175+
out.extend(t for t in self.terminals if t.flag not in flags_names)
176+
assert set(self.terminals) == set(out)
177+
return out

schemascii/components/__init__.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import typing
2+
from dataclasses import dataclass
3+
4+
import schemascii.component as _c
5+
import schemascii.errors as _errors
6+
7+
# TODO: import all of the component subclasses to register them
8+
9+
10+
@dataclass
11+
class NTerminalComponent(_c.Component):
12+
"""Represents a component that only ever has N terminals.
13+
14+
The class must have an attribute n_terminals with the number
15+
of terminals."""
16+
n_terminals: typing.ClassVar[int]
17+
18+
def __post_init__(self):
19+
if self.n_terminals != len(self.terminals):
20+
raise _errors.TerminalsError(
21+
f"{self.rd.name}: can only have {self.n_terminals} terminals "
22+
f"(found {len(self.terminals)})")
23+
24+
25+
class TwoTerminalComponent(NTerminalComponent):
26+
"""Shortcut to define a component with two terminals."""
27+
n_terminals: typing.Final = 2
28+
29+
30+
@dataclass
31+
class PolarizedTwoTerminalComponent(TwoTerminalComponent):
32+
"""Helper class that ensures that a component has only two terminals,
33+
and if provided, sorts the terminals so that the "+" terminal comes
34+
first in the list.
35+
"""
36+
37+
always_polarized: typing.ClassVar[bool] = False
38+
39+
def __post_init__(self):
40+
super().__post_init__() # check count of terminals
41+
num_plus = sum(t.flag == "+" for t in self.terminals)
42+
if (self.always_polarized and num_plus != 1) or num_plus > 1:
43+
raise _errors.TerminalsError(
44+
f"{self.rd.name}: need '+' on only one terminal to indicate "
45+
"polarization")
46+
if self.terminals[1].flag == "+":
47+
# swap first and last
48+
self.terminals.insert(0, self.terminals.pop(-1))

schemascii/components/resistor.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from cmath import phase, pi, rect
2+
3+
import schemascii.components as _c
4+
import schemascii.data_consumer as _dc
5+
import schemascii.utils as _utils
6+
import schemascii.errors as _errors
7+
8+
# TODO: IEC rectangular symbol
9+
# see here: https://eepower.com/resistor-guide/resistor-standards-and-codes/resistor-symbols/ # noqa: E501
10+
11+
12+
def _ansi_resistor_squiggle(t1: complex, t2: complex) -> list[complex]:
13+
vec = t1 - t2
14+
length = abs(vec)
15+
angle = phase(vec)
16+
quad_angle = angle + pi / 2
17+
points = [t1]
18+
for i in range(1, 4 * int(length)):
19+
points.append(t1 - rect(i / 4, angle)
20+
+ (rect(1/4, quad_angle) * pow(-1, i)))
21+
points.append(t2)
22+
return points
23+
24+
25+
class Resistor(_c.TwoTerminalComponent, ids=("R",), namespaces=(":resistor",)):
26+
options = [
27+
"inherit",
28+
_dc.Option("value", str, "Resistance in ohms"),
29+
_dc.Option("power", str, "Maximum power dissipation in watts "
30+
"(i.e. size of the resistor)", None)
31+
]
32+
33+
is_variable = False
34+
35+
def render(self, value: str, power: str, **options) -> str:
36+
t1, t2 = self.terminals[0].pt, self.terminals[1].pt
37+
points = _ansi_resistor_squiggle(t1, t2)
38+
try:
39+
id_text = _utils.id_text(self.rd.name, self.terminals,
40+
((value, "Ω", False, self.is_variable),
41+
(power, "W", False)),
42+
_utils.make_text_point(t1, t2, **options),
43+
**options)
44+
except ValueError as e:
45+
raise _errors.BOMError(
46+
f"{self.rd.name}: Range of values not allowed "
47+
"on fixed resistor") from e
48+
return _utils.polylinegon(points, **options) + id_text
49+
50+
51+
class VariableResistor(Resistor, ids=("VR", "RV")):
52+
is_variable = True
53+
54+
def render(self, **options):
55+
t1, t2 = self.terminals[0].pt, self.terminals[1].pt
56+
return (super().render(**options)
57+
+ _utils.make_variable(
58+
(t1 + t2) / 2, phase(t1 - t2), **options))
59+
60+
# TODO: potentiometers
61+
62+
63+
if __name__ == "__main__":
64+
print(Resistor.all_components)

schemascii/components_render.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -100,38 +100,6 @@ def sort_terminals(
100100
return sort_terminals
101101

102102

103-
@component("R", "RV", "VR")
104-
@n_terminal(2)
105-
@no_ambiguous
106-
def resistor(box: Cbox, terminals: list[Terminal],
107-
bom_data: BOMData, **options):
108-
"""Resistor, Variable resistor, etc.
109-
bom:ohms[,watts]"""
110-
t1, t2 = terminals[0].pt, terminals[1].pt
111-
vec = t1 - t2
112-
mid = (t1 + t2) / 2
113-
length = abs(vec)
114-
angle = phase(vec)
115-
quad_angle = angle + pi / 2
116-
points = [t1]
117-
for i in range(1, 4 * int(length)):
118-
points.append(t1 - rect(i / 4, angle) + pow(-1, i)
119-
* rect(1, quad_angle) / 4)
120-
points.append(t2)
121-
return (
122-
polylinegon(points, **options)
123-
+ make_variable(mid, angle, "V" in box.type, **options)
124-
+ id_text(
125-
box,
126-
bom_data,
127-
terminals,
128-
(("Ω", False), ("W", False)),
129-
make_text_point(t1, t2, **options),
130-
**options,
131-
)
132-
)
133-
134-
135103
@component("C", "CV", "VC")
136104
@n_terminal(2)
137105
@no_ambiguous

schemascii/data_consumer.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import abc
44
import typing
5+
import warnings
56
from dataclasses import dataclass
67

78
import schemascii.data as _data
@@ -29,7 +30,7 @@ class DataConsumer(abc.ABC):
2930
to be rendered. This class registers the options that the class
3031
declares with Data so that they can be checked, automatically pulls
3132
the needed options when to_xml_string() is called, and passes the dict of
32-
options to render().
33+
options to render() as keyword arguments.
3334
"""
3435

3536
options: typing.ClassVar[list[Option
@@ -40,14 +41,25 @@ class DataConsumer(abc.ABC):
4041
Option("linewidth", float, "Width of drawn lines", 2),
4142
Option("color", str, "black", "Default color for everything"),
4243
]
43-
namepaces: tuple[str, ...]
4444
css_class: typing.ClassVar[str] = ""
4545

46-
def __init_subclass__(cls, namespaces: tuple[str, ...] = ("*",)):
46+
@property
47+
def namespaces(self) -> tuple[str, ...]:
48+
# base case to stop recursion
49+
return ()
50+
51+
def __init_subclass__(cls, namespaces: tuple[str, ...] = None):
52+
53+
if not namespaces:
54+
# allow anonymous helper subclasses
55+
return
4756

4857
if not hasattr(cls, "namespaces"):
49-
# don't clobber it if a subclass defines it as a @property!
50-
cls.namespaces = namespaces
58+
# don't clobber it if a subclass already overrides it!
59+
@property
60+
def __namespaces(self) -> tuple[str, ...]:
61+
return super(type(self), self).namespaces + namespaces
62+
cls.namepaces = __namespaces
5163

5264
for b in cls.mro():
5365
if (b is not cls
@@ -97,14 +109,14 @@ def to_xml_string(self, data: _data.Data) -> str:
97109
if opt.name not in values:
98110
if opt.default is _NOT_SET:
99111
raise _errors.NoDataError(
100-
f"value for {self.namespaces[0]}.{name} is required")
112+
f"missing value for {self.namespaces[0]}.{name}")
101113
values[opt.name] = opt.default
102114
continue
103115
if isinstance(opt.type, list):
104116
if values[opt.name] not in opt.type:
105-
raise _errors.DataTypeError(
106-
f"option {self.namespaces[0]}.{opt.name}: "
107-
f"invalid choice: {values[opt.name]} "
117+
raise _errors.BOMError(
118+
f"{self.namespaces[0]}.{opt.name}: "
119+
f"invalid choice: {values[opt.name]!r} "
108120
f"(valid options are "
109121
f"{', '.join(map(repr, opt.type))})")
110122
continue
@@ -114,7 +126,13 @@ def to_xml_string(self, data: _data.Data) -> str:
114126
raise _errors.DataTypeError(
115127
f"option {self.namespaces[0]}.{opt.name}: "
116128
f"invalid {opt.type.__name__} value: "
117-
f"{values[opt.name]}") from err
129+
f"{values[opt.name]!r}") from err
130+
for key in values:
131+
if any(opt.name == key for opt in self.options):
132+
continue
133+
warnings.warn(
134+
f"unknown data key {key!r} for styling {self.namespaces[0]}",
135+
stacklevel=2)
118136
# render
119137
result = self.render(**values, data=data)
120138
if self.css_class:

schemascii/drawing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def to_xml_string(
8383
data |= fudge
8484
return super().to_xml_string(data)
8585

86-
def render(self, data, scale: float, padding: float) -> str:
86+
def render(self, data, scale: float, padding: float, **options) -> str:
8787
# render everything
8888
content = _utils.XML.g(
8989
_utils.XML.g(

schemascii/errors.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class DiagramSyntaxError(SyntaxError, Error):
66
"""Bad formatting in Schemascii diagram syntax."""
77

88

9-
class TerminalsError(TypeError, Error):
9+
class TerminalsError(ValueError, Error):
1010
"""Incorrect usage of terminals on this component."""
1111

1212

@@ -22,5 +22,5 @@ class NoDataError(NameError, Error):
2222
"""Data item is required, but not present."""
2323

2424

25-
class DataTypeError(ValueError, Error):
26-
"""Invalid data value."""
25+
class DataTypeError(TypeError, Error):
26+
"""Invalid data type in data section."""

schemascii/metric.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ def format_metric_unit(
9494
num: str,
9595
unit: str = "",
9696
six: bool = False,
97-
unicode: bool = True) -> str:
97+
unicode: bool = True,
98+
allow_range: bool = True) -> str:
9899
"""Normalizes the Metric multiplier on the number, then adds the unit.
99100
100101
* If there is a suffix on num, moves it to after the unit.
@@ -106,6 +107,8 @@ def format_metric_unit(
106107
num = num.strip()
107108
match = METRIC_RANGE.match(num)
108109
if match:
110+
if not allow_range:
111+
raise ValueError("range not allowed")
109112
# format the range by calling recursively
110113
num0, num1 = match.group(1), match.group(2)
111114
suffix = num[match.span()[1]:]

0 commit comments

Comments
 (0)