Files
Umut CANandClaude Opus 5.5 704a44b190 test(understand): the synthetic plant pair - a schematic P&ID and its layout with known answers
Every track H reader is measured on this pair, never on a client drawing
(spec section 14). tests/fixtures/plant_pair.py writes pid.dxf and layout.dxf
with invented names and returns their truth, computed from the positions it
placed:

- the P&ID is room 1 of the layout stretched 1.3x and room 2 rearranged, so
  21.8 % of the 55 tag pairs fall within 10 % of the median ratio (schematic)
  while room 1 alone is 100 % at 1.3;
- service layers in English and Dutch, Russian supply / return words, one CIP
  return drawn on the supply layer, sizes spelled as drafters do (an MTEXT
  format run, %%c, \U+2205, the U+2300 sign, o-stroke, SMS, DN, 20x27), a
  reducer, inline valves, an arc bend, a segment drawn twice;
- electrical texts in Russian with the power stated for all loads but one;
- a layout declaring inches over millimetre geometry, a stray line 5,000 km
  out, two plan copies 100 m apart, wall outlines, room labels and wiring
  callouts in four spellings.

Run lengths are the rectilinear MST of each run's tags, cable metres are
the Manhattan length rounded up after the 20 % allowance; the generator
refuses to write a pair whose own premises fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:42:09 +03:00

