thermodraw 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thermodraw/__init__.py +55 -0
- thermodraw/__main__.py +262 -0
- thermodraw/_check.py +1198 -0
- thermodraw/_describe.py +424 -0
- thermodraw/_layout.py +561 -0
- thermodraw/_metrics.py +642 -0
- thermodraw/_page.py +164 -0
- thermodraw/_physics.py +269 -0
- thermodraw/_render.py +463 -0
- thermodraw/_solve.py +358 -0
- thermodraw/builder.py +118 -0
- thermodraw/core.py +616 -0
- thermodraw/fonts/OFL.txt +93 -0
- thermodraw/fonts/thermodraw-sans-400.woff2 +0 -0
- thermodraw/fonts/thermodraw-sans-600.woff2 +0 -0
- thermodraw/fonts/thermodraw-sans-italic.woff2 +0 -0
- thermodraw/io.py +38 -0
- thermodraw/model.py +992 -0
- thermodraw/py.typed +0 -0
- thermodraw/symbols.py +608 -0
- thermodraw/theme.py +147 -0
- thermodraw-1.0.0.dist-info/METADATA +204 -0
- thermodraw-1.0.0.dist-info/RECORD +29 -0
- thermodraw-1.0.0.dist-info/WHEEL +5 -0
- thermodraw-1.0.0.dist-info/entry_points.txt +2 -0
- thermodraw-1.0.0.dist-info/licenses/LICENSE +21 -0
- thermodraw-1.0.0.dist-info/licenses/NOTICE +44 -0
- thermodraw-1.0.0.dist-info/licenses/src/thermodraw/fonts/OFL.txt +93 -0
- thermodraw-1.0.0.dist-info/top_level.txt +1 -0
thermodraw/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""ThermoDraw — thermal network diagrams for Python.
|
|
2
|
+
|
|
3
|
+
A diagram is data. You write it, or an LLM writes it, and three pure stages
|
|
4
|
+
turn it into a document:
|
|
5
|
+
|
|
6
|
+
dict / JSON -> Diagram -> placements -> SVG
|
|
7
|
+
model layout render
|
|
8
|
+
(solve fills in the `at` you left out)
|
|
9
|
+
|
|
10
|
+
from thermodraw import Diagram, layout, render, save, theme
|
|
11
|
+
|
|
12
|
+
d = Diagram.from_json(open("diagram.json").read())
|
|
13
|
+
save(theme.with_variables(render(layout(d))), "out.svg")
|
|
14
|
+
|
|
15
|
+
`render` emits CSS custom properties with no fallback, so its output goes
|
|
16
|
+
through `theme` before it is saved: `with_variables` for the web, `bake` for
|
|
17
|
+
Word, slides and rasterisers. Without one of them the file draws nothing.
|
|
18
|
+
|
|
19
|
+
`DiagramBuilder` is sugar over the same data, and `symbols` still places one
|
|
20
|
+
symbol at a time for anything the model does not yet cover.
|
|
21
|
+
|
|
22
|
+
The schema is docs/schema.md. Design notes and the reasoning behind each
|
|
23
|
+
symbol are in CLAUDE.md.
|
|
24
|
+
"""
|
|
25
|
+
from . import core, io, model, symbols, theme
|
|
26
|
+
from ._check import Finding, Report, check
|
|
27
|
+
from ._describe import Description, describe
|
|
28
|
+
from ._layout import Placement, layout
|
|
29
|
+
from ._page import page
|
|
30
|
+
from ._render import render
|
|
31
|
+
from ._solve import solve
|
|
32
|
+
from .builder import DiagramBuilder
|
|
33
|
+
from .io import save
|
|
34
|
+
from .model import Branch, Diagram, DiagramError, Node, Rail, Source
|
|
35
|
+
from .symbols import SYMBOLS, Symbol
|
|
36
|
+
|
|
37
|
+
__version__ = "1.0.0"
|
|
38
|
+
__all__ = [
|
|
39
|
+
# the data
|
|
40
|
+
"Diagram", "Node", "Branch", "Source", "Rail", "DiagramError",
|
|
41
|
+
# the pipeline
|
|
42
|
+
"solve", "layout", "render", "Placement", "DiagramBuilder",
|
|
43
|
+
# is it any good?
|
|
44
|
+
"check", "Report", "Finding",
|
|
45
|
+
# is it the one you meant?
|
|
46
|
+
"describe", "Description",
|
|
47
|
+
# the same drawing, as a page you can interact with
|
|
48
|
+
"page",
|
|
49
|
+
# the vocabulary
|
|
50
|
+
"Symbol", "SYMBOLS",
|
|
51
|
+
# output
|
|
52
|
+
"save", "theme",
|
|
53
|
+
# still public, for placing one symbol at a time
|
|
54
|
+
"symbols", "core", "model", "io",
|
|
55
|
+
]
|
thermodraw/__main__.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""ThermoDraw from the command line.
|
|
2
|
+
|
|
3
|
+
thermodraw check diagram.json
|
|
4
|
+
thermodraw describe diagram.json
|
|
5
|
+
thermodraw render diagram.json -o out.svg --mode light
|
|
6
|
+
thermodraw page diagram.json -o out.html
|
|
7
|
+
thermodraw solve diagram.json -o placed.json
|
|
8
|
+
|
|
9
|
+
python -m thermodraw check diagram.json # from a checkout, no install
|
|
10
|
+
|
|
11
|
+
`check` is the reason this exists, and `describe` is the half it could not
|
|
12
|
+
cover: the checker says nothing is wrong with the drawing and cannot say it is
|
|
13
|
+
the drawing you meant. `page` is the same drawing with its controls, for a
|
|
14
|
+
repeated group a reader may want to expand.
|
|
15
|
+
|
|
16
|
+
Finding out whether a diagram was any good
|
|
17
|
+
used to mean rendering it, serving it over HTTP, opening a browser, taking a
|
|
18
|
+
screenshot and looking — five sequential steps, none of which a script or a
|
|
19
|
+
model can do cheaply. This is one call whose output is text, and whose exit
|
|
20
|
+
status is the answer.
|
|
21
|
+
|
|
22
|
+
Exit codes: 0 clean, 1 findings, 2 no answer — the file could not be read or
|
|
23
|
+
was invalid. Only `check` can exit 1. 2 is also what argparse exits with for a
|
|
24
|
+
bad command line; a script that needs to tell the two apart has stderr.
|
|
25
|
+
|
|
26
|
+
Stdlib only. SVG and HTML; PNG needs a rasteriser, a rasteriser is a
|
|
27
|
+
dependency, and the library has none.
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import pathlib
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
from . import theme
|
|
35
|
+
from ._check import ORDER, Finding, check
|
|
36
|
+
from ._describe import describe
|
|
37
|
+
from ._layout import layout
|
|
38
|
+
from ._page import page
|
|
39
|
+
from ._render import render
|
|
40
|
+
from ._solve import solve
|
|
41
|
+
from .io import DECLARATION, save
|
|
42
|
+
from .model import Diagram, DiagramError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _die(message):
|
|
46
|
+
"""Exit 2, which is neither "clean" nor "findings" but "no answer"."""
|
|
47
|
+
print(f"error: {message}", file=_soften(sys.stderr))
|
|
48
|
+
raise SystemExit(2)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load(path):
|
|
52
|
+
"""The diagram at `path`, or exit 2 saying why."""
|
|
53
|
+
# `utf-8-sig`: PowerShell's Out-File and older Notepads write a BOM, and
|
|
54
|
+
# the file is no less UTF-8 for it. A file that is not UTF-8 at all used
|
|
55
|
+
# to escape as a traceback with exit 1 — the code reserved for "findings"
|
|
56
|
+
# — because UnicodeDecodeError is a ValueError, not an OSError.
|
|
57
|
+
try:
|
|
58
|
+
text = pathlib.Path(path).read_text(encoding="utf-8-sig")
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
_die(f"cannot read {path}: {exc.strerror or exc}")
|
|
61
|
+
except UnicodeDecodeError as exc:
|
|
62
|
+
_die(f"{path} is not UTF-8: {exc.reason} at byte {exc.start}. "
|
|
63
|
+
"Save it as UTF-8")
|
|
64
|
+
try:
|
|
65
|
+
return Diagram.from_json(text)
|
|
66
|
+
except DiagramError as exc:
|
|
67
|
+
_die(f"{path}: {exc}")
|
|
68
|
+
except ValueError as exc: # not JSON at all
|
|
69
|
+
_die(f"{path} is not valid JSON: {exc}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _soften(stream):
|
|
73
|
+
"""Let a cp1252 console print a diagram's own labels without dying.
|
|
74
|
+
|
|
75
|
+
Findings quote node ids, branch endpoints and units keys, which come from
|
|
76
|
+
the file and can hold anything. The fixed text is ASCII; this covers the
|
|
77
|
+
rest.
|
|
78
|
+
|
|
79
|
+
`backslashreplace`, not `replace`. The units quantity for a heat flux is
|
|
80
|
+
`q` followed by U+2033, and on a cp1252 console `replace` printed "units
|
|
81
|
+
names 'q?'" — an error naming a key the reader cannot copy, about a
|
|
82
|
+
character they most likely mistyped in the first place. This prints
|
|
83
|
+
'q\\u2033', which is ugly and recoverable.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
stream.reconfigure(errors="backslashreplace")
|
|
87
|
+
except (AttributeError, ValueError): # not a real tty, or piped
|
|
88
|
+
pass
|
|
89
|
+
return stream
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _strict(report):
|
|
93
|
+
"""Promote every note to a warning, so advice fails the run too."""
|
|
94
|
+
report.findings = [
|
|
95
|
+
f if f.severity != "note" else Finding(
|
|
96
|
+
f.code, "warning", f.where, f.message, f.remedy, f.at)
|
|
97
|
+
for f in report.findings]
|
|
98
|
+
report.findings.sort(key=lambda f: (ORDER[f.severity], f.code, f.where or ""))
|
|
99
|
+
return report
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def do_check(args):
|
|
103
|
+
report = check(_load(args.diagram), size=args.size, source=args.diagram,
|
|
104
|
+
physics=args.physics)
|
|
105
|
+
if args.strict:
|
|
106
|
+
report = _strict(report)
|
|
107
|
+
out = _soften(sys.stdout)
|
|
108
|
+
if args.json:
|
|
109
|
+
json.dump(report.to_dict(), out, indent=2, ensure_ascii=False)
|
|
110
|
+
out.write("\n")
|
|
111
|
+
elif args.quiet:
|
|
112
|
+
for finding in report.findings:
|
|
113
|
+
print(finding.line(out.isatty()), file=out)
|
|
114
|
+
else:
|
|
115
|
+
print(report.text(colour=out.isatty()), file=out)
|
|
116
|
+
return 0 if report.ok else 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def do_describe(args):
|
|
120
|
+
"""What got drawn. Always exit 0: this reports, it does not judge."""
|
|
121
|
+
out = _soften(sys.stdout)
|
|
122
|
+
description = describe(_load(args.diagram), size=args.size,
|
|
123
|
+
source=args.diagram)
|
|
124
|
+
if args.json:
|
|
125
|
+
json.dump(description.to_dict(), out, indent=2, ensure_ascii=False)
|
|
126
|
+
out.write("\n")
|
|
127
|
+
else:
|
|
128
|
+
print(description.text(), file=out)
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def do_page(args):
|
|
133
|
+
"""The diagram as a page: the same SVG inline, plus its controls."""
|
|
134
|
+
diagram = _load(args.diagram)
|
|
135
|
+
out = page(diagram, size=args.size or diagram.size, title=args.title,
|
|
136
|
+
notation=args.notation)
|
|
137
|
+
if args.out == "-":
|
|
138
|
+
_soften(sys.stdout).write(out)
|
|
139
|
+
return 0
|
|
140
|
+
path = save(out, args.out or pathlib.Path(args.diagram).with_suffix(".html"))
|
|
141
|
+
print(f"{path} ({len(out):,} bytes)")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def do_render(args):
|
|
146
|
+
diagram = _load(args.diagram)
|
|
147
|
+
svg = render(layout(diagram), size=args.size or diagram.size,
|
|
148
|
+
notation=args.notation)
|
|
149
|
+
svg = theme.bake(svg, args.mode) if args.mode else theme.with_variables(svg)
|
|
150
|
+
|
|
151
|
+
# The same bytes to stdout as to a file. `save` is the library's one
|
|
152
|
+
# writer and adds the declaration; stdout gets it too. They used to
|
|
153
|
+
# differ, because this function typed the declaration out a second time.
|
|
154
|
+
if args.out == "-":
|
|
155
|
+
_soften(sys.stdout).write(DECLARATION + svg)
|
|
156
|
+
return 0
|
|
157
|
+
path = save(svg, args.out or pathlib.Path(args.diagram).with_suffix(".svg"))
|
|
158
|
+
print(f"{path} ({len(svg):,} bytes)")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def do_solve(args):
|
|
163
|
+
"""The same diagram with every node placed, as JSON to edit from.
|
|
164
|
+
|
|
165
|
+
The workflow this exists for: write the network without coordinates,
|
|
166
|
+
solve it, then move what the solver put somewhere you would not have.
|
|
167
|
+
Everything the author set is kept; only the `at` that were missing are
|
|
168
|
+
added, and the `via` a parallel pair needed.
|
|
169
|
+
"""
|
|
170
|
+
diagram = _load(args.diagram)
|
|
171
|
+
out = solve(diagram).to_json() + "\n"
|
|
172
|
+
if args.out == "-":
|
|
173
|
+
_soften(sys.stdout).write(out)
|
|
174
|
+
return 0
|
|
175
|
+
path = save(out, args.out or pathlib.Path(args.diagram).with_name(
|
|
176
|
+
pathlib.Path(args.diagram).stem + ".solved.json"))
|
|
177
|
+
print(f"{path} ({len(out):,} bytes)")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main(argv=None):
|
|
182
|
+
ap = argparse.ArgumentParser(prog="thermodraw",
|
|
183
|
+
description=__doc__.split("\n\n")[0])
|
|
184
|
+
subs = ap.add_subparsers(dest="command", required=True)
|
|
185
|
+
|
|
186
|
+
def size(sub):
|
|
187
|
+
sub.add_argument("--size", nargs=2, type=float, metavar=("W", "H"),
|
|
188
|
+
help="fix the canvas instead of measuring it")
|
|
189
|
+
|
|
190
|
+
c = subs.add_parser("check", help="report what is wrong with a diagram")
|
|
191
|
+
c.add_argument("diagram")
|
|
192
|
+
c.add_argument("--json", action="store_true", help="machine-readable")
|
|
193
|
+
c.add_argument("--strict", action="store_true",
|
|
194
|
+
help="treat notes as warnings, so they fail too")
|
|
195
|
+
c.add_argument("--quiet", action="store_true",
|
|
196
|
+
help="findings only, no summary line; silent when there "
|
|
197
|
+
"is nothing at all to report")
|
|
198
|
+
c.add_argument("--physics", action="store_true",
|
|
199
|
+
help="also ask whether the stated numbers close at each "
|
|
200
|
+
"node")
|
|
201
|
+
size(c)
|
|
202
|
+
c.set_defaults(fn=do_check)
|
|
203
|
+
|
|
204
|
+
d = subs.add_parser("describe", help="say what the drawing contains")
|
|
205
|
+
d.add_argument("diagram")
|
|
206
|
+
d.add_argument("--json", action="store_true", help="machine-readable")
|
|
207
|
+
size(d)
|
|
208
|
+
d.set_defaults(fn=do_describe)
|
|
209
|
+
|
|
210
|
+
g = subs.add_parser("page", help="write the diagram as an HTML page")
|
|
211
|
+
g.add_argument("diagram")
|
|
212
|
+
g.add_argument("-o", "--out", help='output path, or "-" for stdout')
|
|
213
|
+
g.add_argument("--title", help="heading for the page")
|
|
214
|
+
g.add_argument("--notation", choices=["boxes", "zigzags"],
|
|
215
|
+
default="boxes",
|
|
216
|
+
help="draw resistances as textured boxes (default) or "
|
|
217
|
+
"as circuit zigzags")
|
|
218
|
+
size(g)
|
|
219
|
+
g.set_defaults(fn=do_page)
|
|
220
|
+
|
|
221
|
+
r = subs.add_parser("render", help="write the diagram as SVG")
|
|
222
|
+
r.add_argument("diagram")
|
|
223
|
+
r.add_argument("-o", "--out", help='output path, or "-" for stdout')
|
|
224
|
+
r.add_argument("--mode", choices=["light", "dark"],
|
|
225
|
+
help="bake the palette, for Word, slides and rasterisers")
|
|
226
|
+
r.add_argument("--notation", choices=["boxes", "zigzags"],
|
|
227
|
+
default="boxes",
|
|
228
|
+
help="draw resistances as textured boxes (default) or "
|
|
229
|
+
"as circuit zigzags")
|
|
230
|
+
size(r)
|
|
231
|
+
r.set_defaults(fn=do_render)
|
|
232
|
+
|
|
233
|
+
s = subs.add_parser("solve", help="write the diagram back with every "
|
|
234
|
+
"node placed, to edit from")
|
|
235
|
+
s.add_argument("diagram")
|
|
236
|
+
s.add_argument("-o", "--out", help='output path, or "-" for stdout')
|
|
237
|
+
s.set_defaults(fn=do_solve)
|
|
238
|
+
|
|
239
|
+
args = ap.parse_args(argv)
|
|
240
|
+
try:
|
|
241
|
+
return args.fn(args)
|
|
242
|
+
except SystemExit:
|
|
243
|
+
raise
|
|
244
|
+
except DiagramError as exc:
|
|
245
|
+
# A refusal from the solver comes out of `check`, `describe`,
|
|
246
|
+
# `render` and `page`, after `_load` has accepted the file. It is
|
|
247
|
+
# the library declining a diagram it does not place, which the
|
|
248
|
+
# schema documents; the net below called it a bug in thermodraw,
|
|
249
|
+
# in the same sentence as telling the reader what to type instead.
|
|
250
|
+
_die(str(exc))
|
|
251
|
+
except Exception as exc: # pragma: no cover - a net
|
|
252
|
+
# Exit 1 means findings. An uncaught exception used to reach the
|
|
253
|
+
# shell as 1 through Python's own handler, so a script gating on the
|
|
254
|
+
# status read a crash as a diagram with warnings. Every known cause
|
|
255
|
+
# is now refused by `validate`; this is the net under the unknown
|
|
256
|
+
# ones, and it says 2 -- no answer -- which is what a crash is.
|
|
257
|
+
_die(f"{type(exc).__name__}: {exc}. This is a bug in thermodraw; "
|
|
258
|
+
"the diagram was accepted and then could not be drawn")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
raise SystemExit(main())
|