pandid 0.1.0__tar.gz

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.
Files changed (149) hide show
  1. pandid-0.1.0/.github/workflows/ci.yml +69 -0
  2. pandid-0.1.0/.github/workflows/release.yml +65 -0
  3. pandid-0.1.0/.gitignore +43 -0
  4. pandid-0.1.0/.pre-commit-config.yaml +19 -0
  5. pandid-0.1.0/CHANGELOG.md +156 -0
  6. pandid-0.1.0/CONTRIBUTING.md +301 -0
  7. pandid-0.1.0/LICENSE +122 -0
  8. pandid-0.1.0/LICENSE-APACHE +202 -0
  9. pandid-0.1.0/NOTICE +96 -0
  10. pandid-0.1.0/PKG-INFO +294 -0
  11. pandid-0.1.0/README.md +252 -0
  12. pandid-0.1.0/docs/api.md +2450 -0
  13. pandid-0.1.0/docs/gallery/01_ammonia_loop.png +0 -0
  14. pandid-0.1.0/docs/gallery/01_ammonia_loop.svg +61 -0
  15. pandid-0.1.0/docs/gallery/02_manual_layout.png +0 -0
  16. pandid-0.1.0/docs/gallery/02_manual_layout.svg +43 -0
  17. pandid-0.1.0/docs/gallery/03_distillation_train.png +0 -0
  18. pandid-0.1.0/docs/gallery/03_distillation_train.svg +427 -0
  19. pandid-0.1.0/docs/gallery/04_control_loop.png +0 -0
  20. pandid-0.1.0/docs/gallery/04_control_loop.svg +93 -0
  21. pandid-0.1.0/docs/gallery/05_reactor_recycle.png +0 -0
  22. pandid-0.1.0/docs/gallery/05_reactor_recycle.svg +73 -0
  23. pandid-0.1.0/docs/gallery/06_column_reflux.png +0 -0
  24. pandid-0.1.0/docs/gallery/06_column_reflux.svg +78 -0
  25. pandid-0.1.0/docs/gallery/07_metering_skid.png +0 -0
  26. pandid-0.1.0/docs/gallery/07_metering_skid.svg +80 -0
  27. pandid-0.1.0/docs/gallery/08_from_data.png +0 -0
  28. pandid-0.1.0/docs/gallery/08_from_data.svg +324 -0
  29. pandid-0.1.0/docs/gallery/09_line_numbers.png +0 -0
  30. pandid-0.1.0/docs/gallery/09_line_numbers.svg +219 -0
  31. pandid-0.1.0/docs/gallery/10_ethanol_pfd.png +0 -0
  32. pandid-0.1.0/docs/gallery/10_ethanol_pfd.svg +586 -0
  33. pandid-0.1.0/docs/gallery/11_ethanol_pid.png +0 -0
  34. pandid-0.1.0/docs/gallery/11_ethanol_pid.svg +600 -0
  35. pandid-0.1.0/docs/gallery/README.md +230 -0
  36. pandid-0.1.0/examples/01_ammonia_loop.py +24 -0
  37. pandid-0.1.0/examples/02_manual_layout.py +55 -0
  38. pandid-0.1.0/examples/03_distillation_train.py +159 -0
  39. pandid-0.1.0/examples/04_control_loop.py +95 -0
  40. pandid-0.1.0/examples/05_reactor_recycle.py +47 -0
  41. pandid-0.1.0/examples/06_column_reflux.py +106 -0
  42. pandid-0.1.0/examples/07_metering_skid.py +88 -0
  43. pandid-0.1.0/examples/08_from_data.py +178 -0
  44. pandid-0.1.0/examples/09_line_numbers.py +138 -0
  45. pandid-0.1.0/examples/10_ethanol_pfd.py +272 -0
  46. pandid-0.1.0/examples/11_ethanol_pid.py +440 -0
  47. pandid-0.1.0/examples/_bootstrap.py +29 -0
  48. pandid-0.1.0/pandid/__init__.py +19 -0
  49. pandid-0.1.0/pandid/__main__.py +12 -0
  50. pandid-0.1.0/pandid/cli.py +296 -0
  51. pandid-0.1.0/pandid/components.py +15 -0
  52. pandid-0.1.0/pandid/document.py +379 -0
  53. pandid-0.1.0/pandid/flowsheet.py +917 -0
  54. pandid-0.1.0/pandid/geometry.py +156 -0
  55. pandid-0.1.0/pandid/layout/__init__.py +70 -0
  56. pandid-0.1.0/pandid/layout/attach.py +171 -0
  57. pandid-0.1.0/pandid/layout/coordinates.py +181 -0
  58. pandid-0.1.0/pandid/layout/cycles.py +77 -0
  59. pandid-0.1.0/pandid/layout/faces.py +148 -0
  60. pandid-0.1.0/pandid/layout/layering.py +66 -0
  61. pandid-0.1.0/pandid/layout/ordering.py +126 -0
  62. pandid-0.1.0/pandid/loops.py +124 -0
  63. pandid-0.1.0/pandid/portgeom.py +412 -0
  64. pandid-0.1.0/pandid/ports.py +32 -0
  65. pandid-0.1.0/pandid/py.typed +0 -0
  66. pandid-0.1.0/pandid/render/__init__.py +12 -0
  67. pandid-0.1.0/pandid/render/_vendored_symbols.py +951 -0
  68. pandid-0.1.0/pandid/render/furniture.py +488 -0
  69. pandid-0.1.0/pandid/render/svg.py +1455 -0
  70. pandid-0.1.0/pandid/render/symbols.py +1243 -0
  71. pandid-0.1.0/pandid/routing/__init__.py +127 -0
  72. pandid-0.1.0/pandid/routing/astar.py +144 -0
  73. pandid-0.1.0/pandid/routing/separation.py +193 -0
  74. pandid-0.1.0/pandid/routing/visibility.py +266 -0
  75. pandid-0.1.0/pandid/spec.py +1104 -0
  76. pandid-0.1.0/pandid/state.py +24 -0
  77. pandid-0.1.0/pandid/stations.py +211 -0
  78. pandid-0.1.0/pandid/streams.py +110 -0
  79. pandid-0.1.0/pandid/units.py +1448 -0
  80. pandid-0.1.0/pandid/validate.py +274 -0
  81. pandid-0.1.0/pyproject.toml +80 -0
  82. pandid-0.1.0/scripts/mxgraph_to_svg.py +303 -0
  83. pandid-0.1.0/scripts/symbol_sheet.py +81 -0
  84. pandid-0.1.0/scripts/vendor_data/drawio/agitators.xml +190 -0
  85. pandid-0.1.0/scripts/vendor_data/drawio/compressors.xml +373 -0
  86. pandid-0.1.0/scripts/vendor_data/drawio/driers.xml +235 -0
  87. pandid-0.1.0/scripts/vendor_data/drawio/filters.xml +429 -0
  88. pandid-0.1.0/scripts/vendor_data/drawio/fittings.xml +770 -0
  89. pandid-0.1.0/scripts/vendor_data/drawio/flow_sensors.xml +328 -0
  90. pandid-0.1.0/scripts/vendor_data/drawio/heat_exchangers.xml +809 -0
  91. pandid-0.1.0/scripts/vendor_data/drawio/instruments.xml +571 -0
  92. pandid-0.1.0/scripts/vendor_data/drawio/mixers.xml +97 -0
  93. pandid-0.1.0/scripts/vendor_data/drawio/piping.xml +882 -0
  94. pandid-0.1.0/scripts/vendor_data/drawio/pumps.xml +600 -0
  95. pandid-0.1.0/scripts/vendor_data/drawio/separators.xml +594 -0
  96. pandid-0.1.0/scripts/vendor_data/drawio/valves.xml +846 -0
  97. pandid-0.1.0/scripts/vendor_data/drawio/vessels.xml +1847 -0
  98. pandid-0.1.0/scripts/vendor_symbols.py +1265 -0
  99. pandid-0.1.0/tests/conftest.py +47 -0
  100. pandid-0.1.0/tests/golden/01_ammonia_loop.svg +61 -0
  101. pandid-0.1.0/tests/golden/02_manual_layout.svg +43 -0
  102. pandid-0.1.0/tests/golden/03_distillation_train.svg +427 -0
  103. pandid-0.1.0/tests/golden/04_control_loop.svg +93 -0
  104. pandid-0.1.0/tests/golden/05_reactor_recycle.svg +73 -0
  105. pandid-0.1.0/tests/golden/06_column_reflux.svg +78 -0
  106. pandid-0.1.0/tests/golden/07_metering_skid.svg +80 -0
  107. pandid-0.1.0/tests/golden/08_from_data.svg +324 -0
  108. pandid-0.1.0/tests/golden/09_line_numbers.svg +219 -0
  109. pandid-0.1.0/tests/golden/README.md +36 -0
  110. pandid-0.1.0/tests/test_cli.py +302 -0
  111. pandid-0.1.0/tests/test_conveyor.py +286 -0
  112. pandid-0.1.0/tests/test_custom_units.py +237 -0
  113. pandid-0.1.0/tests/test_faces.py +199 -0
  114. pandid-0.1.0/tests/test_fail_position.py +376 -0
  115. pandid-0.1.0/tests/test_flowsheet.py +181 -0
  116. pandid-0.1.0/tests/test_geometry.py +36 -0
  117. pandid-0.1.0/tests/test_geometry_model.py +71 -0
  118. pandid-0.1.0/tests/test_golden.py +566 -0
  119. pandid-0.1.0/tests/test_gravity_orientation.py +214 -0
  120. pandid-0.1.0/tests/test_instruments.py +557 -0
  121. pandid-0.1.0/tests/test_integration.py +57 -0
  122. pandid-0.1.0/tests/test_layout.py +129 -0
  123. pandid-0.1.0/tests/test_line_numbers.py +398 -0
  124. pandid-0.1.0/tests/test_location_reference.py +150 -0
  125. pandid-0.1.0/tests/test_loops.py +295 -0
  126. pandid-0.1.0/tests/test_model.py +35 -0
  127. pandid-0.1.0/tests/test_normally_closed.py +447 -0
  128. pandid-0.1.0/tests/test_packaging.py +61 -0
  129. pandid-0.1.0/tests/test_recycle.py +36 -0
  130. pandid-0.1.0/tests/test_reducer.py +261 -0
  131. pandid-0.1.0/tests/test_render.py +223 -0
  132. pandid-0.1.0/tests/test_render_api.py +416 -0
  133. pandid-0.1.0/tests/test_route_invariants.py +246 -0
  134. pandid-0.1.0/tests/test_routing.py +99 -0
  135. pandid-0.1.0/tests/test_routing_quality.py +381 -0
  136. pandid-0.1.0/tests/test_scaffold.py +4 -0
  137. pandid-0.1.0/tests/test_serialize.py +49 -0
  138. pandid-0.1.0/tests/test_spec.py +756 -0
  139. pandid-0.1.0/tests/test_state.py +38 -0
  140. pandid-0.1.0/tests/test_stream_numbering.py +136 -0
  141. pandid-0.1.0/tests/test_symbol_invariants.py +1504 -0
  142. pandid-0.1.0/tests/test_tee.py +455 -0
  143. pandid-0.1.0/tests/test_titleblock.py +546 -0
  144. pandid-0.1.0/tests/test_transforms.py +369 -0
  145. pandid-0.1.0/tests/test_units.py +253 -0
  146. pandid-0.1.0/tests/test_units_api.py +56 -0
  147. pandid-0.1.0/tests/test_utility_headers.py +213 -0
  148. pandid-0.1.0/tests/test_validate.py +150 -0
  149. pandid-0.1.0/tests/test_valve_station.py +433 -0
@@ -0,0 +1,69 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ # The release workflow calls these same gates on the tagged commit, so a
8
+ # release runs the matrix that is actually maintained rather than a copy.
9
+ workflow_call:
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ # Cancel superseded runs on the same ref (e.g. rapid pushes to a PR branch).
15
+ concurrency:
16
+ group: ci-${{ github.workflow }}-${{ github.ref }}
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ lint:
21
+ name: Lint (ruff)
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.12"
28
+ - name: Install
29
+ run: |
30
+ python -m pip install --upgrade pip
31
+ pip install -e ".[dev]"
32
+ - name: ruff check
33
+ run: ruff check .
34
+ - name: ruff format (tests must stay formatted)
35
+ run: ruff format --check tests
36
+
37
+ typecheck:
38
+ name: Type check (mypy)
39
+ runs-on: ubuntu-latest
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+ - uses: actions/setup-python@v5
43
+ with:
44
+ python-version: "3.12"
45
+ - name: Install
46
+ run: |
47
+ python -m pip install --upgrade pip
48
+ pip install -e ".[dev]"
49
+ - name: mypy
50
+ run: mypy pandid
51
+
52
+ test:
53
+ name: Tests (py${{ matrix.python-version }})
54
+ runs-on: ubuntu-latest
55
+ strategy:
56
+ fail-fast: false
57
+ matrix:
58
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+ - uses: actions/setup-python@v5
62
+ with:
63
+ python-version: ${{ matrix.python-version }}
64
+ - name: Install
65
+ run: |
66
+ python -m pip install --upgrade pip
67
+ pip install -e ".[dev]"
68
+ - name: pytest
69
+ run: pytest -q
@@ -0,0 +1,65 @@
1
+ name: Release
2
+
3
+ # Publishing is driven by the tag, not by a branch: pushing `v0.1.0` cuts the
4
+ # release. The tag must name the version in pandid/__init__.py — the build job
5
+ # below refuses to go on if it does not.
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ # Re-run the full CI matrix against the tagged commit: a tag can point at a
15
+ # commit that never went through a pull request.
16
+ gates:
17
+ name: Gates
18
+ uses: ./.github/workflows/ci.yml
19
+
20
+ build:
21
+ name: Build sdist + wheel
22
+ needs: [gates]
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.12"
29
+ - name: Tag must match pandid.__version__
30
+ run: |
31
+ tag="${GITHUB_REF_NAME#v}"
32
+ src="$(python -c 'import pandid; print(pandid.__version__)')"
33
+ if [ "$tag" != "$src" ]; then
34
+ echo "::error::tag $GITHUB_REF_NAME does not name pandid.__version__ ($src)"
35
+ exit 1
36
+ fi
37
+ - name: Build
38
+ run: |
39
+ python -m pip install --upgrade pip
40
+ pip install build twine
41
+ python -m build
42
+ - name: Check the metadata PyPI will render
43
+ run: twine check --strict dist/*
44
+ - uses: actions/upload-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+
49
+ publish:
50
+ name: Publish to PyPI
51
+ needs: [build]
52
+ runs-on: ubuntu-latest
53
+ # Named environment so the repository owner can require a manual approval,
54
+ # and so the PyPI trusted publisher can be scoped to it, before any upload.
55
+ environment:
56
+ name: pypi
57
+ url: https://pypi.org/p/pandid
58
+ permissions:
59
+ id-token: write # mint the OIDC token PyPI trusted publishing exchanges
60
+ steps:
61
+ - uses: actions/download-artifact@v4
62
+ with:
63
+ name: dist
64
+ path: dist/
65
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,43 @@
1
+ # Byte-compiled / optimized files
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ # Packaging / build
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # Tooling caches
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+
19
+ # uv lockfile (not committed for this library)
20
+ uv.lock
21
+
22
+ # Generated diagram output (reproducible from examples/)
23
+ *.svg
24
+ # ...but the golden fixtures are source, not output: the regression suite has
25
+ # nothing to compare against if these are ignored.
26
+ !tests/golden/*.svg
27
+ # ...and the gallery is documentation: docs/gallery/README.md has nothing to
28
+ # show if these are ignored. Regenerate by running examples/ (see that README).
29
+ !docs/gallery/*.svg
30
+
31
+ # Agent / harness working files
32
+ .agents/
33
+ .superpowers/
34
+ skills-lock.json
35
+
36
+ # local render artifacts
37
+ renders/
38
+
39
+ # Reference drawings (third-party coursework; kept local, not redistributed)
40
+ professional_examples/
41
+
42
+ # Agent worktrees (created per-run under .claude/, never part of the project)
43
+ .claude/worktrees/
@@ -0,0 +1,19 @@
1
+ # Local commit-time checkers, mirroring CI. Enable with:
2
+ # pip install pre-commit && pre-commit install
3
+ repos:
4
+ - repo: https://github.com/astral-sh/ruff-pre-commit
5
+ rev: v0.15.14
6
+ hooks:
7
+ - id: ruff # lint the whole tree (auto-fixes what it safely can)
8
+ args: [--fix]
9
+ - id: ruff-format # keep the test suite formatted
10
+ files: ^tests/
11
+
12
+ - repo: https://github.com/pre-commit/pre-commit-hooks
13
+ rev: v5.0.0
14
+ hooks:
15
+ - id: trailing-whitespace
16
+ - id: end-of-file-fixer
17
+ - id: check-yaml
18
+ - id: check-toml
19
+ - id: check-merge-conflict
@@ -0,0 +1,156 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-28
9
+
10
+ First public release. Nothing was published before it, so everything below is the
11
+ initial feature set rather than a change to something users have, and the entries
12
+ are headlines rather than a description of the library. `README.md` is the tour,
13
+ [`docs/api.md`](docs/api.md) is the reference, and
14
+ [`docs/gallery/README.md`](docs/gallery/README.md) walks the eleven examples.
15
+ Later releases will list changes rather than capabilities.
16
+
17
+ ### Added
18
+
19
+ - **Topology.** `Flowsheet` is the container and the single source of truth for
20
+ connectivity, with `add()`, `connect()`, `add_component()` and
21
+ `add_annotation()`. 28 typed `Unit` classes declare named ports reachable as
22
+ `unit.ports[name]` or as attributes (`pump.suction`), and a typo raises an
23
+ error naming the real ports. `connect()` validates every connection: outlet to
24
+ inlet only, both units on the same flowsheet, one stream per port, and signal
25
+ against process. A `Unit` subclass of your own declaring its `kind` and
26
+ `PORTS` is laid out, routed and drawn like a shipped class.
27
+ - **Assemblies and branching.** `Tee` is the pipe tee, drawn as bare pipe and
28
+ scheduled nowhere, so a bypass, a drain, a vent or a PSV takeoff no longer puts
29
+ equipment on the sheet that the plant does not contain.
30
+ `Flowsheet.add_valve_station()` builds the eight devices and four tees a
31
+ control valve is installed in, in one call.
32
+ - **Numbering.** Automatic stream numbers carry one number *through* inline
33
+ valves, reducers and fittings. Line numbers (`size`, `schedule`, `service`,
34
+ `spec`, `insulation`, plus an auto-filled `sequence`) are assembled by
35
+ `line_numbering_scheme` and assigned by the same pass, so a line number
36
+ survives an in-line fitting and breaks where the spec break is marked.
37
+ - **Layout.** Sugiyama-style automatic layout: cycle breaking, layer assignment,
38
+ crossing reduction and coordinate assignment, with the main flow line
39
+ straightened onto one axis. The geometry model separates intent (`Pin`, written
40
+ only by `Unit.pin()`) from result (`Frame`, written only by the engine), which
41
+ is what makes `layout()` idempotent. `pin()` takes a grid cell or exact
42
+ coordinates, a quarter turn, a mirror, and `port=` to place a **nozzle** rather
43
+ than a corner. Port faces and equipment tags are then chosen automatically from
44
+ where each peer actually landed, and `nozzle()` overrides that pick where a
45
+ drawing convention has to be stated.
46
+ - **Routing.** Orthogonal A\* over a visibility graph, with port anchors projected
47
+ onto unit boundaries, used-edge penalties so runs do not overlap, crossing
48
+ jump-gaps, and separation of co-located parallel runs. `Stream.via([...])`
49
+ forces explicit waypoints. `route()` places attached instruments and re-routes
50
+ until the two agree rather than trading a fixed number of passes.
51
+ - **Rendering.** SVG with **no runtime dependencies**; `.pdf` and `.png` go
52
+ through the optional `cairosvg` backend. `page_size="A4"`..`"A0"` draws a sheet
53
+ of exactly that ISO 216 size, `border="zone"` rules the ASME-style zone frame,
54
+ and `diagram="p&id"` draws process lines without arrowheads. Signal lines are
55
+ drawn at half the weight of process pipe, the 2:1 ratio ISO 15519-1 §6.2
56
+ requires. A `TitleBlock`, `Revision` rows, and docked `Annotation` / `TableBox`
57
+ furniture (`equipment_list()`, `notes()`, `legend()`) are sheet furniture drawn
58
+ whatever the border, and every cell is measured before it is written into, so
59
+ text that cannot fit is reported on `fs.warnings` rather than drawn across a
60
+ rule.
61
+ - **Symbols.** 139 registered `(kind, variant)` pairs across 28 kinds, generated
62
+ from the draw.io / diagrams.net P&ID stencils by `scripts/vendor_symbols.py`
63
+ and matched to ISO 10628-2 where a symbol exists. Feed/Product flags, the
64
+ variable-port Mixer and Splitter, the pipe tee and the ANSI/ISA-5.1 balloons are
65
+ hand-drawn originals. Every port is checked to land on drawn ink, at every box
66
+ shape a unit can be given. `variant=` is checked against the registry, so a
67
+ typo raises naming the nearest match rather than reaching the printer.
68
+ - **Instrumentation (ISA-5.1).** `add_instrument()` and the `Instrument` unit
69
+ draw the functional letters over a loop number, in six balloon variants plus
70
+ the two trip squares ANSI/ISA-5.1-2009 distinguishes. `Instrument.attach()`
71
+ anchors a balloon to the stream or equipment it reads, with an impulse line to
72
+ the tap. Typed signal lines (`electric`, `pneumatic`, `data`, `software`,
73
+ `capillary`) are legal only between two signal connections.
74
+ `Flowsheet.add_loop()` declares a control loop, so the loop number is typed
75
+ once and each balloon's own letters are checked against it.
76
+ - **Valve marking.** `Valve(normal_position="closed")` darkens the body
77
+ (PIP PIC001 4.2.2.7) or writes `NC` beside it where a filled body would hide
78
+ the device (ISO 15519-1 §11.4.5), and refuses on a control or relief valve
79
+ (clause 4.2.2.10). `Valve(fail=...)` writes the six ANSI/ISA-5.1-2009 Table
80
+ 5.4.4 codes beside an actuated valve. `Fitting(variant="blind")` takes the same
81
+ `normal_position` and changes shape rather than fill.
82
+ - **Validation.** `Flowsheet.validate()` returns `Issue` records, errors first.
83
+ Errors such as overlapping pinned units raise from `render()` rather than emit
84
+ a silently wrong drawing; warnings such as a route crossing a unit body, a tag
85
+ whose letters are out of ISO 15519-2 §5.2.4 order, or a gravity-dependent
86
+ symbol given a quarter turn collect on `fs.warnings`.
87
+ - **Spec format.** `Flowsheet.to_dict()` / `from_dict()` round-trip the whole
88
+ topology as JSON-safe data, and `pandid.spec` reads the same shape from YAML or
89
+ JSON. An unknown key is rejected rather than ignored, and the message names the
90
+ key it was probably meant to be.
91
+ - **Command line.** A `pandid` command, installed with the distribution:
92
+ `pandid draw plant.yaml -o plant.pdf --page-size A3 --border zone`,
93
+ `pandid validate plant.yaml`, and `pandid symbols --kind valve`. Exit codes a
94
+ build script can gate on, and every user-provokable failure is one line on
95
+ stderr rather than a traceback. Built on `argparse`, so the package still has
96
+ no runtime dependencies.
97
+ - **Packaging and tooling.** `pandid/py.typed` (PEP 561), a golden-SVG regression
98
+ suite, symbol- and route-invariant suites, CI on Python 3.10 to 3.13, and a
99
+ release workflow that checks the tag against `pandid.__version__` and publishes
100
+ over PyPI Trusted Publishing. `pandid.__version__` is the only place the version
101
+ is written; the build backend reads it from there.
102
+
103
+ ### Changed
104
+
105
+ Two attributes were renamed on the way to this release. Nothing was published, so
106
+ there is no alias and the old spellings simply do not exist. **Both are also spec
107
+ keys**, so a YAML or JSON file written against a pre-release checkout has to be
108
+ edited: the reader rejects an unknown key rather than ignoring it, and names the
109
+ new one.
110
+
111
+ - `Unit.significant` is now **`Unit.new_line_number`**, and the unit spec key
112
+ `significant:` is now `new_line_number:`. It marks the inline item at which a
113
+ line number breaks and a new one starts, which is what the new name says and
114
+ the old one did not.
115
+ - `connect(tear_hint=...)` and `Stream.tear_hint` are now
116
+ **`draw_as_recycle`**, and the stream spec key `tear_hint:` is now
117
+ `draw_as_recycle:`. It marks a stream to be drawn as a recycle loop.
118
+
119
+ Neither rename changes a drawn sheet: every golden fixture and all eleven example
120
+ SVGs are byte-identical across it.
121
+
122
+ ### Removed
123
+
124
+ The deprecated aliases, on the same reasoning: nothing has been published, so
125
+ keeping them preserves compatibility with code that cannot exist. This follows the
126
+ clean breaks already made for `hot_*` to `shell_*` on heat-exchanger nozzles.
127
+
128
+ - `styling=` on `to_svg()` / `render()`. Use `border="zone"` and
129
+ `diagram="p&id"`, which are independent and name the two things it bundled.
130
+ - `anchor=` on `Annotation`, `TableBox`, `equipment_list()`, `notes()` and
131
+ `legend()`. Use `align=`.
132
+ - `Unit.port_face()`. Use `Unit.nozzle()`, whose `face` is the compass point on
133
+ the finished sheet. The two disagreed on a rotated or mirrored unit, since
134
+ `port_face()` read its face in the symbol's own frame, so **rewrite each call
135
+ site with the face the reader sees** rather than substituting the name (#26).
136
+ - `Symbol.port_alts` and `Symbol.free_ports`. Declare the whole menu, home
137
+ placement included, in `Symbol.port_faces`; the faceless set is
138
+ `Symbol.faceless_ports`.
139
+ - `Unit._PORTS`. Use `Unit.PORTS`, the same list of `(name, direction, role)`
140
+ tuples under a name that does not tell the one attribute a subclass must set
141
+ that it is private.
142
+
143
+ ### Licence
144
+
145
+ Licensed under the **PolyForm Small Business License 1.0.0**: free for
146
+ individuals, research, teaching, and companies under 100 people and 1,000,000 USD
147
+ revenue; a commercial licence is required above either threshold. Source-available
148
+ rather than OSI open source.
149
+
150
+ The vendored draw.io symbol geometry remains **Apache-2.0**, as that licence
151
+ requires. The stencil artwork carries one additional field-of-use restriction on
152
+ top of that grant, naming Atlassian products and marketplace distribution and
153
+ excluding diagram output; `NOTICE` reproduces it in full, lists exactly which
154
+ files fall under which licence, and both texts ship in the distribution.
155
+
156
+ [0.1.0]: https://github.com/Alpha9463/pandid/releases/tag/v0.1.0
@@ -0,0 +1,301 @@
1
+ # Contributing to `pandid`
2
+
3
+ Thanks for helping. This covers setup, the checks that have to pass, and the
4
+ four things about this codebase a new contributor will otherwise get wrong.
5
+
6
+ ## Setup
7
+
8
+ Python 3.10+ (CI tests 3.10 – 3.13). From a checkout:
9
+
10
+ ```bash
11
+ pip install -e '.[dev]' # pytest, ruff, mypy
12
+ pip install -e '.[dev,pdf]' # ...plus cairosvg, if you need PDF/PNG export
13
+ ```
14
+
15
+ The package has **zero runtime dependencies** and must stay that way: `pandid/` may
16
+ import only the Python standard library. `cairosvg` is optional and is imported
17
+ lazily inside `Flowsheet.render()`, only when the output path ends in `.pdf` or
18
+ `.png`.
19
+
20
+ Optionally install the commit hooks, which mirror the CI lint gates:
21
+
22
+ ```bash
23
+ pip install pre-commit && pre-commit install
24
+ ```
25
+
26
+ ## The gates
27
+
28
+ All four must pass. CI (`.github/workflows/ci.yml`) runs the same four.
29
+
30
+ ```bash
31
+ python -m pytest -q # tests (CI: pytest -q on 3.10/3.11/3.12/3.13)
32
+ python -m ruff check . # lint the whole tree
33
+ python -m ruff format --check tests # tests/ must stay ruff-formatted
34
+ python -m mypy pandid # type check (blocking, not advisory)
35
+ ```
36
+
37
+ `ruff format` is enforced on `tests/` only. The rest of the tree is linted but
38
+ not auto-formatted, so don't reformat `pandid/` in a feature PR.
39
+
40
+ Keep a PR to one concern. If a change touches rendering, say so and show what
41
+ moved (see *Goldens* below).
42
+
43
+ ## Where things live
44
+
45
+ 1. **Topology** (`pandid/flowsheet.py`, `pandid/units.py`, `pandid/ports.py`,
46
+ `pandid/streams.py`) holds units, ports and stream connectivity.
47
+ 2. **Geometry.** `pandid/layout/` runs Sugiyama layering, then ordering, then
48
+ coordinates, emitting each unit's resolved `Frame`, and finally port-face
49
+ selection and label placement. `pandid/portgeom.py` is the single source of
50
+ truth for port geometry, and `pandid/routing/` is the visibility graph and
51
+ the A\* search over it.
52
+ 3. **Render** (`pandid/render/`) produces the SVG output and the symbol
53
+ registry, with `pandid/validate.py` and `pandid/document.py` beside it.
54
+
55
+ Geometry separates *intent* (`Pin`, written by `pin()`) from *result* (`Frame`,
56
+ written by the layout engine), which is what makes `layout()` idempotent.
57
+
58
+ `scripts/vendor_symbols.py` generates the symbol library into
59
+ `pandid/render/_vendored_symbols.py`, converting mxGraph stencil XML to SVG via
60
+ `scripts/mxgraph_to_svg.py`. `scripts/symbol_sheet.py` renders a catalogue.
61
+
62
+ ---
63
+
64
+ ## 1. Symbols come from the vendored stencils, never from hand-editing
65
+
66
+ `pandid/render/_vendored_symbols.py` is **generated**, and its own docstring says
67
+ so: draw.io-derived equipment symbols under Apache-2.0, generated by
68
+ `scripts/vendor_symbols.py`, not to be edited by hand, with attribution in
69
+ `NOTICE`. The file is regenerated wholesale, so a hand edit is lost the next
70
+ time anyone runs the generator and the drawing silently reverts.
71
+
72
+ The pipeline is:
73
+
74
+ ```
75
+ scripts/vendor_data/drawio/*.xml mxGraph stencil XML, vendored from jgraph/drawio
76
+
77
+ ├── scripts/mxgraph_to_svg.py stencil XML -> plain SVG
78
+
79
+ └── scripts/vendor_symbols.py KIND_MAP: (kind, variant) -> stencil shape + ports
80
+
81
+
82
+ pandid/render/_vendored_symbols.py generated; registered last, overriding
83
+ the hand-drawn defaults of the same kind
84
+ ```
85
+
86
+ To add or change an equipment symbol:
87
+
88
+ 1. Add an entry to `KIND_MAP` in `scripts/vendor_symbols.py`, naming the stencil
89
+ file, the shape, and where each port lands. A port is either a named draw.io
90
+ `<constraint>` anchor (`"W"`, `"E"`, `"N"`, `"S"`, …) or an explicit
91
+ placement on a bounding-box edge.
92
+ 2. Regenerate:
93
+
94
+ ```bash
95
+ python scripts/vendor_symbols.py
96
+ ```
97
+
98
+ 3. Review it visually. `python scripts/symbol_sheet.py out.svg` draws every
99
+ registered `(kind, variant)` in a labelled grid with a red dot on each port
100
+ anchor. It is a dev tool, not part of the package.
101
+ 4. Commit the regenerated `_vendored_symbols.py` with your `KIND_MAP` change,
102
+ never one without the other.
103
+
104
+ The shape's `aspect` comes across with it, as `Symbol.stretchable`. The stencil
105
+ author has already answered whether the drawing may be reshaped to fill a box of
106
+ another shape (`aspect="variable"`, mxGraph's default) or has to keep its
107
+ proportions (`aspect="fixed"`), and that is the same question a unit given an
108
+ explicit `width` and `height` asks. Every shape `KIND_MAP` names today is a
109
+ variable one, because the 24 fixed shapes in the stencil set are draw.io's own
110
+ instrument balloons, which `pandid` draws itself, so no generated symbol carries
111
+ the keyword. Do not set it by hand on one: change the stencil mapping, or say so
112
+ in `symbols.py` if the symbol is hand-drawn.
113
+
114
+ Only a handful of symbols are hand-drawn in `pandid/render/symbols.py`: the
115
+ Feed/Product boundary flags, the variable-port Mixer and Splitter, and the
116
+ ISA-5.1 instrument balloons. Those are original primitives, not stencils. New
117
+ *equipment* should come from the stencils, so the sheet stays visually
118
+ consistent and `NOTICE` remains the whole attribution story.
119
+
120
+ ### Symbols that have to stretch
121
+
122
+ There is a third case, and today it has one member. The generator emits one
123
+ fixed-size `Symbol` per shape, and a fixed drawing lands in whatever box its
124
+ unit is given: reshaped to fill it, or held to its own proportions and centred
125
+ in it. Some artwork survives neither. Stretching a belt conveyor draws its
126
+ rollers as ellipses; holding its proportions instead draws a short conveyor
127
+ adrift in a long box. A symbol the user is meant to resize along *one* axis,
128
+ leaving the rest of it alone, cannot come out of the generator at all.
129
+
130
+ Such a symbol is written as a *builder* in `pandid/render/symbols.py`, taking the
131
+ size and returning a `Symbol` drawn at it, and `SymbolRegistry.for_unit()`
132
+ resolves it against the unit. Because the artwork is built to the size, the
133
+ `<defs>` entry is per size and the placed box is exactly the box it was drawn
134
+ in, so the scale factor is 1 and every rotation and mirror still works the way
135
+ it does for a fixed symbol.
136
+
137
+ It is still stencil-derived and still Apache-2.0. Adapting one means:
138
+
139
+ 1. write the builder in `pandid/render/symbols.py`, with a comment naming the
140
+ stencil file, the shape, and what was changed;
141
+ 2. record it in `ADAPTED_ELSEWHERE` in `scripts/vendor_symbols.py`, so the
142
+ mapping table stays the place provenance is looked up;
143
+ 3. name it and its file in `NOTICE` section 2. It is not an original primitive
144
+ and does not belong in the section 1 paragraph that lists those.
145
+
146
+ Do not reach for this to avoid vendoring. A symbol with a fixed size goes
147
+ through `KIND_MAP` like everything else.
148
+
149
+ ## 2. Every port must land on drawn ink
150
+
151
+ A nozzle floating in whitespace draws a stream that fails to touch its
152
+ equipment. `tests/test_symbol_invariants.py` enforces this over **every**
153
+ registered `(kind, variant)`, not just the ones the examples use:
154
+
155
+ - the symbol SVG parses and declares a `stroke-width`;
156
+ - every port, and every alternate face in `port_faces`, lies inside the symbol's
157
+ declared bounding box;
158
+ - every port and alternate is within `GEOM_TOL` (2 units) of the nearest drawn
159
+ stroke, measured against the SVG primitives flattened to line segments;
160
+ - no two *different* ports resolve to the same point, which would stack two
161
+ streams on one pixel;
162
+ - and the same again on a *rendered sheet*, with every unit forced into box
163
+ shapes nothing is drawn at: much wider than tall, much taller than wide,
164
+ square. A unit given an explicit `width`/`height` is drawn at that box, so
165
+ this is where the artwork and the ports can drift apart: the rectangle the
166
+ drawing lands in is read back out of the SVG and the resolved nozzle is
167
+ measured against it.
168
+
169
+ That last one is what `Symbol.stretchable` answers. A symbol that may be
170
+ reshaped fills the box, and its ports map onto it linearly. One that may not,
171
+ an instrument balloon being a circle because ISA-5.1 says a circle, keeps its
172
+ aspect and is centred, and `pandid.portgeom.ink_box` is what keeps its ports on
173
+ the drawing rather than out in the whitespace beside it. The vendored symbols
174
+ take the answer from the stencil's own `aspect` attribute, as section 1 above
175
+ describes.
176
+
177
+ If that suite fails on your symbol, **move the port onto the geometry**. Adding
178
+ the symbol to `_KNOWN_GEOMETRY_GAPS` is not the fix, and a PR that does it will
179
+ be sent back. That set fences off three pre-existing, *deliberate* cases: the
180
+ pump and compressor suction nozzles, which sit in the mouth of an opening the
181
+ casing is drawn around.
182
+
183
+ The one legitimate escape hatch is `Symbol.faceless_ports`. An instrument
184
+ balloon is a circle, so its signal connections have no face of their own and may
185
+ offer the same faces as each other. Faceless ports are still checked against the
186
+ nozzles that do own a face, and equipment nozzles are never faceless.
187
+
188
+ ## 3. Goldens: a changed golden means changed rendering
189
+
190
+ `tests/golden/*.svg` are committed SVG fixtures, one per fixed scenario, and
191
+ `tests/test_golden.py` renders each scenario and compares. They are *source*,
192
+ not build output, which is why `.gitignore`'s blanket `*.svg` rule carries a
193
+ `!tests/golden/*.svg` negation.
194
+
195
+ The comparison runs on normalized text (`_normalize` canonicalizes the `<defs>`
196
+ ordering, which depends on the process hash seed and on nothing about the
197
+ diagram). Every other line compares verbatim, so a real regression fails.
198
+
199
+ After an *intentional* rendering change:
200
+
201
+ ```bash
202
+ PANDID_UPDATE_GOLDEN=1 python -m pytest tests/test_golden.py -q
203
+ ```
204
+
205
+ (on Windows PowerShell: `$env:PANDID_UPDATE_GOLDEN=1; python -m pytest tests/test_golden.py -q`)
206
+
207
+ Then **read `git diff tests/golden/` before committing**. A golden diff is the
208
+ diagram changing for every user, so the PR has to say what changed and why, with
209
+ the before/after visible. "the test was red" is not a reason. If you cannot
210
+ explain the diff, you have found a bug, not a golden that needed updating.
211
+
212
+ `tests/golden/README.md` explains why the fixtures are rebuilt inline in
213
+ `test_golden.py` rather than by running `examples/*.py`. Those scripts write
214
+ into `examples/`, and `03`'s title block would stamp today's date into the
215
+ fixture. Read it before adding a scenario.
216
+
217
+ ## 4. Port geometry goes through `pandid/portgeom.py`
218
+
219
+ Layout, routing, and rendering must never compute a port position
220
+ independently. They all call into `pandid/portgeom.py`, the single source of truth
221
+ for a unit's resolved size and its port coordinates.
222
+
223
+ The bug it prevents actually happened: the renderer forgot a mirror flip the
224
+ router had applied, and every mirrored unit's streams drew visually detached
225
+ from their nozzles.
226
+
227
+ - `resolve_size(unit)` gives the intrinsic `(w, h)` of a unit's placed box.
228
+ - `symbol_to_box(...)` maps a point from the symbol's own coordinates into the
229
+ placed box, applying mirroring **first** and then the clockwise quarter turn,
230
+ which is exactly the order the renderer's SVG transform composes in.
231
+ - `ink_box(...)` gives the rectangle inside that box the artwork actually
232
+ occupies: the whole of it for a symbol that may be reshaped, and the centred,
233
+ aspect-preserving part of it for one that may not. Ports are resolved against
234
+ *that*, so a symbol drawn smaller than its box keeps its nozzles on the
235
+ drawing. `unit_box()` stays the full placed box, since that is the space the
236
+ unit occupies and what the router has to route around.
237
+ - `port_point(unit, frame, name)` is where a stream visually attaches.
238
+ - `port_anchor(unit, frame, name)` is the routing anchor, projected onto the
239
+ bounding-box edge, plus the outward direction.
240
+ - `unit_box(unit, frame)` is the true drawn bounding box the router treats as an
241
+ obstacle.
242
+
243
+ Every one of these takes the resolved box explicitly rather than reading
244
+ `unit.frame`, so the same code works during layout (on a `_Slot`) and afterwards
245
+ (on a `Frame`). If you find yourself writing `frame.x + something` to find a
246
+ nozzle, stop and use `portgeom`.
247
+
248
+ The related invariant on the geometry model: `Pin` is *intent*, written only by
249
+ `Unit.pin()` and never by the engine, and `Frame` is *result*, written only by
250
+ the layout engine. The solver reseeds from `Pin` on every run, which is what
251
+ makes `layout()` idempotent. Don't write coordinates back onto a `Pin`.
252
+
253
+ ---
254
+
255
+ ## Examples and the gallery
256
+
257
+ `examples/*.py` are runnable scripts and are checked by `ruff`. They render into
258
+ `examples/` (gitignored). The gallery under `docs/gallery/` is generated by
259
+ running them and copying the output, as `docs/gallery/README.md` describes. If
260
+ you add an example, add it to the gallery in the same PR, and consider adding
261
+ the scenario to `tests/test_golden.py`.
262
+
263
+ ## Releasing
264
+
265
+ The version is written in exactly one place, `pandid/__init__.py`. `pyproject.toml`
266
+ declares it `dynamic` and the build backend reads that literal, so there is no
267
+ second copy to forget. To cut a release, bump `__version__`, move the CHANGELOG
268
+ `[Unreleased]` heading to the new number, merge, then push the matching tag.
269
+
270
+ ```bash
271
+ git tag v0.1.0 && git push origin v0.1.0
272
+ ```
273
+
274
+ `.github/workflows/release.yml` takes it from there. It re-runs the four gates
275
+ against the tagged commit, refuses to build if the tag and `pandid.__version__`
276
+ disagree, builds the sdist and wheel, and publishes to PyPI over Trusted
277
+ Publishing. Nothing is uploaded from a laptop and there is no API token to leak.
278
+
279
+ ## Reporting a bug
280
+
281
+ Include the flowsheet that reproduces it (the topology is usually 15 lines) and
282
+ the SVG you got. `fs.validate()` returns the engine's own findings, errors first
283
+ and then warnings, and is usually the fastest way to describe what went wrong.
284
+
285
+ ## Licence
286
+
287
+ The engine is under the [PolyForm Small Business License
288
+ 1.0.0](https://polyformproject.org/licenses/small-business/1.0.0), free for
289
+ individuals, research, teaching and small companies. A commercial licence is
290
+ required above 100 people or 1,000,000 USD revenue. It is source-available
291
+ rather than OSI open source.
292
+
293
+ By opening a pull request you agree that your contribution is licensed under
294
+ those same terms, and that the maintainer may also offer it under a commercial
295
+ licence. If that is not something you want, say so in the PR and it will not be
296
+ merged rather than left ambiguous.
297
+
298
+ Symbols derived from the draw.io / diagrams.net P&ID stencils are **Apache-2.0**
299
+ and stay that way. That licence permits the surrounding work to be licensed
300
+ differently only while its own terms and attribution are preserved. If you
301
+ vendor anything new, add it to the file list in `NOTICE` in the same PR.