667 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The synthetic plant pair: a schematic P&ID and its layout, with known answers.
Every track H reader is measured on this pair and never on a client drawing
(spec §14). It reproduces each field finding of the design spec (§1, §15) with
invented names and invented numbers:
* **Tags in both drawings** - T101..T104, M11, CP-1 in room 1; T201..T203, M12,
CP-2 in room 2. T105 is on the P&ID only, so the run to it is not routable.
* **The P&ID is not to scale** - room 1 is the layout stretched 1.3x about the
origin, room 2 is rearranged outright. The generator computes the scale
statistics of its own placement (pairs at least 2 m apart, the ratio of their
distances, the share within ±10 % of the median) and refuses to write a pair
that would not read as ``schematic``.
* **Service layers in English and Dutch** - ``P_product piping``,
``P_cipsupplyline``, ``P_cipreturnline``, ``P_ijswater`` (ice water, a utility
outside the default takeoff scope), valves and the reducer on ``KLEPPEN``.
* **Russian supply / return words** - ПОДАЧА beside the CIP supply runs,
ВОЗВРАТ beside the returns, and one return (``CR2``) drawn on the *supply*
layer: only the word says which way it runs.
* **Size labels as drafters write them** - ``{\\fArial|b0;Ø51}`` (an MTEXT format
run), ``Ø51``, ``%%c38``, ``\\U+2205 38``, ``⌀38``, ``ø38``, ``SMS51``, ``DN20``,
``DN25``, ``20x27``; each 300 mm from the segment it names and at least 600 mm
from every other run (the generator checks it).
* **Fittings on runs** - a reducer (``VERLOOPSTUK``) between Ø51 and Ø38 on
``PR3``, inline valves (``KLEP_VLINDER``) on ``PR2`` and ``CS2``; the pipe stops
at each fitting's box and continues on its far side.
* **A double-drawn segment** - on ``PR2`` a LINE lies exactly over an LWPOLYLINE
segment; it must count once.
* **Electrical texts in Russian** - power, phases, voltage, N and PE, ЧРП (VFD)
1500 mm right of each load's tag; the heater on T202 states no power.
* **The layout lies about its units** - ``$INSUNITS = 1`` (inches) over
millimetre geometry; one stray LINE 5,000 km away; two copies of the plan
100 m apart, so every tag is on the layout twice and is unique only within a
copy.
* **Rooms** - wall outlines (200 mm walls) and labels ``ROOM 1 PROCESS HALL`` /
``ПОМЕЩЕНИЕ 2 МОЙКА``; ``Wiring to CP1`` style MULTILEADER callouts from each
load to its panel, in four spellings.
Truth values are **computed here from the positions placed here** - Manhattan
distances, the rectilinear minimum spanning tree of each run's tags, the cable
lengths rounded up after the allowance - never typed. A position on the layout
is the insertion point of the tag's TEXT, which is also the centre of the
equipment circle it labels, so "the tag's position" and "the equipment's
position" are one point by construction.
"""
from __future__ import annotations
import itertools
import math
import statistics
from dataclasses import dataclass, field
from pathlib import Path
import ezdxf
from ezdxf import bbox as ezbbox
from ezdxf.math import Vec2
from ezdxf.render.mleader import ConnectionSide
Pt = tuple[float, float]
__all__ = ["ALLOWANCE", "LABEL_OFFSET", "build_plant_pair"]
#: The takeoffs' default allowance; the truth's ROUNDUP metres use it.
ALLOWANCE = 0.20
#: How far a size label or a service word sits from the segment it names.
LABEL_OFFSET = 300.0
#: The stretch of room 1 on the P&ID.
ROOM1_FACTOR = 1.3
#: The second plan copy on the layout is this far to the right of the first.
COPY_OFFSET: Pt = (100_000.0, 0.0)
#: The stray entity on the layout.
OUTLIER_START: Pt = (-5.0e9, 3.0e9)
#: Tank, pump and panel sizes (P&ID radii / layout radii / panel box).
PID_TANK_R, PID_PUMP_R = 600.0, 300.0
LAYOUT_TANK_R, LAYOUT_PUMP_R = 1000.0, 400.0
PANEL_W, PANEL_H = 1000.0, 400.0
TEXT_H = 250.0
#: Tag positions on the layout (first copy), mm. The P&ID positions follow.
LAYOUT_TAGS: dict[str, Pt] = {
"T101": (3000.0, 3000.0),
"T102": (8000.0, 3500.0),
"T103": (13500.0, 3000.0),
"T104": (17000.0, 8500.0),
"M11": (5500.0, 8200.0),
"CP-1": (1200.0, 10800.0),
"T201": (23500.0, 3200.0),
"T202": (28700.0, 3000.0),
"T203": (33000.0, 8800.0),
"M12": (26300.0, 8400.0),
"CP-2": (35000.0, 1300.0),
}
ROOM1_TAGS = ("T101", "T102", "T103", "T104", "M11", "CP-1")
#: Room 2 on the P&ID: rearranged, not scaled.
PID_ROOM2: dict[str, Pt] = {
"T201": (25000.0, 3000.0),
"T202": (29000.0, 14000.0),
"T203": (25500.0, 11000.0),
"M12": (32000.0, 4000.0),
"CP-2": (28000.0, 7500.0),
}
#: On the P&ID only: the run to it cannot be routed on the layout.
PID_ONLY = {"T105": (22100.0, 16500.0)}
ROOMS = (
{"number": "1", "name": "PROCESS HALL", "box": (0.0, 0.0, 20000.0, 12000.0)},
{
"number": "2",
"name": "МОЙКА",
"box": (20200.0, 0.0, 36000.0, 12000.0),
},
)
ROOM_LABELS = (
("ROOM 1 PROCESS HALL", (10000.0, 11000.0)),
(
"ПОМЕЩЕНИЕ 2 МОЙКА",
(28000.0, 11000.0),
),
)
OUTER_WALL = (-200.0, -200.0, 36200.0, 12200.0)
SUPPLY_WORD = "ПОДАЧА"
RETURN_WORD = "ВОЗВРАТ"
#: (tag, the Russian electrical text beside it, stated kW or None, VFD).
LOADS = (
("M11", "Насос P = 5,5 кВт\\P3 ф. 400 В + N + PE", 5.5, False),
("T102", "Мешалка P = 2,2 кВт\\P3 ф. 400 В + N + PE", 2.2, False),
("M12", "Насос CIP P = 7,5 кВт\\P3 ф. 400 В + N + PE\\PЧРП", 7.5, True),
("T202", "Нагреватель\\P3 ф. 400 В + N + PE", None, False),
)
#: (load tag, callout text on the layout, the panel it names).
CALLOUTS = (
("M11", "Wiring to CP1", "CP-1"),
("T102", "Wiring to CP 1", "CP-1"),
("M12", "Wiring to CP-2", "CP-2"),
("T202", "WIRING TO CP2", "CP-2"),
)
ELECTRICAL_DX = 1500.0
CALLOUT_DX, CALLOUT_DY = 1500.0, 1500.0
@dataclass(frozen=True)
class _Run:
"""One pipe run of the P&ID and what the takeoffs must find for it."""
id: str
layer: str
service: str
diameter: str
tags: tuple[str, ...]
status: str
paths: tuple[tuple[Pt, ...], ...]
labels: tuple[tuple[str, str, Pt, str], ...] # (TEXT | MTEXT, as drawn, insert, as read)
arcs: tuple[tuple[Pt, float, float, float], ...] = () # centre, radius, start, end (deg)
fittings: tuple[tuple[str, Pt, float], ...] = () # block, centre, rotation
words: tuple[tuple[str, Pt], ...] = ()
duplicate: tuple[Pt, Pt] | None = None
notes: tuple[str, ...] = field(default_factory=tuple)
RUNS: tuple[_Run, ...] = (
_Run(
"PR1",
"P_product piping",
"product",
"Ø51",
("T101", "T102"),
"A",
paths=(((4500.0, 4200.0), (9800.0, 4200.0)),),
labels=(("MTEXT", "{\\fArial|b0;Ø51}", (7150.0, 4500.0), "Ø51"),),
),
_Run(
"PR2",
"P_product piping",
"product",
"SMS51",
("M11", "T102", "T103"),
"B",
paths=(
((10400.0, 5150.0), (10400.0, 7000.0)),
((7150.0, 7000.0), (13850.0, 7000.0)),
((14150.0, 7000.0), (17250.0, 7000.0)),
((17550.0, 6700.0), (17550.0, 4500.0)),
((7150.0, 7000.0), (7150.0, 10360.0)),
),
labels=(("TEXT", "SMS51", (10700.0, 6075.0), "SMS51"),),
arcs=(((17250.0, 6700.0), 300.0, 0.0, 90.0),),
fittings=(("KLEP_VLINDER", (14000.0, 7000.0), 0.0),),
duplicate=((14150.0, 7000.0), (17250.0, 7000.0)),
notes=("T-junction at (10400, 7000)", "valve continued", "one segment drawn twice"),
),
_Run(
"PR3",
"P_product piping",
"product",
"Ø51",
("T103", "T104"),
"A",
paths=(
((18150.0, 3900.0), (20000.0, 3900.0), (20000.0, 7000.0)),
((20000.0, 7300.0), (20000.0, 11050.0), (21500.0, 11050.0)),
),
labels=(
("TEXT", "Ø51", (19075.0, 4200.0), "Ø51"),
("TEXT", "Ø51", (20300.0, 5450.0), "Ø51"),
("TEXT", "%%c38", (20300.0, 9175.0), "Ø38"),
("TEXT", "%%c38", (20750.0, 11350.0), "Ø38"),
),
fittings=(("VERLOOPSTUK", (20000.0, 7150.0), 90.0),),
notes=("reducer between the two sizes",),
),
_Run(
"PR4",
"P_product piping",
"product",
"DN20",
("T104", "T105"),
"C",
paths=(((22100.0, 11650.0), (22100.0, 15900.0)),),
labels=(("TEXT", "DN20", (22400.0, 13775.0), "DN20"),),
notes=("T105 is not on the layout",),
),
_Run(
"CS1",
"P_cipsupplyline",
"cip_supply",
"Ø38",
("T101", "T201"),
"A",
paths=(((25000.0, 2400.0), (25000.0, 1500.0), (3900.0, 1500.0), (3900.0, 3300.0)),),
labels=(
("MTEXT", "\\U+2205 38", (25300.0, 1950.0), "Ø38"),
("MTEXT", "\\U+2205 38", (14450.0, 1800.0), "Ø38"),
("MTEXT", "\\U+2205 38", (4200.0, 2400.0), "Ø38"),
),
words=((SUPPLY_WORD, (14450.0, 1200.0)),),
),
_Run(
"CS2",
"P_cipsupplyline",
"cip_supply",
"20x27",
("M12", "T201"),
"B",
paths=(
((25600.0, 3000.0), (27850.0, 3000.0)),
((28150.0, 3000.0), (32000.0, 3000.0), (32000.0, 3700.0)),
),
labels=(("TEXT", "20x27", (26725.0, 3300.0), "20x27"),),
fittings=(("KLEP_VLINDER", (28000.0, 3000.0), 0.0),),
words=((SUPPLY_WORD, (30075.0, 2700.0)),),
),
_Run(
"CR1",
"P_cipreturnline",
"cip_return",
"Ø38",
("T101", "T202"),
"A",
paths=(
(
(3300.0, 3900.0),
(2500.0, 3900.0),
(2500.0, 19000.0),
(29000.0, 19000.0),
(29000.0, 14600.0),
),
),
labels=(
("TEXT", "⌀38", (2900.0, 4200.0), "Ø38"),
("TEXT", "⌀38", (2200.0, 11450.0), "Ø38"),
("TEXT", "⌀38", (15750.0, 19300.0), "Ø38"),
("TEXT", "⌀38", (29300.0, 16800.0), "Ø38"),
),
words=((RETURN_WORD, (15750.0, 18700.0)),),
),
_Run(
"CR2",
"P_cipsupplyline",
"cip_return",
"Ø38",
("T202", "T203"),
"A",
paths=(((25500.0, 11600.0), (25500.0, 14000.0), (28400.0, 14000.0)),),
labels=(
("TEXT", "ø38", (25200.0, 12800.0), "Ø38"),
("TEXT", "ø38", (26950.0, 14300.0), "Ø38"),
),
words=((RETURN_WORD, (26950.0, 13700.0)),),
notes=("a return drawn on the supply layer",),
),
_Run(
"UT1",
"P_ijswater",
"ice_water",
"DN25",
("M12", "T203"),
"A",
paths=(((32000.0, 4300.0), (32000.0, 11000.0), (26100.0, 11000.0)),),
labels=(
("TEXT", "DN25", (32300.0, 7650.0), "DN25"),
("TEXT", "DN25", (29050.0, 11300.0), "DN25"),
),
notes=("a utility: outside the default takeoff scope",),
),
)
LAYER_SERVICES = {
"P_product piping": "product",
"P_cipsupplyline": "cip_supply",
"P_cipreturnline": "cip_return",
"P_ijswater": "ice_water",
"E_power": "electrical",
}
def pid_tags() -> dict[str, Pt]:
"""Tag positions on the P&ID: room 1 stretched, room 2 rearranged, T105 alone."""
out = {
tag: (ROOM1_FACTOR * x, ROOM1_FACTOR * y)
for tag, (x, y) in LAYOUT_TAGS.items()
if tag in ROOM1_TAGS
}
out.update(PID_ROOM2)
out.update(PID_ONLY)
return out
def manhattan(a: Pt, b: Pt) -> float:
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def rmst_length(points: list[Pt]) -> float:
"""Rectilinear minimum spanning tree length (Prim, Manhattan weights)."""
if len(points) < 2:
return 0.0
inside = {0}
total = 0.0
while len(inside) < len(points):
best = min(
(manhattan(points[i], points[j]), j)
for i in inside
for j in range(len(points))
if j not in inside
)
total += best[0]
inside.add(best[1])
return total
def scale_statistics(pid: dict[str, Pt], layout: dict[str, Pt], tags) -> dict:
"""Spec §6 over ``tags``: pairs at least 2 m apart on both drawings."""
ratios = []
for a, b in itertools.combinations(sorted(tags), 2):
on_layout = math.dist(layout[a], layout[b])
on_pid = math.dist(pid[a], pid[b])
if on_layout >= 2000.0 and on_pid >= 2000.0:
ratios.append(on_pid / on_layout)
median = statistics.median(ratios)
within = sum(1 for r in ratios if abs(r - median) <= 0.1 * median) / len(ratios)
return {"pairs": len(ratios), "median": median, "within_10pct": within}
def _segments(run: _Run) -> list[tuple[Pt, Pt]]:
out = []
for path in run.paths:
out.extend(zip(path, path[1:], strict=False))
return out
def _segment_distance(p: Pt, a: Pt, b: Pt) -> float:
ax, ay = a
bx, by = b
dx, dy = bx - ax, by - ay
t = max(0.0, min(1.0, ((p[0] - ax) * dx + (p[1] - ay) * dy) / (dx * dx + dy * dy)))
return math.hypot(p[0] - (ax + t * dx), p[1] - (ay + t * dy))
def _arc_length(arc) -> float:
_centre, radius, start, end = arc
return radius * math.radians((end - start) % 360.0)
def _check_labels() -> None:
"""Every label names its own segment at LABEL_OFFSET and no other run within 2x."""
for run in RUNS:
own = _segments(run)
others = [s for other in RUNS if other is not run for s in _segments(other)]
for _kind, _raw, at, _read in run.labels:
mine = min(_segment_distance(at, a, b) for a, b in own)
theirs = min(_segment_distance(at, a, b) for a, b in others)
if abs(mine - LABEL_OFFSET) > 1e-9 or theirs <= 2 * LABEL_OFFSET:
raise AssertionError(f"{run.id} label at {at}: own {mine}, other run {theirs}")
if run.status == "A":
for a, b in own:
near = min(_segment_distance(lab[2], a, b) for lab in run.labels)
if abs(near - LABEL_OFFSET) > 1e-9:
raise AssertionError(f"{run.id}: segment {a}-{b} has no direct label")
def _segment_diameters(run: _Run) -> dict[str, float]:
"""Drawn length per size: a segment takes the label 300 mm from it (direct),
else the run's size (continuity); arcs are continuity."""
lengths: dict[str, float] = {}
for a, b in _segments(run):
size = run.diameter
for _kind, _raw, at, read in run.labels:
if abs(_segment_distance(at, a, b) - LABEL_OFFSET) <= 1e-9:
size = read
break
lengths[size] = lengths.get(size, 0.0) + math.dist(a, b)
for arc in run.arcs:
lengths[run.diameter] = lengths.get(run.diameter, 0.0) + _arc_length(arc)
return lengths
def _blocks(doc) -> None:
tank = doc.blocks.new("TANK_V")
tank.add_circle((0.0, 0.0), PID_TANK_R)
pump = doc.blocks.new("POMP")
pump.add_circle((0.0, 0.0), PID_PUMP_R)
pump.add_lwpolyline([(-150.0, -150.0), (150.0, 0.0), (-150.0, 150.0)], close=True)
panel = doc.blocks.new("SCHAKELKAST")
panel.add_lwpolyline(
[
(-PANEL_W / 2, -PANEL_H / 2),
(PANEL_W / 2, -PANEL_H / 2),
(PANEL_W / 2, PANEL_H / 2),
(-PANEL_W / 2, PANEL_H / 2),
],
close=True,
)
valve = doc.blocks.new("KLEP_VLINDER")
valve.add_lwpolyline(
[(-150.0, -100.0), (150.0, 100.0), (150.0, -100.0), (-150.0, 100.0)], close=True
)
reducer = doc.blocks.new("VERLOOPSTUK")
reducer.add_lwpolyline(
[(-150.0, -100.0), (150.0, -60.0), (150.0, 60.0), (-150.0, 100.0)], close=True
)
def _write_pid(path: Path) -> None:
doc = ezdxf.new("R2018")
doc.header["$INSUNITS"] = 4
for name in (*LAYER_SERVICES, "KLEPPEN", "TEKST", "TAGS", "EQUIPMENT"):
if name not in doc.layers:
doc.layers.add(name)
_blocks(doc)
msp = doc.modelspace()
for tag, at in pid_tags().items():
block = (
"SCHAKELKAST" if tag.startswith("CP") else "POMP" if tag.startswith("M") else "TANK_V"
)
msp.add_blockref(block, at, dxfattribs={"layer": "EQUIPMENT"})
msp.add_text(tag, dxfattribs={"insert": at, "height": TEXT_H, "layer": "TAGS"})
for run in RUNS:
attribs = {"layer": run.layer}
for pts in run.paths:
# a two-point path is a LINE - except the one drawn twice, which is
# an LWPOLYLINE, so its duplicate is a line lying over a polyline
if len(pts) == 2 and tuple(pts) != run.duplicate:
msp.add_line(pts[0], pts[1], dxfattribs=attribs)
else:
msp.add_lwpolyline(pts, dxfattribs=attribs)
for centre, radius, start, end in run.arcs:
msp.add_arc(centre, radius, start, end, dxfattribs=attribs)
if run.duplicate is not None:
msp.add_line(run.duplicate[0], run.duplicate[1], dxfattribs=attribs)
for block, at, rotation in run.fittings:
msp.add_blockref(block, at, dxfattribs={"layer": "KLEPPEN", "rotation": rotation})
for kind, raw, at, _read in run.labels:
if kind == "MTEXT":
msp.add_mtext(
raw, dxfattribs={"insert": at, "char_height": TEXT_H, "layer": "TEKST"}
)
else:
msp.add_text(raw, dxfattribs={"insert": at, "height": TEXT_H, "layer": "TEKST"})
for word, at in run.words:
msp.add_text(word, dxfattribs={"insert": at, "height": TEXT_H, "layer": "TEKST"})
tags = pid_tags()
for tag, text, _kw, _vfd in LOADS:
x, y = tags[tag]
msp.add_mtext(
text,
dxfattribs={
"insert": (x + ELECTRICAL_DX, y),
"char_height": TEXT_H,
"layer": "E_power",
},
)
_set_extents(doc)
doc.saveas(path)
def _set_extents(doc) -> None:
"""Declare the model-space extents the way AutoCAD would: over everything.
ezdxf copies the model-space layout's ``extmin`` / ``extmax`` into
``$EXTMIN`` / ``$EXTMAX`` when it saves (``Drawing.update_extents``), so the
layout's attributes are what is set; a header write alone is overwritten.
"""
msp = doc.modelspace()
box = ezbbox.extents(msp, fast=True)
msp.dxf.extmin = (box.extmin.x, box.extmin.y, 0.0)
msp.dxf.extmax = (box.extmax.x, box.extmax.y, 0.0)
def _rect(msp, box, layer) -> None:
x0, y0, x1, y1 = box
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
for a, b in zip(corners, corners[1:] + corners[:1], strict=True):
msp.add_line(a, b, dxfattribs={"layer": layer})
def _write_layout(path: Path) -> str:
doc = ezdxf.new("R2018")
doc.header["$INSUNITS"] = 1 # inches - the geometry is millimetres
for name in ("WALLS", "ROOMS", "EQUIPMENT", "TAGS", "E-PANEL", "E-CALLOUT"):
doc.layers.add(name)
msp = doc.modelspace()
for copy in range(2):
ox, oy = COPY_OFFSET[0] * copy, COPY_OFFSET[1] * copy
def at(p: Pt, ox=ox, oy=oy) -> Pt:
return (p[0] + ox, p[1] + oy)
x0, y0, x1, y1 = OUTER_WALL
_rect(msp, (x0 + ox, y0 + oy, x1 + ox, y1 + oy), "WALLS")
for room in ROOMS:
bx0, by0, bx1, by1 = room["box"]
_rect(msp, (bx0 + ox, by0 + oy, bx1 + ox, by1 + oy), "WALLS")
for text, where in ROOM_LABELS:
msp.add_text(text, dxfattribs={"insert": at(where), "height": TEXT_H, "layer": "ROOMS"})
for tag, where in LAYOUT_TAGS.items():
centre = at(where)
if tag.startswith("CP"):
cx, cy = centre
msp.add_lwpolyline(
[
(cx - PANEL_W / 2, cy - PANEL_H / 2),
(cx + PANEL_W / 2, cy - PANEL_H / 2),
(cx + PANEL_W / 2, cy + PANEL_H / 2),
(cx - PANEL_W / 2, cy + PANEL_H / 2),
],
close=True,
dxfattribs={"layer": "E-PANEL"},
)
else:
radius = LAYOUT_PUMP_R if tag.startswith("M") else LAYOUT_TANK_R
msp.add_circle(centre, radius, dxfattribs={"layer": "EQUIPMENT"})
msp.add_text(tag, dxfattribs={"insert": centre, "height": TEXT_H, "layer": "TAGS"})
for tag, text, _panel in CALLOUTS:
tip = at(LAYOUT_TAGS[tag])
builder = msp.add_multileader_mtext("Standard", dxfattribs={"layer": "E-CALLOUT"})
builder.set_content(text, char_height=TEXT_H)
builder.add_leader_line(ConnectionSide.left, [Vec2(tip)])
builder.build(insert=Vec2(tip[0] + CALLOUT_DX, tip[1] + CALLOUT_DY))
stray = msp.add_line(OUTLIER_START, (OUTLIER_START[0] + 1000.0, OUTLIER_START[1]))
_set_extents(doc)
doc.saveas(path)
return str(stray.dxf.handle)
def build_plant_pair(directory: str | Path) -> dict:
"""Write ``pid.dxf`` and ``layout.dxf`` into ``directory``; return the truth.
Refuses (``AssertionError``) to write a pair that would not test what it
claims: a label nearer another run than its own, a status-A segment with no
direct label, a P&ID that is not schematic overall or not 1.3x in room 1,
or a cable length whose rounding sits on an integer.
"""
_check_labels()
layout_tags = dict(LAYOUT_TAGS)
pid = pid_tags()
overall = scale_statistics(pid, layout_tags, LAYOUT_TAGS)
room1 = scale_statistics(pid, layout_tags, ROOM1_TAGS)
room2 = scale_statistics(pid, layout_tags, [t for t in LAYOUT_TAGS if t not in ROOM1_TAGS])
if overall["within_10pct"] > 0.5 or room1["within_10pct"] != 1.0:
raise AssertionError(f"the pair does not read as schematic: {overall}, {room1}")
out = Path(directory)
out.mkdir(parents=True, exist_ok=True)
pid_path, layout_path = out / "pid.dxf", out / "layout.dxf"
_write_pid(pid_path)
outlier = _write_layout(layout_path)
pipe_runs = []
for run in RUNS:
routable = all(tag in layout_tags for tag in run.tags)
pieces = sum(math.dist(a, b) for a, b in _segments(run)) + sum(
_arc_length(a) for a in run.arcs
)
pipe_runs.append(
{
"id": run.id,
"layer": run.layer,
"service": run.service,
"diameter": run.diameter,
"diameters": _segment_diameters(run),
"tags": sorted(run.tags),
"status": run.status,
"pid_length_mm": pieces,
"layout_length_mm": rmst_length([layout_tags[t] for t in run.tags])
if routable
else None,
}
)
cable_rows = []
for (tag, _text, kw, vfd), (callout_tag, _callout, panel) in zip(LOADS, CALLOUTS, strict=True):
assert tag == callout_tag
length = manhattan(layout_tags[tag], layout_tags[panel])
metres = length / 1000.0 * (1.0 + ALLOWANCE)
if abs(metres - round(metres)) < 1e-6:
raise AssertionError(f"{tag}: {metres} m sits on an integer; move the tag")
cable_rows.append(
{
"tag": tag,
"panel": panel,
"kw": kw,
"phases": 3,
"voltage": 400,
"vfd": vfd,
"length_mm": length,
"roundup_m": math.ceil(metres),
}
)
rooms = []
for room in ROOMS:
x0, y0, x1, y1 = room["box"]
rooms.append(
{
"number": room["number"],
"name": room["name"],
"polygon": [[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
}
)
return {
"pid": str(pid_path),
"layout": str(layout_path),
"scale_verdict": "schematic",
"layout_insunits": 1,
"layout_true_unit": "mm",
"pid_insunits": 4,
"outlier_handle": outlier,
"cluster_count": 2,
"copy_offset": list(COPY_OFFSET),
"layer_services": dict(LAYER_SERVICES),
"pipe_runs": pipe_runs,
"cable_rows": cable_rows,
"rooms": rooms,
"allowance": ALLOWANCE,
"label_offset_mm": LABEL_OFFSET,
"tag_positions": {"layout": layout_tags, "pid": pid},
"scale": {
"overall": overall,
"room_1": room1,
"room_2": room2,
"room_1_factor": ROOM1_FACTOR,
},
}