MEGAINFRA-WEBAPP/scripts/site-model/extract_cedar_gate_services.py

235 lines
12 KiB
Python
Raw Normal View History

"""Add stormwater and sewer structures, pipes and levels to a Cedar Gate site model.
Reads the long-section tables of C3277-921-001 (stormwater, with the sewer sheets bound in) from a
LibreDWG DXF, and writes a copy of the base model folder with services.json extended. Plan routes and
water are kept as they were. Requires ezdxf. Coordinates: Lo Y/X in the tables are positive; local
east/north = (-Y, -X) minus the model origin, the same as extract_cedar_gate.py.
python3 extract_cedar_gate_services.py LONGSECTION.dxf BASE_MODEL_DIR OUT_DIR [ISSUED.pdf ...]
The bound DWG also holds superseded copies of some sewer long sections. When the issued PDFs are
given (needs poppler's pdftotext), each profile is scored by how many of its levels appear in them,
and for profiles drawn twice only the best match is kept.
"""
import hashlib
import io
import json
import math
import re
import shutil
import subprocess
import sys
from pathlib import Path
LAYER = "LS&DET$0$CC_LS_TEXT"
ROWS = ("GROUND LEVEL", "INVERT LEVEL", "DEPTH (m)", "GRADE (1:_)", "PEG DISTANCE (m)", "PIPE DESCRIPTION")
NON_STRUCTURE = re.compile(r"^(LH|Y-Junction)$", re.I)
COORD = re.compile(r"([A-Za-z][\w.\-()]*)\s+(\d{5}\.\d+)\s+(2\d{6}\.\d+)")
def read_texts(path):
"""TEXT/MTEXT on the long-section layer. LibreDWG wraps multiline text and writes payloads ezdxf rejects."""
import ezdxf
lines = Path(path).read_text(errors="replace").splitlines()
pairs, i = [], 0
while i < len(lines):
try:
code = int(lines[i])
except ValueError:
pairs[-1] = (pairs[-1][0], pairs[-1][1] + r"\P" + lines[i]); i += 1; continue
pairs.append((code, lines[i + 1])); i += 2
clean, skip = [], False
for code, value in pairs:
if code == 0:
skip = False
if code == 101 and value == "Embedded Object":
skip = True
if not skip:
clean.extend((str(code), value))
doc = ezdxf.read(io.StringIO("\n".join(clean) + "\n"))
out = []
def flatten(entities):
for e in entities:
if e.dxftype() == "INSERT":
if e.dxf.name in doc.blocks:
try:
yield from flatten(e.virtual_entities())
except Exception:
pass
else:
yield e
for e in flatten(doc.modelspace()):
if e.dxf.layer != LAYER or e.dxftype() not in ("TEXT", "MTEXT"):
continue
text = e.plain_text() if e.dxftype() == "MTEXT" else e.dxf.text
out.append((float(e.dxf.insert[0]), float(e.dxf.insert[1]), text))
return out
norm = lambda s: re.sub(r"\s+", "", s)
def num(s):
try:
return float(s)
except ValueError:
return None
def blocks(texts):
"""One block per 'MANHOLE' anchor: the row y positions and the x range of its columns."""
anchors = sorted((t for t in texts if t[2].strip() in ("MANHOLE", "MANHOLE / JUNCTION BOX")), key=lambda t: (-t[1], t[0]))
out = []
for ax, ay, label in anchors:
rows = {}
for name in ROWS:
near = [t for t in texts if t[2].strip() == name and abs(t[0] - ax) < 4 and 0 < ay - t[1] < 600]
if near:
rows[name] = max(near, key=lambda t: t[1])[1]
right = [a[0] for a in anchors if a[0] > ax + 20 and abs(a[1] - ay) < 60]
out.append({"ax": ax, "ay": ay, "kind": "stormwater" if "JUNCTION" in label else "sewer", "rows": rows,
"x0": ax + 60, "x1": min(right) - 5 if right else ax + 800})
return out
def read_block(texts, b):
band = lambda y, tol=5.5: [t for t in texts if b["x0"] <= t[0] < b["x1"] and abs(t[1] - y) <= tol]
names = [t for t in band(b["ay"], 8) if t[2].strip() and t[2].strip() not in ("MANHOLE", "MANHOLE / JUNCTION BOX") and not num(t[2])]
values = {r: band(b["rows"][r]) for r in ROWS if r in b["rows"]}
# every number belongs to the structure whose name is nearest across the table
xs = [t[0] for t in sorted(names)]
owned = {r: {i: [] for i in range(len(xs))} for r in values}
for r, vals in values.items():
for vx, _, text in vals:
v = num(text)
if v is None or not xs:
continue
k = min(range(len(xs)), key=lambda i: abs(xs[i] - vx))
if abs(xs[k] - vx) <= 12:
owned[r][k].append((vx, v))
items = []
for k, (x, y, text) in enumerate(sorted(names)):
gl, peg, depth = (sorted(owned.get(r, {}).get(k, [])) for r in ("GROUND LEVEL", "PEG DISTANCE (m)", "DEPTH (m)"))
inv = sorted(owned.get("INVERT LEVEL", {}).get(k, []))
if not gl or not inv or not peg:
continue
items.append({"name": norm(text.split("\n")[0]), "x": x, "gl": gl[0][1], "peg": peg[0][1],
"invL": inv[0][1], "invR": inv[-1][1], "depth": depth[0][1] if depth else None})
desc = []
for x, y, text in sorted(values.get("PIPE DESCRIPTION", [])):
m = re.search(r"(\d{2,4})\s*mm\s*(?:%%C|Ø)?\s*(.*)", text.replace("\n", " "))
if m:
desc.append({"x": x, "dia": int(m.group(1)), "text": re.sub(r"\s+", " ", text.replace("\n", " ")).strip()})
grades = sorted((t[0], num(t[2])) for t in values.get("GRADE (1:_)", []) if num(t[2]) is not None)
return items, desc, grades
def title(texts, b):
c = [t for t in texts if re.fullmatch(r"S[WE]-\S+", t[2].strip()) and abs(t[0] - b["ax"]) < 60 and 0 < t[1] - b["ay"] < 120]
return min(c, key=lambda t: t[1] - b["ay"])[2].strip() if c else None
def issued_text(pdfs):
return " ".join(subprocess.run(["pdftotext", "-raw", str(p), "-"], capture_output=True, text=True).stdout for p in pdfs)
def build(texts, origin, issued=""):
ox, oy = origin
coords = {}
for _, _, text in texts:
for m in COORD.finditer(text):
if not NON_STRUCTURE.match(m.group(1)):
coords.setdefault(norm(m.group(1)).lower(), (float(m.group(2)), float(m.group(3))))
local = lambda Y, X: (round(-Y - ox, 3), round(-X - oy, 3))
sw_struct, sw_pipes, sr_mh, sr_pipes, report = {}, [], {}, [], {"runs": [], "gradeChecks": [], "unlocated": []}
cands = []
for b in blocks(texts):
items, desc, grades = read_block(texts, b)
if len(items) < 2:
continue
levels = [f"{v:.3f}" for i in items for v in (i["invL"], i["invR"])]
score = sum(v in issued for v in levels) / len(levels) if issued else 1.0
cands.append({"b": b, "items": items, "desc": desc, "title": title(texts, b), "score": round(score, 3)})
keep = []
for c in cands: # a profile drawn twice: keep the copy that matches the issued sheets
same = [o for o in cands if o["b"]["kind"] == c["b"]["kind"] and o["title"] == c["title"] and c["title"]]
if c is max(same or [c], key=lambda o: o["score"]):
keep.append(c)
else:
report.setdefault("discarded", []).append({"kind": c["b"]["kind"], "title": c["title"], "score": c["score"], "structures": [i["name"] for i in c["items"]]})
run_no = {"stormwater": 0, "sewer": 0}
for c in keep:
b, items, desc, tname = c["b"], c["items"], c["desc"], c["title"]
run_no[b["kind"]] += 1
run = run_no[b["kind"]]
report["runs"].append({"kind": b["kind"], "run": run, "title": tname, "score": c["score"], "verifiedOnIssuedSheets": c["score"] >= 0.9, "structures": [i["name"] for i in items]})
real = [i for i in items if not NON_STRUCTURE.match(i["name"])]
for it in real:
c = coords.get(it["name"].lower())
e, n = local(*c) if c else (None, None)
if not c:
report["unlocated"].append(it["name"])
if b["kind"] == "stormwater":
s = sw_struct.setdefault(it["name"], {"name": it["name"], "e": e, "n": n, "Y": c[0] if c else None, "X": c[1] if c else None, "il": min(it["invL"], it["invR"]), "gl": it["gl"]})
s["il"] = min(s["il"], it["invL"], it["invR"])
else:
m = sr_mh.setdefault(f"S-{it['name']}", {"name": it["name"], "id": f"S-{it['name']}", "run": run, "il": min(it["invL"], it["invR"]), "located": bool(c), "gl": it["gl"]})
m["il"] = min(m["il"], it["invL"], it["invR"])
if c:
m.update({"Y": c[0], "X": c[1], "e": e, "n": n})
real.sort(key=lambda i: i["peg"])
for a, bb in zip(real, real[1:]):
length = round(bb["peg"] - a["peg"], 3)
if length <= 0:
continue
# flow runs downhill: from the higher pipe invert to the lower
a_to_b = a["invR"] > bb["invL"]
up, down = (a, bb) if a_to_b else (bb, a)
il_from = up["invR"] if a_to_b else up["invL"]
il_to = down["invL"] if a_to_b else down["invR"]
mid = (a["x"] + bb["x"]) / 2
d = min(desc, key=lambda t: abs(t["x"] - mid)) if desc else None
grade = round((il_from - il_to) / length * 100, 3)
pipe = {"from": up["name"], "to": down["name"], "run": run, "length": length, "dia": d["dia"] if d else 0,
"ilFrom": il_from, "ilTo": il_to, "grade": grade}
if b["kind"] == "stormwater":
pipe["diaSource"] = "drawing label"
sw_pipes.append(pipe)
else:
pipe.update({"from": f"S-{up['name']}", "to": f"S-{down['name']}", "hold": None})
pipe.pop("diaSource", None)
sr_pipes.append(pipe)
return sw_struct, sw_pipes, sr_mh, sr_pipes, report
def main(dxf, base_dir, out_dir, *pdfs):
base, out = Path(base_dir), Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
road = json.loads((base / "bushveld.json").read_text())
origin = (road["origin"]["cadX"], road["origin"]["cadY"])
sw_struct, sw_pipes, sr_mh, sr_pipes, report = build(read_texts(dxf), origin, issued_text(pdfs) if pdfs else "")
services = json.loads((base / "services.json").read_text())
services["stormwater"] = {"structures": [s for s in sw_struct.values() if s["e"] is not None], "pipes": sw_pipes,
"notes": ["Pipes, sizes, inverts and ground levels read from the C3277-921-001 long sections; positions from their coordinate lists.",
"A pipe's size is the nearest PIPE DESCRIPTION on the long section; check against the plan labels before use.",
"Kerb inlets, grid inlets and the outlet are shown as structures with their invert levels.",
"The external SW-M1 line is on the DWG only; no issued PDF was supplied to check its levels against."]}
services["sewer"] = {"manholes": list(sr_mh.values()), "pipes": sr_pipes,
"notes": ["Manholes and pipes read from the C3277-931 sewer long sections; Y-junctions and lampholes on a run are not drawn as structures.",
"160 mm uPVC Class 400 kPa unless the long section shows otherwise."]}
for name in ("bushveld", "earthworks", "schedules", "surface"):
shutil.copyfile(base / f"{name}.json", out / f"{name}.json")
(out / "services.json").write_text(json.dumps(services, separators=(",", ":")))
sources = json.loads((base / "sources.json").read_text()) if (base / "sources.json").exists() else {}
sources.setdefault("sources", []).append({"file": Path(dxf).name, "role": "long sections (stormwater, sewer)"})
sources["servicesExtraction"] = {"stormwater": {"structures": len(services["stormwater"]["structures"]), "pipes": len(sw_pipes)},
"sewer": {"manholes": len(sr_mh), "pipes": len(sr_pipes)}, "unlocated": sorted(set(report["unlocated"]))}
(out / "sources.json").write_text(json.dumps(sources, indent=2))
(out / "services-extraction.json").write_text(json.dumps(report, indent=2))
print(json.dumps({"stormwater": sources["servicesExtraction"]["stormwater"], "sewer": sources["servicesExtraction"]["sewer"], "unlocated": sources["servicesExtraction"]["unlocated"]}))
return services
if __name__ == "__main__":
main(*sys.argv[1:])