feat(pid): symbol catalogue framework — instruments, markers, connectors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-15 15:14:55 +03:00
co-authored by Claude Opus 5
parent a0b16f399b
commit 6cccadee84
6 changed files with 718 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
# engineering/pid/geometry.py
"""Primitive-spec helpers for authoring symbols (block-local millimetres).
Each helper returns a ``block_define`` entity/ATTDEF spec (see
``backends/block_specs.py``), so a symbol builder composes plain dicts and the
same spec is validated and written identically on both engines.
"""
from __future__ import annotations
import math
def line(x1: float, y1: float, x2: float, y2: float) -> dict:
return {"type": "line", "x1": float(x1), "y1": float(y1), "x2": float(x2), "y2": float(y2)}
def circle(cx: float, cy: float, r: float) -> dict:
return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r)}
def arc(cx: float, cy: float, r: float, start_deg: float, end_deg: float) -> dict:
return {
"type": "arc",
"cx": float(cx),
"cy": float(cy),
"r": float(r),
"start_deg": float(start_deg),
"end_deg": float(end_deg),
}
def polyline(points, closed: bool = True, bulges=None) -> dict:
spec = {
"type": "polyline",
"points": [[float(x), float(y)] for x, y in points],
"closed": closed,
}
if bulges is not None:
spec["bulges"] = [float(b) for b in bulges]
return spec
def text(
t: str,
x: float,
y: float,
height: float,
align: str = "middle_center",
rotation_deg: float = 0.0,
) -> dict:
return {
"type": "text",
"text": t,
"x": float(x),
"y": float(y),
"height": float(height),
"align": align,
"rotation_deg": float(rotation_deg),
}
def solid(points) -> dict:
return {"type": "solid", "points": [[float(x), float(y)] for x, y in points]}
def dashed_line(
x1: float, y1: float, x2: float, y2: float, on: float = 1.0, off: float = 1.0
) -> list[dict]:
"""A dashed line as short LINE primitives, so the block needs no linetype."""
length = math.hypot(x2 - x1, y2 - y1)
if length <= 0:
raise ValueError("dashed_line: zero-length line")
if on <= 0 or off < 0:
raise ValueError("dashed_line: 'on' must be > 0 and 'off' >= 0")
ux, uy = (x2 - x1) / length, (y2 - y1) / length
out: list[dict] = []
pos = 0.0
while pos < length - 1e-9:
end = min(pos + on, length)
out.append(line(x1 + ux * pos, y1 + uy * pos, x1 + ux * end, y1 + uy * end))
pos = end + off
return out
def regular_polygon(
cx: float, cy: float, r: float, n: int, start_deg: float = 0.0
) -> list[tuple[float, float]]:
return [
(
cx + r * math.cos(math.radians(start_deg + 360.0 * i / n)),
cy + r * math.sin(math.radians(start_deg + 360.0 * i / n)),
)
for i in range(n)
]
def attdef(
tag: str,
x: float,
y: float,
height: float,
align: str = "center",
invisible: bool = False,
default: str = "",
) -> dict:
return {
"tag": tag,
"prompt": tag,
"default": default,
"x": float(x),
"y": float(y),
"height": float(height),
"align": align,
"invisible": invisible,
}
def _arc_extent(cx, cy, r, start_deg, end_deg):
"""Points along the arc at 1° steps plus both ends (CCW from start to end)."""
pts = []
sweep = (end_deg - start_deg) % 360.0 or 360.0
for k in range(0, int(sweep) + 1):
a = math.radians(start_deg + k)
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
a = math.radians(end_deg)
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
return pts
def bbox_of(primitives) -> tuple[float, float, float, float]:
"""Extent of the drawn geometry (text counts by its insertion point only)."""
xs: list[float] = []
ys: list[float] = []
for p in primitives:
kind = p["type"]
if kind == "line":
xs += [p["x1"], p["x2"]]
ys += [p["y1"], p["y2"]]
elif kind == "circle":
xs += [p["cx"] - p["r"], p["cx"] + p["r"]]
ys += [p["cy"] - p["r"], p["cy"] + p["r"]]
elif kind == "arc":
for x, y in _arc_extent(p["cx"], p["cy"], p["r"], p["start_deg"], p["end_deg"]):
xs.append(x)
ys.append(y)
elif kind in ("polyline", "solid"):
for x, y in p["points"]:
xs.append(x)
ys.append(y)
elif kind == "text":
xs.append(p["x"])
ys.append(p["y"])
if not xs:
return (0.0, 0.0, 0.0, 0.0)
return (min(xs), min(ys), max(xs), max(ys))
+404
View File
@@ -0,0 +1,404 @@
# engineering/pid/symbols.py
"""The P&ID symbol catalogue: symbols as data, blocks on demand.
Every symbol is a ``SymbolSpec`` — typed primitives in symbol-local mm, ATTDEFs,
named ports with an outward direction, a declared bbox — authored from
ISO 10628-2 (equipment, valves) or ISA-5.1 (instrumentation). Builders live in
``symbols_valves.py``, ``symbols_equipment.py`` and ``symbols_vessels.py`` and
register here; this module holds the framework plus the instrument bubble,
markers and connectors.
"""
from __future__ import annotations
import hashlib
import json
import math
from collections.abc import Callable
from dataclasses import dataclass, field
from .geometry import (
attdef,
bbox_of,
circle,
dashed_line,
line,
polyline,
regular_polygon,
solid,
)
#: Written into every INSERT's XDATA; bump on any change to shipped geometry or ports.
CATALOG_VERSION = "1"
BUBBLE_RADIUS = 5.0
FAMILY_LAYER = {
"valve": "PROCESS-VALVES",
"rotating": "PROCESS-EQUIPMENT",
"vessel": "PROCESS-EQUIPMENT",
"heat": "PROCESS-EQUIPMENT",
"misc": "PROCESS-EQUIPMENT",
"instrument": "INSTRUMENT-SYMBOL",
"connector": "PROCESS-EQUIPMENT",
"marker": "line",
}
INSTRUMENT_TYPES = ("discrete", "dcs", "computer", "plc")
INSTRUMENT_LOCATIONS = ("field", "primary", "auxiliary", "primary_rear", "auxiliary_rear")
TAG_HEIGHT = 2.5
DESC_HEIGHT = 2.0
@dataclass(frozen=True)
class Port:
name: str
x: float
y: float
direction_deg: float | None # outward direction a line leaves along; None = radial
kind: str = "process" # "process" | "signal"
radius: float = 0.0 # radial ports only
def to_dict(self) -> dict:
return {
"name": self.name,
"x": self.x,
"y": self.y,
"direction_deg": self.direction_deg,
"kind": self.kind,
"radius": self.radius,
}
@dataclass(frozen=True)
class SymbolSpec:
name: str
family: str
symbol: str
variant: str | None
primitives: tuple[dict, ...]
attdefs: tuple[dict, ...]
ports: tuple[Port, ...]
bbox: tuple[float, float, float, float]
layer_class: str
source: str
parametric: bool = False
params: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"name": self.name,
"family": self.family,
"symbol": self.symbol,
"variant": self.variant,
"ports": [p.to_dict() for p in self.ports],
"bbox": list(self.bbox),
"layer_class": self.layer_class,
"source": self.source,
"parametric": self.parametric,
"params": dict(self.params),
}
Builder = Callable[..., SymbolSpec]
_REGISTRY: dict[str, tuple[str, Builder, dict[str, list[str]], dict | None]] = {}
def register(
symbol: str,
family: str,
builder: Builder,
variants: dict[str, list[str]] | None = None,
params_schema: dict | None = None,
) -> None:
if family not in FAMILY_LAYER:
raise ValueError(f"unknown family {family!r}")
_REGISTRY[symbol] = (family, builder, dict(variants or {}), params_schema)
def resolve(symbol: str, **options) -> SymbolSpec:
"""Build the SymbolSpec for ``symbol`` with the given variant options."""
if symbol not in _REGISTRY:
raise ValueError(
f"unknown symbol {symbol!r}; valid symbols: {', '.join(sorted(_REGISTRY))}"
)
family, builder, variants, schema = _REGISTRY[symbol]
for key, value in options.items():
if value is None:
continue
if key == "params":
if schema is None:
raise ValueError(f"{symbol} is not parametric; 'params' is only for vessels")
continue
if key not in variants:
raise ValueError(f"{symbol} takes no option {key!r}")
if value not in variants[key]:
raise ValueError(
f"{symbol}: {key} must be one of {', '.join(variants[key])}, got {value!r}"
)
return builder(**{k: v for k, v in options.items() if v is not None})
def list_symbols(family: str | None = None) -> list[dict]:
rows = []
for symbol, (fam, builder, variants, schema) in sorted(_REGISTRY.items()):
if family and fam != family:
continue
base = builder()
rows.append(
{
"symbol": symbol,
"family": fam,
"variants": variants,
"ports": [p.to_dict() for p in base.ports],
"parametric": base.parametric,
"params_schema": schema or {},
"source": base.source,
"block_name": base.name,
}
)
return rows
def all_specs() -> list[SymbolSpec]:
"""Every base symbol plus every enumerable variant (for tests and the contact sheet)."""
specs: list[SymbolSpec] = []
for _symbol, (_fam, builder, variants, _schema) in sorted(_REGISTRY.items()):
if not variants:
specs.append(builder())
continue
keys = sorted(variants)
combos: list[dict] = [{}]
for key in keys:
combos = [{**c, key: v} for c in combos for v in variants[key]]
for combo in combos:
try:
specs.append(builder(**combo))
except ValueError:
continue # a combination the builder refuses (e.g. an actuator on a check valve)
return specs
def transform_port(port: Port, x: float, y: float, rotation_deg: float, scale: float) -> dict:
"""Symbol-local port -> WCS, for an INSERT at (x, y) rotated by ``rotation_deg``
and uniformly scaled."""
a = math.radians(rotation_deg)
lx, ly = port.x * scale, port.y * scale
wx = x + lx * math.cos(a) - ly * math.sin(a)
wy = y + lx * math.sin(a) + ly * math.cos(a)
direction = None if port.direction_deg is None else (port.direction_deg + rotation_deg) % 360.0
return {
"name": port.name,
"x": wx,
"y": wy,
"direction_deg": direction,
"kind": port.kind,
"radius": port.radius * scale,
"inferred": False,
}
def params_hash(params: dict) -> str:
"""First 8 hex chars of SHA-256 over the canonical JSON of ``params`` (spec 4.5)."""
canonical = json.dumps(params, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode()).hexdigest()[:8].upper()
def tag_attdefs(ymax: float, with_desc: bool = True) -> tuple[dict, ...]:
"""``TAG`` (and ``DESC``) above the body, per spec 4.7."""
out = [attdef("TAG", 0.0, ymax + 3.5, TAG_HEIGHT)]
if with_desc:
out.append(attdef("DESC", 0.0, ymax + 6.5, DESC_HEIGHT))
return tuple(out)
def make_spec(
name: str,
family: str,
symbol: str,
variant: str | None,
primitives,
attdefs,
ports,
source: str,
bbox=None,
parametric: bool = False,
params: dict | None = None,
) -> SymbolSpec:
prims = tuple(primitives)
return SymbolSpec(
name=name,
family=family,
symbol=symbol,
variant=variant,
primitives=prims,
attdefs=tuple(attdefs),
ports=tuple(ports),
bbox=tuple(bbox) if bbox else bbox_of(prims),
layer_class=FAMILY_LAYER[family],
source=source,
parametric=parametric,
params=dict(params or {}),
)
# ── instrument bubble (ISA-5.1-2009 Table 5.4.1) ─────────────────────────────
def build_instrument(type: str = "discrete", location: str = "field") -> SymbolSpec:
if type not in INSTRUMENT_TYPES:
raise ValueError(f"instrument: type must be one of {', '.join(INSTRUMENT_TYPES)}")
if location not in INSTRUMENT_LOCATIONS:
raise ValueError(f"instrument: location must be one of {', '.join(INSTRUMENT_LOCATIONS)}")
r = BUBBLE_RADIUS
prims: list[dict] = [circle(0, 0, r)]
square = polyline([(-r, -r), (r, -r), (r, r), (-r, r)])
if type == "dcs":
prims.append(square)
elif type == "computer":
prims.append(polyline(regular_polygon(0, 0, r, 6, 0.0)))
elif type == "plc":
prims += [square, polyline([(0, r), (r, 0), (0, -r), (-r, 0)])]
ys = {
"field": [],
"primary": [0.0],
"auxiliary": [0.8, -0.8],
"primary_rear": [0.0],
"auxiliary_rear": [0.8, -0.8],
}[location]
for y in ys:
prims += dashed_line(-r, y, r, y) if location.endswith("_rear") else [line(-r, y, r, y)]
attdefs = (
attdef("FUNC", 0.0, 2.0, DESC_HEIGHT, align="middle_center"),
attdef("LOOP", 0.0, -2.0, DESC_HEIGHT, align="middle_center"),
)
ports = (Port("signal", 0.0, 0.0, None, "signal", r),)
return make_spec(
f"PID_INST_{type.upper()}_{location.upper()}",
"instrument",
"instrument",
f"{type}_{location}",
prims,
attdefs,
ports,
"ISA-5.1-2009 Table 5.4.1",
bbox=(-r, -r, r, r),
)
register(
"instrument",
"instrument",
build_instrument,
variants={"type": list(INSTRUMENT_TYPES), "location": list(INSTRUMENT_LOCATIONS)},
)
# ── markers (decoration on a line; never graph nodes) ────────────────────────
MARKERS = ("arrow_flow", "mark_pneumatic", "mark_capillary", "mark_hydraulic", "mark_data")
def build_marker(which: str) -> SymbolSpec:
shapes = {
"arrow_flow": [solid([(0, 0), (-3, 1.2), (-3, -1.2)])],
"mark_pneumatic": [line(-1.5, -1.5, 0, 1.5), line(0.5, -1.5, 2, 1.5)],
"mark_capillary": [line(-1.2, -1.2, 1.2, 1.2), line(-1.2, 1.2, 1.2, -1.2)],
"mark_hydraulic": [line(-1, 1.5, -1, -1.5), line(-1, -1.5, 1.5, -1.5)],
"mark_data": [circle(0, 0, 0.8)],
}
if which not in shapes:
raise ValueError(f"unknown marker {which!r}; valid markers: {', '.join(MARKERS)}")
return make_spec(
f"PID_MARKER_{which.upper()}",
"marker",
which,
None,
shapes[which],
(),
(),
"ISA-5.1-2009 Table 5.3.1",
)
def _marker_builder(which: str) -> Builder:
def build() -> SymbolSpec:
return build_marker(which)
return build
for _which in MARKERS:
register(_which, "marker", _marker_builder(_which))
# ── connectors ───────────────────────────────────────────────────────────────
def build_connector(which: str = "offpage", direction: str = "out") -> SymbolSpec:
if which == "offpage":
if direction == "out":
pts = [(-8, -3), (4, -3), (8, 0), (4, 3), (-8, 3)]
port = Port("process", -8.0, 0.0, 180.0, "process")
elif direction == "in":
pts = [(8, -3), (-4, -3), (-8, 0), (-4, 3), (8, 3)]
port = Port("process", 8.0, 0.0, 0.0, "process")
else:
raise ValueError(f"offpage: direction must be one of in, out, got {direction!r}")
attdefs = (
attdef(
"TAG",
-2.0 if direction == "out" else 2.0,
0.0,
DESC_HEIGHT,
align="middle_center",
),
attdef("LINK", 0.0, -5.0, DESC_HEIGHT, invisible=True),
)
return make_spec(
f"PID_CONNECTOR_OFFPAGE_{direction.upper()}",
"connector",
"offpage",
direction,
[polyline(pts)],
attdefs,
(port,),
"PIP PIC001 off-page connector",
bbox=(-8, -3, 8, 3),
)
if which == "tie_in":
return make_spec(
"PID_CONNECTOR_TIE_IN",
"connector",
"tie_in",
None,
[circle(0, 0, 3)],
(attdef("TAG", 0.0, 0.0, DESC_HEIGHT, align="middle_center"),),
(Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, 0.0, 0.0)),
"PIP PIC001 tie-in point",
)
if which == "spec_break":
return make_spec(
"PID_CONNECTOR_SPEC_BREAK",
"connector",
"spec_break",
None,
[line(0, -3, 0, 3)],
(attdef("TAG", 0.0, 4.5, DESC_HEIGHT),),
(Port("in", -0.3, 0.0, 180.0), Port("out", 0.3, 0.0, 0.0)),
"PIP PIC001 specification break",
bbox=(-0.3, -3, 0.3, 4.5),
)
raise ValueError(f"unknown connector {which!r}")
register(
"offpage",
"connector",
lambda direction="out": build_connector("offpage", direction),
variants={"direction": ["in", "out"]},
)
register("tie_in", "connector", lambda: build_connector("tie_in"))
register("spec_break", "connector", lambda: build_connector("spec_break"))
# Builders for valves, equipment and vessels register themselves on import.
from . import symbols_equipment as _symbols_equipment # noqa: E402,F401
from . import symbols_valves as _symbols_valves # noqa: E402,F401
from . import symbols_vessels as _symbols_vessels # noqa: E402,F401
+1
View File
@@ -0,0 +1 @@
"""Rotating equipment, heat transfer and miscellaneous inline symbols - filled in by Task 9."""
+1
View File
@@ -0,0 +1 @@
"""Valve bodies and actuators - filled in by Task 8."""
+1
View File
@@ -0,0 +1 @@
"""Parametric vessels - filled in by Task 10."""
+155
View File
@@ -0,0 +1,155 @@
"""Every catalogue symbol: valid primitives, honest bbox, ports on the boundary."""
from __future__ import annotations
import re
import pytest
from backends.block_specs import validate_attdef_specs, validate_entity_specs
from engineering.pid.geometry import bbox_of
from engineering.pid.symbols import (
BUBBLE_RADIUS,
CATALOG_VERSION,
INSTRUMENT_LOCATIONS,
INSTRUMENT_TYPES,
Port,
all_specs,
list_symbols,
resolve,
transform_port,
)
NAME_RE = re.compile(r"^PID_[A-Z0-9_]+$")
SOURCE_RE = re.compile(r"^(ISO 10628-2|ISA-5\.1|PIP PIC001)")
EPS = 1e-6
def _ids():
return [spec.name for spec in all_specs()]
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
def test_primitives_and_attdefs_validate(spec):
validate_entity_specs(list(spec.primitives))
validate_attdef_specs(list(spec.attdefs))
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
def test_declared_bbox_contains_every_primitive(spec):
xmin, ymin, xmax, ymax = bbox_of(spec.primitives)
dxmin, dymin, dxmax, dymax = spec.bbox
assert dxmin - EPS <= xmin and dymin - EPS <= ymin
assert xmax <= dxmax + EPS and ymax <= dymax + EPS
assert dxmax > dxmin and dymax > dymin
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
def test_ports_sit_on_the_boundary_or_at_the_centre(spec):
if spec.family == "marker":
assert spec.ports == ()
return
assert spec.ports, "every non-marker symbol needs at least one port"
xmin, ymin, xmax, ymax = spec.bbox
for port in spec.ports:
if port.direction_deg is None:
assert port.radius > 0 and (port.x, port.y) == (0.0, 0.0)
continue
on_edge = (
abs(port.x - xmin) < EPS
or abs(port.x - xmax) < EPS
or abs(port.y - ymin) < EPS
or abs(port.y - ymax) < EPS
)
assert on_edge, (
f"{spec.name}.{port.name} at ({port.x}, {port.y}) is inside bbox {spec.bbox}"
)
assert port.direction_deg in (0.0, 90.0, 180.0, 270.0)
assert port.kind in ("process", "signal")
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
def test_naming_source_and_layer(spec):
assert NAME_RE.match(spec.name)
assert SOURCE_RE.match(spec.source), spec.source
assert spec.layer_class in (
"PROCESS-VALVES",
"PROCESS-EQUIPMENT",
"INSTRUMENT-SYMBOL",
"line",
)
tags = [a["tag"] for a in spec.attdefs]
assert len(tags) == len(set(tags))
for prim in spec.primitives:
assert prim.get("layer", "0") == "0"
def test_catalogue_names_are_unique_and_versioned():
names = [s.name for s in all_specs()]
assert len(names) == len(set(names))
assert CATALOG_VERSION == "1"
def test_instrument_generator_covers_four_types_by_five_locations():
specs = {
(t, loc): resolve("instrument", type=t, location=loc)
for t in INSTRUMENT_TYPES
for loc in INSTRUMENT_LOCATIONS
}
assert len(specs) == 20
field = specs[("discrete", "field")]
assert field.name == "PID_INST_DISCRETE_FIELD"
assert [a["tag"] for a in field.attdefs] == ["FUNC", "LOOP"]
assert field.ports == (Port("signal", 0.0, 0.0, None, "signal", BUBBLE_RADIUS),)
assert sum(1 for p in field.primitives if p["type"] == "circle") == 1
primary = specs[("discrete", "primary")]
assert sum(1 for p in primary.primitives if p["type"] == "line") == 1
aux_rear = specs[("plc", "auxiliary_rear")]
assert sum(1 for p in aux_rear.primitives if p["type"] == "line") > 2, "dashed pair"
assert sum(1 for p in aux_rear.primitives if p["type"] == "polyline") == 2, "square + diamond"
hexagon = next(p for p in specs[("computer", "field")].primitives if p["type"] == "polyline")
assert len(hexagon["points"]) == 6
def test_unknown_symbol_or_option_names_the_valid_values():
with pytest.raises(ValueError, match="instrument"):
resolve("thermometer")
with pytest.raises(ValueError, match="field"):
resolve("instrument", type="dcs", location="ceiling")
def test_markers_and_connectors():
arrow = resolve("arrow_flow")
assert arrow.family == "marker" and arrow.layer_class == "line"
assert any(p["type"] == "solid" for p in arrow.primitives)
for which in ("mark_pneumatic", "mark_capillary", "mark_hydraulic", "mark_data"):
assert resolve(which).family == "marker"
out = resolve("offpage", direction="out")
inn = resolve("offpage", direction="in")
assert out.name == "PID_CONNECTOR_OFFPAGE_OUT" and inn.name == "PID_CONNECTOR_OFFPAGE_IN"
assert out.ports[0] == Port("process", -8.0, 0.0, 180.0, "process")
assert inn.ports[0] == Port("process", 8.0, 0.0, 0.0, "process")
assert [a["tag"] for a in out.attdefs] == ["TAG", "LINK"]
assert next(a for a in out.attdefs if a["tag"] == "LINK")["invisible"] is True
def test_transform_port_applies_rotation_and_scale_about_the_insertion():
port = Port("out", 4.0, 0.0, 0.0, "process")
moved = transform_port(port, 100.0, 50.0, 90.0, 2.0)
assert moved["x"] == pytest.approx(100.0) and moved["y"] == pytest.approx(58.0)
assert moved["direction_deg"] == pytest.approx(90.0)
radial = transform_port(Port("signal", 0.0, 0.0, None, "signal", 5.0), 10.0, 10.0, 45.0, 3.0)
assert radial["direction_deg"] is None and radial["radius"] == pytest.approx(15.0)
def test_list_symbols_reports_variants_and_ports():
rows = {row["symbol"]: row for row in list_symbols()}
assert rows["instrument"]["variants"] == {
"type": list(INSTRUMENT_TYPES),
"location": list(INSTRUMENT_LOCATIONS),
}
assert rows["offpage"]["variants"] == {"direction": ["in", "out"]}
assert rows["arrow_flow"]["ports"] == []
assert list_symbols(family="marker") and all(
r["family"] == "marker" for r in list_symbols(family="marker")
)