pandid 0.1.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.
- pandid/__init__.py +19 -0
- pandid/__main__.py +12 -0
- pandid/cli.py +296 -0
- pandid/components.py +15 -0
- pandid/document.py +379 -0
- pandid/flowsheet.py +917 -0
- pandid/geometry.py +156 -0
- pandid/layout/__init__.py +70 -0
- pandid/layout/attach.py +171 -0
- pandid/layout/coordinates.py +181 -0
- pandid/layout/cycles.py +77 -0
- pandid/layout/faces.py +148 -0
- pandid/layout/layering.py +66 -0
- pandid/layout/ordering.py +126 -0
- pandid/loops.py +124 -0
- pandid/portgeom.py +412 -0
- pandid/ports.py +32 -0
- pandid/py.typed +0 -0
- pandid/render/__init__.py +12 -0
- pandid/render/_vendored_symbols.py +951 -0
- pandid/render/furniture.py +488 -0
- pandid/render/svg.py +1455 -0
- pandid/render/symbols.py +1243 -0
- pandid/routing/__init__.py +127 -0
- pandid/routing/astar.py +144 -0
- pandid/routing/separation.py +193 -0
- pandid/routing/visibility.py +266 -0
- pandid/spec.py +1104 -0
- pandid/state.py +24 -0
- pandid/stations.py +211 -0
- pandid/streams.py +110 -0
- pandid/units.py +1448 -0
- pandid/validate.py +274 -0
- pandid-0.1.0.dist-info/METADATA +294 -0
- pandid-0.1.0.dist-info/RECORD +40 -0
- pandid-0.1.0.dist-info/WHEEL +4 -0
- pandid-0.1.0.dist-info/entry_points.txt +2 -0
- pandid-0.1.0.dist-info/licenses/LICENSE +122 -0
- pandid-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- pandid-0.1.0.dist-info/licenses/NOTICE +96 -0
pandid/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""pandid: a Python engine for chemical-engineering Process Flow Diagrams.
|
|
2
|
+
|
|
3
|
+
Public API (topology layer)::
|
|
4
|
+
|
|
5
|
+
from pandid import Flowsheet, Component, units
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# The one place the version is written: hatchling reads this literal at build
|
|
9
|
+
# time (`[tool.hatch.version]`), so the distribution metadata cannot disagree
|
|
10
|
+
# with what `import pandid` reports, and a source checkout reports the same string
|
|
11
|
+
# without the package having to be installed.
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from pandid.components import Component
|
|
15
|
+
from pandid.flowsheet import Flowsheet
|
|
16
|
+
from pandid import units
|
|
17
|
+
from pandid.spec import SpecError
|
|
18
|
+
|
|
19
|
+
__all__ = ["Flowsheet", "Component", "units", "SpecError", "__version__"]
|
pandid/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Run the CLI as ``python -m pandid``.
|
|
2
|
+
|
|
3
|
+
This is the same entry point as the installed ``pandid`` command, for a checkout
|
|
4
|
+
or an environment whose scripts directory is not on PATH.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from pandid.cli import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
sys.exit(main())
|
pandid/cli.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The ``pandid`` command line.
|
|
2
|
+
|
|
3
|
+
:mod:`pandid.spec` already reads an entire flowsheet from a YAML or JSON file, so
|
|
4
|
+
the only thing left between an equipment list and a drawing is a Python prompt.
|
|
5
|
+
This module removes it. It is a shell over the public API and knows nothing
|
|
6
|
+
about drawing: it reads the spec, calls the methods a script would call, and
|
|
7
|
+
turns whatever the engine raises into one line on stderr and an exit code a
|
|
8
|
+
shell can gate on.
|
|
9
|
+
|
|
10
|
+
pandid draw plant.yaml -o plant.pdf --page-size A3 --border zone
|
|
11
|
+
pandid validate plant.yaml
|
|
12
|
+
pandid symbols --kind valve
|
|
13
|
+
|
|
14
|
+
The exit codes are the interface a script sees, so they distinguish the three
|
|
15
|
+
things that can go wrong rather than all reporting 1:
|
|
16
|
+
|
|
17
|
+
===== ======================================================================
|
|
18
|
+
``0`` the command did what it was asked
|
|
19
|
+
``1`` the flowsheet was rejected: the spec could not be read or understood,
|
|
20
|
+
validation found errors, or the engine refused the request (an unknown
|
|
21
|
+
page size, a page too small for its own furniture)
|
|
22
|
+
``2`` the command line was wrong: an unknown flag, a missing argument, an
|
|
23
|
+
option value this module checks itself
|
|
24
|
+
``3`` an optional extra the request needs is not installed (PyYAML to read a
|
|
25
|
+
YAML spec, cairosvg to write a PDF or a PNG)
|
|
26
|
+
===== ======================================================================
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import difflib
|
|
33
|
+
import shutil
|
|
34
|
+
import sys
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from pandid import __version__, spec, units
|
|
39
|
+
from pandid.flowsheet import Flowsheet
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_FAILED = 1
|
|
43
|
+
EXIT_USAGE = 2
|
|
44
|
+
EXIT_MISSING_DEPENDENCY = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Failure(Exception):
|
|
48
|
+
"""Something this module found wrong, and the code to report it under."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, code: int = EXIT_FAILED) -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.code = code
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _note(message: str) -> None:
|
|
56
|
+
"""Write a line to stderr, behind whatever is still queued on stdout.
|
|
57
|
+
|
|
58
|
+
The two streams are buffered differently once either is a pipe, so without
|
|
59
|
+
the flush a note lands above the line it is about.
|
|
60
|
+
"""
|
|
61
|
+
sys.stdout.flush()
|
|
62
|
+
print(message, file=sys.stderr)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fail(message: str, code: int) -> int:
|
|
66
|
+
_note(f"error: {message}")
|
|
67
|
+
return code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _plural(count: int, noun: str) -> str:
|
|
71
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fold(name: str) -> str:
|
|
75
|
+
"""``HeatExchanger``, ``heat_exchanger`` and ``hex`` all written one way."""
|
|
76
|
+
return name.lower().replace("_", "").replace("-", "")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _suggest(value: str, candidates: Sequence[str]) -> str:
|
|
80
|
+
close = difflib.get_close_matches(_fold(value), [_fold(c) for c in candidates], n=1, cutoff=0.6)
|
|
81
|
+
if not close:
|
|
82
|
+
return ""
|
|
83
|
+
match = next(c for c in candidates if _fold(c) == close[0])
|
|
84
|
+
return f" (did you mean {match!r}?)"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Subcommands
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load(path: Path) -> Flowsheet:
|
|
93
|
+
"""Read a spec file, choosing the reader from its extension."""
|
|
94
|
+
suffix = path.suffix.lower()
|
|
95
|
+
if suffix in (".yaml", ".yml"):
|
|
96
|
+
return spec.from_yaml(path)
|
|
97
|
+
if suffix == ".json":
|
|
98
|
+
return spec.from_json(path)
|
|
99
|
+
named = repr(suffix) if suffix else "a file with no extension"
|
|
100
|
+
raise _Failure(f"{path}: cannot read a spec from {named}; write it as .yaml, .yml or .json")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _draw(args: argparse.Namespace) -> int:
|
|
104
|
+
fs = _load(args.spec)
|
|
105
|
+
out = args.output if args.output is not None else args.spec.with_suffix(".svg")
|
|
106
|
+
fs.render(
|
|
107
|
+
out,
|
|
108
|
+
page_size=args.page_size,
|
|
109
|
+
border=args.border,
|
|
110
|
+
diagram=args.diagram,
|
|
111
|
+
show_stream_table=args.stream_table,
|
|
112
|
+
jump_direction=args.jump_direction,
|
|
113
|
+
)
|
|
114
|
+
sheet = f"{args.page_size.upper()}, " if args.page_size else ""
|
|
115
|
+
print(
|
|
116
|
+
f"wrote {out} ({sheet}{_plural(len(fs.units), 'unit')}, "
|
|
117
|
+
f"{_plural(len(fs.streams), 'stream')})"
|
|
118
|
+
)
|
|
119
|
+
if fs.warnings:
|
|
120
|
+
# The drawing is made either way; say where to read what was flagged.
|
|
121
|
+
_note(f"{_plural(len(fs.warnings), 'warning')}; see: pandid validate {args.spec}")
|
|
122
|
+
return EXIT_OK
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
126
|
+
fs = _load(args.spec)
|
|
127
|
+
# Lay the sheet out first: the geometric checks have nothing to measure
|
|
128
|
+
# until every unit has a frame, so validating a freshly read spec without
|
|
129
|
+
# this reports only what the reader itself caught.
|
|
130
|
+
fs.route()
|
|
131
|
+
issues = fs.validate()
|
|
132
|
+
for issue in issues:
|
|
133
|
+
print(f"{issue.severity}: {issue.code}: {issue.message}")
|
|
134
|
+
errors = sum(1 for issue in issues if issue.severity == "error")
|
|
135
|
+
warnings = len(issues) - errors
|
|
136
|
+
if not issues:
|
|
137
|
+
print(
|
|
138
|
+
f"{args.spec}: no problems found "
|
|
139
|
+
f"({_plural(len(fs.units), 'unit')}, {_plural(len(fs.streams), 'stream')})"
|
|
140
|
+
)
|
|
141
|
+
return EXIT_OK
|
|
142
|
+
tail = "" if errors else "; warnings do not stop the drawing"
|
|
143
|
+
print(f"{args.spec}: {_plural(errors, 'error')}, {_plural(warnings, 'warning')}{tail}")
|
|
144
|
+
return EXIT_FAILED if errors else EXIT_OK
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _catalogue() -> list[tuple[str, str, list[str]]]:
|
|
148
|
+
"""Every registered symbol as ``(class name, kind, variants)``, one per kind.
|
|
149
|
+
|
|
150
|
+
The kinds come from the unit classes rather than from the registry, so what
|
|
151
|
+
is listed is what a flowsheet can actually put on a sheet: a ``kind`` a spec
|
|
152
|
+
is free to name, with the variants the renderer has artwork for.
|
|
153
|
+
"""
|
|
154
|
+
from pandid.render.symbols import default_registry
|
|
155
|
+
|
|
156
|
+
rows = []
|
|
157
|
+
for name in units.__all__:
|
|
158
|
+
kind = getattr(units, name).kind
|
|
159
|
+
variants = default_registry.variants(kind)
|
|
160
|
+
if variants:
|
|
161
|
+
rows.append((name, kind, variants))
|
|
162
|
+
return sorted(rows)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _row(label: str, items: Sequence[str], pad: int, width: int) -> list[str]:
|
|
166
|
+
"""``label``, then its items, wrapped under a hanging indent."""
|
|
167
|
+
lines = [label.ljust(pad)]
|
|
168
|
+
for item in items:
|
|
169
|
+
if len(lines[-1]) > pad and len(lines[-1]) + len(item) > width:
|
|
170
|
+
lines.append(" " * pad)
|
|
171
|
+
lines[-1] += item + " "
|
|
172
|
+
return [line.rstrip() for line in lines]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _symbols(args: argparse.Namespace) -> int:
|
|
176
|
+
catalogue = _catalogue()
|
|
177
|
+
rows = catalogue
|
|
178
|
+
if args.kind is not None:
|
|
179
|
+
wanted = _fold(args.kind)
|
|
180
|
+
rows = [row for row in catalogue if wanted in (_fold(row[0]), _fold(row[1]))]
|
|
181
|
+
if not rows:
|
|
182
|
+
names = [name for name, _, _ in catalogue]
|
|
183
|
+
raise _Failure(
|
|
184
|
+
f"no equipment kind called {args.kind!r}{_suggest(args.kind, names)}; "
|
|
185
|
+
f"available kinds: {', '.join(names)}",
|
|
186
|
+
EXIT_USAGE,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# A class name is what a spec writes; the kind is what the symbol is filed
|
|
190
|
+
# under and what the engine names in its own messages. They read the same
|
|
191
|
+
# for all but a couple, so the second is shown only where it differs.
|
|
192
|
+
labels = [name if _fold(name) == kind else f"{name} ({kind})" for name, kind, _ in rows]
|
|
193
|
+
pad = max(len(label) for label in labels) + 2
|
|
194
|
+
width = max(shutil.get_terminal_size(fallback=(100, 24)).columns - 1, pad + 24)
|
|
195
|
+
for label, (_, _, variants) in zip(labels, rows):
|
|
196
|
+
for line in _row(label, variants, pad, width):
|
|
197
|
+
print(line)
|
|
198
|
+
if args.kind is None:
|
|
199
|
+
total = sum(len(variants) for _, _, variants in rows)
|
|
200
|
+
_note(
|
|
201
|
+
f"{_plural(total, 'symbol')} in {_plural(len(rows), 'kind')}; narrow this with "
|
|
202
|
+
"--kind, e.g. pandid symbols --kind valve"
|
|
203
|
+
)
|
|
204
|
+
return EXIT_OK
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# The command line itself
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
213
|
+
parser = argparse.ArgumentParser(
|
|
214
|
+
prog="pandid",
|
|
215
|
+
description="Draw a P&ID or process flow diagram from a flowsheet spec file.",
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument("--version", action="version", version=f"pandid {__version__}")
|
|
218
|
+
commands = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
|
|
219
|
+
|
|
220
|
+
draw = commands.add_parser("draw", help="render a spec file to a drawing")
|
|
221
|
+
draw.add_argument("spec", type=Path, metavar="SPEC", help="the spec file (.yaml, .yml, .json)")
|
|
222
|
+
draw.add_argument(
|
|
223
|
+
"-o", "--output", type=Path, metavar="OUT",
|
|
224
|
+
help="where to write it; the extension picks the format (.svg, or .pdf/.png with the "
|
|
225
|
+
"pdf extra). Default: the spec's name with .svg",
|
|
226
|
+
)
|
|
227
|
+
draw.add_argument(
|
|
228
|
+
"--page-size", metavar="SIZE",
|
|
229
|
+
help="draw on a sheet of exactly this size (A4 to A0); omit to fit the sheet to the "
|
|
230
|
+
"drawing",
|
|
231
|
+
)
|
|
232
|
+
draw.add_argument(
|
|
233
|
+
"--border", choices=("none", "zone"),
|
|
234
|
+
help="'zone' rules the ASME-style zone-lettered drawing frame around the sheet",
|
|
235
|
+
)
|
|
236
|
+
draw.add_argument(
|
|
237
|
+
"--diagram", choices=("pfd", "p&id"), default="pfd",
|
|
238
|
+
help="which drawing this is; a P&ID draws its process lines without "
|
|
239
|
+
"arrowheads (default: pfd)",
|
|
240
|
+
)
|
|
241
|
+
draw.add_argument(
|
|
242
|
+
"--stream-table", action="store_true",
|
|
243
|
+
help="draw the stream property table under the drawing",
|
|
244
|
+
)
|
|
245
|
+
draw.add_argument(
|
|
246
|
+
"--jump-direction", choices=("vertical", "horizontal"), default="vertical",
|
|
247
|
+
help="which of two crossing lines gets the semicircle hop (default: vertical)",
|
|
248
|
+
)
|
|
249
|
+
draw.set_defaults(run=_draw)
|
|
250
|
+
|
|
251
|
+
validate = commands.add_parser(
|
|
252
|
+
"validate", help="report what the engine thinks of a spec, without drawing it"
|
|
253
|
+
)
|
|
254
|
+
validate.add_argument(
|
|
255
|
+
"spec", type=Path, metavar="SPEC", help="the spec file (.yaml, .yml, .json)"
|
|
256
|
+
)
|
|
257
|
+
validate.set_defaults(run=_validate)
|
|
258
|
+
|
|
259
|
+
symbols = commands.add_parser("symbols", help="list the equipment symbols that can be drawn")
|
|
260
|
+
symbols.add_argument(
|
|
261
|
+
"--kind", metavar="KIND",
|
|
262
|
+
help="list one kind only, named as a spec would name it (Valve, HeatExchanger, hex)",
|
|
263
|
+
)
|
|
264
|
+
symbols.set_defaults(run=_symbols)
|
|
265
|
+
return parser
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
269
|
+
"""Run the command line and return the exit code, printing rather than raising.
|
|
270
|
+
|
|
271
|
+
Every failure a user can provoke is reported as one line on stderr. A
|
|
272
|
+
traceback out of here is a bug in the engine, not a bad spec.
|
|
273
|
+
"""
|
|
274
|
+
parser = _build_parser()
|
|
275
|
+
try:
|
|
276
|
+
args = parser.parse_args(None if argv is None else list(argv))
|
|
277
|
+
except SystemExit as e: # --help and --version, and a line argparse rejected
|
|
278
|
+
return e.code if isinstance(e.code, int) else EXIT_USAGE
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
return int(args.run(args))
|
|
282
|
+
except _Failure as e:
|
|
283
|
+
return _fail(str(e), e.code)
|
|
284
|
+
except ImportError as e:
|
|
285
|
+
# PyYAML and cairosvg are the two optional extras, and both of these
|
|
286
|
+
# messages already name the one to install.
|
|
287
|
+
return _fail(str(e), EXIT_MISSING_DEPENDENCY)
|
|
288
|
+
except ValueError as e:
|
|
289
|
+
# SpecError is a ValueError, and so is every refusal from the engine.
|
|
290
|
+
# Those messages are written to be read by whoever wrote the file, so
|
|
291
|
+
# they are printed as they are rather than wrapped in anything.
|
|
292
|
+
return _fail(str(e), EXIT_FAILED)
|
|
293
|
+
except OSError as e:
|
|
294
|
+
# A file that is not there, or not readable, or a directory that is not.
|
|
295
|
+
detail = f"{e.filename}: {e.strerror}" if e.filename and e.strerror else str(e)
|
|
296
|
+
return _fail(detail, EXIT_FAILED)
|
pandid/components.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Component: an entry in a flowsheet's chemical-species registry.
|
|
2
|
+
|
|
3
|
+
Carries no thermophysical data yet; a future mass/energy balance backend
|
|
4
|
+
attaches property calculations here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Component:
|
|
14
|
+
name: str
|
|
15
|
+
formula: str | None = None
|