feat(site-model): Nellmapius Ext. 25 roads, kerbs and benchmarks from the 100_ROADS and 400_KERBING drawings
Road areas (5,221 m²) in 20 m sections along the ring roads and the Igwali Street spur, 1.74 km of kerb measured once, stand lines, and the four survey benchmarks with an indicative ground plane. No services or ground levels are placed; the supplied survey and sewer sheets cannot yield them. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d6cf3d1144
commit
36da7f9f20
10 changed files with 331 additions and 0 deletions
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"source":"None supplied","method":"No design levels or cross-sections supplied for Nellmapius Ext. 25.","chainageOffset":0,"chainageEnd":0,"sections":[],"volumes":[],"alignment":[],"totals":{"cut":0,"fill":0},"validation":{"samples":0,"meanAbs":0,"median":0,"p90Abs":0,"note":"No earthworks data."}}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"signs":[],"signCounts":{},"markingLabels":[],"markingCounts":{},"studs":[],"studCounts":{},"markings":{},"paintAreas":{},"waterFittings":{},"notes":["Signs, studs and fittings are not quantified."]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"planRoutes":[],"stormwater":{"structures":[],"pipes":[],"notes":["Not placed: no levels supplied."]},"sewer":{"manholes":[],"pipes":[],"notes":["Not placed: sewer long section sheet 3 of 3 only."]},"water":{"points":[],"pipeLabels":{}},"holds":[]}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"sources": [
|
||||
{
|
||||
"file": "100_ROADS.dxf",
|
||||
"sha256": "fc853eda6381255d875834c5d1aa0faaf1bf26bc5cd82c2603fdaabd28b8b9e5"
|
||||
},
|
||||
{
|
||||
"file": "400_KERBING.dxf",
|
||||
"sha256": "b6bdb2d1d76ba43360b356253177080ac2c7710a97ad04a067b68c83ea3bfa10"
|
||||
}
|
||||
],
|
||||
"excluded": "Cedar Gate (C3277) drawings are a different project."
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"roadFaces": [
|
||||
5220.9
|
||||
],
|
||||
"blocks": [
|
||||
9031,
|
||||
9028
|
||||
],
|
||||
"spurSections": 7,
|
||||
"sections": 53,
|
||||
"areaPieces": 54,
|
||||
"roadAreaM2": 5220.9,
|
||||
"kerbM": 1744.5,
|
||||
"kerbRows": 100,
|
||||
"contextLines": 537,
|
||||
"planeResidualsM": [
|
||||
0.037,
|
||||
-0.191,
|
||||
0.016,
|
||||
0.138
|
||||
],
|
||||
"origin": [
|
||||
-65140.0,
|
||||
-2847300.0
|
||||
],
|
||||
"sha256": {
|
||||
"bushveld.json": "fd602c6766c4a667f2f8155af8f960b30a729a9609a1bd5d09d8b20f0ff9c59c",
|
||||
"services.json": "1e4f65a1f1c3b0135e40afc2ac5a8f7da62510d65954647dbbc1054332e88b13",
|
||||
"earthworks.json": "1dc06454932fd29275b188cc1a5cbb217a35ae7c1fa236caca0daa9ab535a4dd",
|
||||
"schedules.json": "f7ac61530123eeedfe45bc3a3a188100600bf05ffc7ee1070f949453c97c97d1",
|
||||
"surface.json": "91a5aa2b24ed390d280fd50a82bc484b0418275804ba4aec8826d4e50b4d7f16"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"file": "100_ROADS.dxf",
|
||||
"sha256": "fc853eda6381255d875834c5d1aa0faaf1bf26bc5cd82c2603fdaabd28b8b9e5"
|
||||
},
|
||||
{
|
||||
"file": "400_KERBING.dxf",
|
||||
"sha256": "b6bdb2d1d76ba43360b356253177080ac2c7710a97ad04a067b68c83ea3bfa10"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -72,3 +72,18 @@ python3 -m unittest scripts/site-model/test_cedar_gate_services.py
|
|||
The DWG also holds superseded copies of three sewer profiles; the issued PDFs decide which copy is kept
|
||||
(`services-extraction.json` lists what was kept and dropped). The external SW-M1 line is on the DWG only.
|
||||
Water is still plan routes only. Upload with the normal uploader.
|
||||
|
||||
### Nellmapius Ext. 25 (added 2026-10-21)
|
||||
|
||||
`extract_nellmapius.py` reads the converted `100_ROADS` and `400_KERBING` DWGs (not the Cedar Gate files) and writes
|
||||
a model with road areas in 20 m sections, kerbs, stand/context lines, the four SVR survey benchmarks and an
|
||||
indicative ground plane through them. Nothing else in the supplied set can be placed: the detail contour plan
|
||||
has its contour labels drawn as outlines (no readable levels), the sewer long section is sheet 3 of 3, and the
|
||||
services layout carries no levels. Do not use the model for earthworks or levels.
|
||||
|
||||
```sh
|
||||
dwg2dxf -y 100_ROADS.dwg; dwg2dxf -y 400_KERBING.dwg # LibreDWG
|
||||
python3 -I scripts/site-model/extract_nellmapius.py 100_ROADS.dxf 400_KERBING.dxf reports/site-models/nellmapius-ext-25/2026-10-21
|
||||
python3 -m unittest scripts/site-model/test_nellmapius.py
|
||||
python3 scripts/site-model/upload.py --site dmqybquea190z22 --label "Nellmapius Ext. 25 — roads and kerbs" reports/site-models/nellmapius-ext-25/2026-10-21
|
||||
```
|
||||
|
|
|
|||
215
scripts/site-model/extract_nellmapius.py
Normal file
215
scripts/site-model/extract_nellmapius.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Build a Map 2/3 model for Nellmapius Ext. 25 from the 100_ROADS and 400_KERBING drawings.
|
||||
|
||||
What the supplied files give: the kerb and road-edge lines, stand lines, road names and widths, plus four
|
||||
survey benchmarks (SVR detail contour plan). They give no design levels, no pavement build-up and no
|
||||
stormwater or sewer data in a form that can be placed, so the model has road areas, kerbs and context only,
|
||||
over an indicative plane through the four benchmarks.
|
||||
|
||||
Requires numpy shapely ezdxf scipy. Coordinates: local east/north metres = CAD (x=-Lo Y, y=-Lo X) minus origin.
|
||||
|
||||
python3 -I extract_nellmapius.py 100_ROADS.dxf 400_KERBING.dxf OUT_DIR
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import shapely
|
||||
from scipy.spatial import cKDTree
|
||||
from shapely.geometry import LineString, MultiPoint, Point, Polygon, box
|
||||
from shapely.ops import polygonize, unary_union
|
||||
|
||||
# Survey benchmarks printed on the SVR detail contour plan (Lo29 Y, X, level), Sep 2024
|
||||
BENCHMARKS = [("ST18", 65305.974, 2847299.892, 1298.993, "12mm peg next to kerb"), ("BM3", 65203.736, 2847335.083, 1300.044, "hole in kerb"),
|
||||
("ST17", 65152.153, 2847435.083, 1302.880, "12mm peg in paving"), ("BM2", 65182.192, 2847332.670, 1300.406, "hole in kerb")]
|
||||
KERB_LAYERS = {"Kerb_300 Mountable": "300 mm mountable kerb", "Kerb_Edge Beam": "150 mm edge beam", "Kerb_Fig7_Semi-mount": "Fig. 7 semi-mountable kerb"}
|
||||
NAMES = {"IDELO CRESCENT": "Idelo Crescent", "UMNGAMO STREET": "Umngamo Street", "IGWALI STREET": "Igwali Street"} # roads built in this phase (cross-section titles)
|
||||
SECTION = 20.0
|
||||
|
||||
|
||||
def read_dxf(path, layers):
|
||||
"""TEXT/MTEXT/lines of the wanted layers; repairs LibreDWG's multiline text and unsupported payloads."""
|
||||
import ezdxf
|
||||
from ezdxf.path import make_path
|
||||
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 = defaultdict(list)
|
||||
for e in doc.modelspace():
|
||||
if e.dxf.layer not in layers:
|
||||
continue
|
||||
k = e.dxftype()
|
||||
if k in ("LINE", "ARC", "LWPOLYLINE", "POLYLINE", "CIRCLE"):
|
||||
out[e.dxf.layer].append({"type": k, "pts": [list(p)[:2] for p in make_path(e).flattening(.05)]})
|
||||
elif k in ("TEXT", "MTEXT"):
|
||||
out[e.dxf.layer].append({"type": k, "text": e.plain_text() if k == "MTEXT" else e.dxf.text, "pos": list(e.dxf.insert)[:2]})
|
||||
return out
|
||||
|
||||
|
||||
def plane_fit(points):
|
||||
A = np.array([[1, e, n] for e, n, _ in points]); z = np.array([p[2] for p in points])
|
||||
coef, *_ = np.linalg.lstsq(A, z, rcond=None)
|
||||
return coef, z - A @ coef
|
||||
|
||||
|
||||
def build(roads, kerbs, out_dir, source_files):
|
||||
edge = [LineString(r["pts"]) for r in kerbs["_X25 ROAD EDGE"] if len(r["pts"]) > 2]
|
||||
closed = [l for l in edge if math.dist(l.coords[0], l.coords[-1]) < 0.05]
|
||||
cx0, cy0, cx1, cy1 = unary_union(closed).bounds
|
||||
window = box(cx0 - 120, cy0 - 120, cx1 + 120, cy1 + 120)
|
||||
origin = (round((cx0 + cx1) / 2, -1), round((cy0 + cy1) / 2, -1))
|
||||
local = lambda pts: [[round(p[0] - origin[0], 3), round(p[1] - origin[1], 3)] for p in pts]
|
||||
inside = lambda pts: window.contains(LineString(pts)) if len(pts) > 1 else False
|
||||
|
||||
# ---- road faces: the ring roads are the space between the closed edge lines (kerbs lie on them); the Igwali spur is closed by a hull
|
||||
kerb_lines = [(k, LineString(r["pts"])) for k in KERB_LAYERS for r in kerbs[k] if len(r["pts"]) > 1 and inside(r["pts"])]
|
||||
faces = list(polygonize(unary_union([g for _, g in kerb_lines] + closed)))
|
||||
ring = [f for f in faces if f.area > 20 and f.area / f.length < 8]
|
||||
blocks = [f for f in faces if f.area > 20 and f.area / f.length >= 8]
|
||||
road = unary_union(ring) # the 932 m outer edge line includes the Igwali Street spur, so the face already has it
|
||||
report = {"roadFaces": [round(f.area, 1) for f in ring], "blocks": [round(b.area) for b in blocks]}
|
||||
|
||||
# ---- 20 m sections: along each block's boundary (ring roads) and along the spur axis
|
||||
labels = [] # (name, x, y)
|
||||
for r in roads["_NEW ROAD NAMES"] + roads["ROAD NAMES"]:
|
||||
t = re.sub(r"[()]|\d+m", "", r["text"]).strip().upper()
|
||||
if t in NAMES:
|
||||
labels.append((NAMES[t], r["pos"][0], r["pos"][1]))
|
||||
block_rings = [b.exterior for b in blocks]
|
||||
block_minx = min(b.bounds[0] for b in blocks)
|
||||
cell = 1.0
|
||||
minx, miny, maxx, maxy = road.bounds
|
||||
cells = [(x + .5, y + .5) for x in np.arange(math.floor(minx), math.ceil(maxx), cell) for y in np.arange(math.floor(miny), math.ceil(maxy), cell) if road.contains(Point(x + .5, y + .5))]
|
||||
|
||||
spur_cells = [(x, y) for x, y in cells if x < block_minx - 2]
|
||||
west = min(spur_cells, key=lambda c: c[0])
|
||||
near_j = [c[1] for c in spur_cells if block_minx - 3 < c[0] < block_minx - 2] or [west[1]]
|
||||
axis = LineString([(block_minx - 2, float(np.mean(near_j))), west])
|
||||
n_spur = max(1, int(axis.length // SECTION))
|
||||
|
||||
def chunk_of(x, y):
|
||||
if x < block_minx - 2:
|
||||
return ("spur", min(n_spur - 1, int(axis.project(Point(x, y)) // SECTION)))
|
||||
p = Point(x, y)
|
||||
k = min(range(len(block_rings)), key=lambda i: block_rings[i].distance(p))
|
||||
return (f"loop{k}", int(block_rings[k].project(p) // SECTION))
|
||||
|
||||
groups = defaultdict(list)
|
||||
for x, y in cells:
|
||||
groups[chunk_of(x, y)].append((x, y))
|
||||
exact = road.area / max(1, len(cells))
|
||||
sections, areas, chunk_geo = [], [], {}
|
||||
for (kind, idx), pts in sorted(groups.items()):
|
||||
cx, cy = np.mean([p[0] for p in pts]), np.mean([p[1] for p in pts])
|
||||
name = (min(labels, key=lambda l: math.hypot(l[1] - cx, l[2] - cy))[0] if labels else "Road") if kind != "spur" else "Igwali Street"
|
||||
if kind.startswith("loop") and name == "Igwali Street":
|
||||
name = "Idelo Crescent"
|
||||
tag = kind if kind != "spur" else "spur"
|
||||
f, t = (idx * SECTION, (idx + 1) * SECTION)
|
||||
key = f"{re.sub('[^a-z0-9]+', '-', name.lower())}-{tag}-ch{int(f)}-{int(t)}"
|
||||
poly = unary_union([box(x - .5, y - .5, x + .5, y + .5) for x, y in pts]).buffer(0)
|
||||
parts = list(poly.geoms) if poly.geom_type == "MultiPolygon" else [poly]
|
||||
sections.append({"key": key, "name": f"{name} ({'spur' if kind == 'spur' else 'loop ' + 'AB'[int(kind[-1])] if len(blocks) > 1 else 'loop'})", "from": f, "to": t})
|
||||
chunk_geo[key] = poly
|
||||
for part in parts:
|
||||
areas.append({"kind": "road_surface", "section": key, "area": round(part.area * exact / cell ** 2, 3), "rings": [local(list(part.simplify(.05).exterior.coords))]})
|
||||
assert abs(sum(a["area"] for a in areas) - road.area) < 1, (sum(a["area"] for a in areas), road.area)
|
||||
|
||||
# ---- kerbs: each metre of kerb line goes to the section it sits in
|
||||
tree = cKDTree(np.array([(g.centroid.x, g.centroid.y) for g in chunk_geo.values()])); keys = list(chunk_geo)
|
||||
owner = lambda x, y: keys[int(tree.query([x, y])[1])] if False else min(keys, key=lambda k: chunk_geo[k].distance(Point(x, y)))
|
||||
kerb_rows, total_kerb = [], 0.0
|
||||
for lname, g in kerb_lines:
|
||||
runs, cur, cur_key = [], [], None
|
||||
n = max(1, int(round(g.length)))
|
||||
pts = [g.interpolate(i * g.length / n) for i in range(n + 1)]
|
||||
for a, b in zip(pts, pts[1:]):
|
||||
k = owner((a.x + b.x) / 2, (a.y + b.y) / 2)
|
||||
if k != cur_key and cur:
|
||||
runs.append((cur_key, cur)); cur = []
|
||||
cur_key = k; cur = cur + [a.coords[0]] if not cur else cur; cur.append(b.coords[0])
|
||||
if cur:
|
||||
runs.append((cur_key, cur))
|
||||
for k, line in runs:
|
||||
L = LineString(line).length; total_kerb += L
|
||||
kerb_rows.append({"section": k, "length": round(L, 3), "pts": local(line)})
|
||||
|
||||
context = [{"kind": "tar_edge", "pts": local(list(l.coords))} for l in closed if window.contains(l)]
|
||||
context += [{"kind": "other_road", "pts": local(r["pts"])} for r in kerbs["KERB_FIG 10"] if inside(r["pts"])]
|
||||
context += [{"kind": "marking", "pts": local(r["pts"])} for r in roads["DCE-RMRS-LINE-W-SOLID"] if inside(r["pts"])]
|
||||
context += [{"kind": "other_road", "pts": local(r["pts"])} for r in roads["2025_Calc Plan"] if inside(r["pts"])] # stand lines
|
||||
pegs = []
|
||||
for name, Y, X, z, note in BENCHMARKS:
|
||||
e, n = local([(-Y, -X)])[0]
|
||||
pegs.append({"name": name, "e": e, "n": n, "z": z, "Y": Y, "X": X})
|
||||
notes = [
|
||||
"Nellmapius Ext. 25 (SVR / Civil Concepts drawings 100_ROADS and 400_KERBING). Roads read from the closed road-edge lines and kerb lines; widths per the cross-section titles: Idelo Crescent and Umngamo Street 5.0 m in 13 m reserve, Igwali Street 8.0 m in 20 m reserve.",
|
||||
"20 m sections run along the inner edge of each ring road and along the Igwali Street spur; road names come from the nearest street-name label, so check them.",
|
||||
"No pavement build-up was supplied: each section has one 'road surface' item to tick off. No design levels or contours were supplied.",
|
||||
"The ground is an indicative plane through the four survey benchmarks (Sep 2024). It is not a survey surface: do not use it for earthworks, cut and fill or levels.",
|
||||
"No stormwater, sewer or water pipes are placed: the sewer long section supplied is sheet 3 of 3 and the services layout carries no levels.",
|
||||
f"Kerb lines measured once: {total_kerb:.0f} m (300 mm mountable, 150 mm edge beam, Fig. 7 semi-mountable).",
|
||||
]
|
||||
road_model = {"project": "Nellmapius Ext. 25", "origin": {"cadX": origin[0], "cadY": origin[1]},
|
||||
"builds": {"road_surface": {"name": "Road surface", "color": 0x6B7280, "layers": [{"id": "road-surface", "name": "Road surface (layers not supplied)", "thk": 0.1, "unit": "m²", "color": 0x6B7280}]}},
|
||||
"sections": sections, "areas": areas, "kerbs": kerb_rows, "kerbQuantityFactor": 1, "context": context, "contours": [], "pegs": pegs, "water": [], "buffer": [], "notes": notes}
|
||||
|
||||
# ---- indicative ground: a plane through the benchmarks
|
||||
pts3 = [(p["e"], p["n"], p["z"]) for p in pegs]
|
||||
coef, res = plane_fit(pts3)
|
||||
x0, y0 = math.floor(minx - origin[0] - 40), math.floor(miny - origin[1] - 40)
|
||||
nx, ny = math.ceil(maxx - origin[0] + 40) - x0 + 1, math.ceil(maxy - origin[1] + 40) - y0 + 1
|
||||
gx, gy = np.meshgrid(x0 + np.arange(nx), y0 + np.arange(ny))
|
||||
Z = coef[0] + coef[1] * gx + coef[2] * gy
|
||||
zmin = round(float(Z.min()) - 1, 1)
|
||||
q = np.round((Z - zmin) * 1000).astype("<i2")
|
||||
surface = {"x0": float(x0), "y0": float(y0), "cell": 1, "nx": int(nx), "ny": int(ny), "zmin": zmin, "z": base64.b64encode(q.tobytes()).decode(),
|
||||
"encoding": "int16 little-endian, mm above zmin, -1 = outside supplied contour coverage, row-major",
|
||||
"source": "Indicative plane through four survey benchmarks (SVR Land Surveyors, Sep 2024). Not a survey surface.",
|
||||
"check": {"pegs": 4, "medianAbs": round(float(np.median(np.abs(res))), 3), "p90Abs": round(float(np.percentile(np.abs(res), 90)), 3)}}
|
||||
earthworks = {"source": "None supplied", "method": "No design levels or cross-sections supplied for Nellmapius Ext. 25.", "chainageOffset": 0, "chainageEnd": 0, "sections": [], "volumes": [], "alignment": [],
|
||||
"totals": {"cut": 0, "fill": 0}, "validation": {"samples": 0, "meanAbs": 0, "median": 0, "p90Abs": 0, "note": "No earthworks data."}}
|
||||
services = {"planRoutes": [], "stormwater": {"structures": [], "pipes": [], "notes": ["Not placed: no levels supplied."]}, "sewer": {"manholes": [], "pipes": [], "notes": ["Not placed: sewer long section sheet 3 of 3 only."]},
|
||||
"water": {"points": [], "pipeLabels": {}}, "holds": []}
|
||||
schedules = {"signs": [], "signCounts": {}, "markingLabels": [], "markingCounts": {}, "studs": [], "studCounts": {}, "markings": {}, "paintAreas": {}, "waterFittings": {}, "notes": ["Signs, studs and fittings are not quantified."]}
|
||||
out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
|
||||
files = {"bushveld": road_model, "services": services, "earthworks": earthworks, "schedules": schedules, "surface": surface}
|
||||
hashes = {}
|
||||
for name, data in files.items():
|
||||
text = json.dumps(data, separators=(",", ":")); (out / f"{name}.json").write_text(text); hashes[f"{name}.json"] = hashlib.sha256(text.encode()).hexdigest()
|
||||
report.update({"spurSections": n_spur, "sections": len(sections), "areaPieces": len(areas), "roadAreaM2": round(road.area, 1), "kerbM": round(total_kerb, 1), "kerbRows": len(kerb_rows), "contextLines": len(context),
|
||||
"planeResidualsM": [round(float(r), 3) for r in res], "origin": origin, "sha256": hashes, "sources": source_files})
|
||||
(out / "validation.json").write_text(json.dumps(report, indent=2))
|
||||
(out / "sources.json").write_text(json.dumps({"sources": source_files, "excluded": "Cedar Gate (C3277) drawings are a different project."}, indent=2))
|
||||
print(json.dumps({k: report[k] for k in ("sections", "spurSections", "roadAreaM2", "kerbM", "planeResidualsM")}))
|
||||
|
||||
|
||||
def main(roads_dxf, kerbs_dxf, out_dir):
|
||||
roads = read_dxf(roads_dxf, {"_NEW ROAD NAMES", "ROAD NAMES", "DCE-RMRS-LINE-W-SOLID", "2025_Calc Plan"})
|
||||
kerbs = read_dxf(kerbs_dxf, {"_X25 ROAD EDGE", "KERB_FIG 10", *KERB_LAYERS})
|
||||
src = [{"file": Path(p).name, "sha256": hashlib.sha256(Path(p).read_bytes()).hexdigest()} for p in (roads_dxf, kerbs_dxf)]
|
||||
build(roads, kerbs, out_dir, src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(*sys.argv[1:4])
|
||||
40
scripts/site-model/test_nellmapius.py
Normal file
40
scripts/site-model/test_nellmapius.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import json
|
||||
import math
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "reports/site-models/nellmapius-ext-25/2026-10-21"
|
||||
|
||||
|
||||
class Nellmapius(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.road = json.loads((ROOT / "bushveld.json").read_text())
|
||||
cls.report = json.loads((ROOT / "validation.json").read_text())
|
||||
|
||||
def test_area_is_conserved_and_every_section_has_area(self):
|
||||
total = sum(a["area"] for a in self.road["areas"])
|
||||
self.assertAlmostEqual(total, self.report["roadAreaM2"], delta=1)
|
||||
self.assertGreater(total, 5000); self.assertLess(total, 5500)
|
||||
keys = {s["key"] for s in self.road["sections"]}
|
||||
self.assertEqual(len(keys), len(self.road["sections"]))
|
||||
self.assertEqual({a["section"] for a in self.road["areas"]}, keys)
|
||||
self.assertTrue(all(k["section"] in keys for k in self.road["kerbs"]))
|
||||
|
||||
def test_kerbs_are_measured_once(self):
|
||||
kerb = sum(k["length"] for k in self.road["kerbs"])
|
||||
self.assertAlmostEqual(kerb, self.report["kerbM"], delta=1)
|
||||
self.assertGreater(kerb, 1500); self.assertLess(kerb, 2200) # two edges of ~900 m of road
|
||||
|
||||
def test_benchmarks_are_real_levels_and_plane_fits_them(self):
|
||||
self.assertEqual({p["name"] for p in self.road["pegs"]}, {"ST18", "BM3", "ST17", "BM2"})
|
||||
self.assertLess(max(abs(r) for r in self.report["planeResidualsM"]), 0.25)
|
||||
|
||||
def test_no_invented_services_or_levels(self):
|
||||
s = json.loads((ROOT / "services.json").read_text()); e = json.loads((ROOT / "earthworks.json").read_text())
|
||||
self.assertEqual(s["stormwater"]["pipes"], []); self.assertEqual(s["sewer"]["manholes"], []); self.assertEqual(e["volumes"], [])
|
||||
self.assertIn("indicative plane", " ".join(self.road["notes"]).lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue