399 lines
24 KiB
Python
399 lines
24 KiB
Python
"""Extract Cedar Gate Road's 40 drawn cross-sections from C3277-912-001 Rev A and check them against
|
||
the C3277-911-001 Rev A road long section.
|
||
|
||
The cross-sections are read from the CAD drawing converted to DXF (preferred: named layers, exact lines)
|
||
or, as before, from the plotted PDF. Scales are measured from the grid labels. Connected road design
|
||
paths are separated from the independent service symbols. The long section's centreline ground and design
|
||
levels must agree with every matching cross-section. Quantities are drawing-derived end-area estimates,
|
||
not measured backfill or as-built volumes.
|
||
|
||
python3 extract_cedar_gate_xs.py "C3277-912-001 ... - bound.dxf" raw.json MODEL_FOLDER --long-section "C3277-911-001 ... - bound.dxf"
|
||
"""
|
||
import argparse
|
||
import base64
|
||
from collections import defaultdict
|
||
import hashlib
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
import re
|
||
import subprocess
|
||
import tempfile
|
||
|
||
import numpy as np
|
||
from shapely.geometry import LineString, Point
|
||
|
||
from extract_cedar_gate import alignment, load_dxf
|
||
from extract_xs import load_svg_paths, load_words
|
||
|
||
H_SCALE = 1000/250/25.4*72
|
||
V_SCALE = -1000/100/25.4*72
|
||
STEP = .05
|
||
|
||
|
||
def profile(chainage, ground, design_segments, excluded):
|
||
"""Sample one section's natural ground and road design every STEP m and measure cut/fill to formation.
|
||
|
||
ground and design_segments are ((offset, level), (offset, level)) pairs sorted by offset.
|
||
"""
|
||
xs = np.arange(-15, 15+STEP/2, STEP)
|
||
def sample(segments, aggregate):
|
||
out = np.full(len(xs), np.nan)
|
||
for (ax, ay), (bx, by) in segments:
|
||
mask = (xs >= ax-1e-8) & (xs <= bx+1e-8)
|
||
value = ay+(xs[mask]-ax)*(by-ay)/(bx-ax)
|
||
out[mask] = np.where(np.isnan(out[mask]), value, aggregate(out[mask], value))
|
||
return out
|
||
if not ground:
|
||
raise ValueError(f'{chainage}: no natural ground line')
|
||
ngl = sample(ground, np.maximum)
|
||
good = np.isfinite(ngl)
|
||
if good.sum() < 20:
|
||
raise ValueError(f'{chainage}: insufficient natural ground')
|
||
ngl = np.interp(xs, xs[good], ngl[good]) # bridge plotted dashes
|
||
top = sample(design_segments, np.maximum)
|
||
bottom = sample(design_segments, np.minimum)
|
||
cut = np.maximum(ngl-bottom, 0)
|
||
fill = np.maximum(bottom-ngl, 0)
|
||
# Integrate only contiguous runs of drawn formation; do not bridge blank spans.
|
||
def integrate(values):
|
||
mask = np.isfinite(values[:-1]) & np.isfinite(values[1:])
|
||
return float(np.sum((values[:-1][mask]+values[1:][mask])*.5*STEP))
|
||
return {'ch': chainage, 'x': xs, 'ngl': ngl, 'top': top, 'bot': bottom,
|
||
'cutArea': integrate(cut), 'fillArea': integrate(fill),
|
||
'roadWidth': float(np.ptp(xs[np.isfinite(bottom)])), 'excludedIndependentPaths': excluded}
|
||
|
||
|
||
def check_chainages(result):
|
||
result.sort(key=lambda s: s['ch'])
|
||
if len(result) != 40 or [s['ch'] for s in result] != [*range(0, 381, 10), 385.411]:
|
||
raise ValueError('Expected all 40 drawn sections from 0 to 385.411 m')
|
||
return result
|
||
|
||
|
||
def read_sections(svg_path, words_path):
|
||
paths = load_svg_paths(Path(svg_path).read_text())
|
||
words = load_words(Path(words_path).read_text())
|
||
black = lambda p: p['stroke'] == 'rgb(0%, 0%, 0%)'
|
||
grey = lambda p: p['stroke'].startswith('rgb(72.')
|
||
inside = lambda r, x, y, pad=0: r[0]-pad <= x <= r[2]+pad and r[1]-pad <= y <= r[3]+pad
|
||
rects = []
|
||
for p in paths:
|
||
if black(p) and p['w'] == 17 and len(p['pts']) >= 4:
|
||
xx, yy = zip(*p['pts'])
|
||
if max(xx)-min(xx) > 300 and max(yy)-min(yy) > 30:
|
||
rects.append((min(xx), min(yy), max(xx), max(yy)))
|
||
result = []
|
||
for i, word in enumerate(words):
|
||
if word[0] != 'Peg':
|
||
continue
|
||
cx, cy = (word[1]+word[3])/2, (word[2]+word[4])/2
|
||
strip = next((r for r in rects if inside(r, cx, cy) and r[3]-r[1] < 60), None)
|
||
if strip is None:
|
||
raise ValueError('Cross-section caption lacks a frame')
|
||
plot = next((r for r in rects if abs(r[3]-strip[1]) < 2 and abs(r[0]-strip[0]) < 2 and r[3]-r[1] > 60), None)
|
||
if plot is None:
|
||
raise ValueError('Cross-section plot lacks a frame')
|
||
following = [w for w in words[i+1:i+7] if abs((w[2]+w[4])/2-cy) < 3]
|
||
chainage = next((float(w[0]) for w in following if re.fullmatch(r'\d+(\.\d+)?', w[0])), None)
|
||
if chainage is None:
|
||
raise ValueError('Cross-section chainage unreadable')
|
||
gx = sorted({p['pts'][0][0] for p in paths if grey(p) and len(p['pts']) == 2 and abs(p['pts'][0][0]-p['pts'][1][0]) < .01 and inside(plot, *p['pts'][0])})
|
||
gy = sorted({p['pts'][0][1] for p in paths if grey(p) and len(p['pts']) == 2 and abs(p['pts'][0][1]-p['pts'][1][1]) < .01 and inside(plot, *p['pts'][0])})
|
||
offsets = [(float(w[0]), (w[1]+w[3])/2) for w in words if re.fullmatch(r'-?\d{1,2}', w[0]) and inside(strip, (w[1]+w[3])/2, (w[2]+w[4])/2)]
|
||
snapped = [(o, min(gx, key=lambda g: abs(g-x))) for o, x in offsets if o % 5 == 0 and min(abs(g-x) for g in gx) < 2]
|
||
if len(snapped) < 5:
|
||
raise ValueError(f'{chainage}: insufficient offset labels')
|
||
x0 = float(np.median([x-H_SCALE*o for o, x in snapped]))
|
||
levels = [(float(w[0]), (w[2]+w[4])/2) for w in words if re.fullmatch(r'13\d{2}', w[0]) and inside(plot, (w[1]+w[3])/2, (w[2]+w[4])/2, 1)]
|
||
level_pairs = [(z, min([g for g in gy if 0 < g-y < 10])) for z, y in levels if any(0 < g-y < 10 for g in gy)]
|
||
if len(level_pairs) < 2:
|
||
raise ValueError(f'{chainage}: insufficient level labels')
|
||
y0 = float(np.median([y-V_SCALE*z for z, y in level_pairs]))
|
||
to_m = lambda p: ((p[0]-x0)/H_SCALE, (p[1]-y0)/V_SCALE)
|
||
local = []
|
||
for p in paths:
|
||
if black(p) and p['w'] in (6, 9, 12) and all(inside(plot, x, y, -.3) for x, y in p['pts']):
|
||
local.append({'w': p['w'], 'pts': [to_m(p) for p in p['pts']]})
|
||
# The 0.72 pt finished road profile is the seed. Select road layerwork,
|
||
# kerbs, sidewalks and batters connected to it; exclude separate pipe markers.
|
||
designs = [p for p in local if p['w'] in (6, 12)]
|
||
connected = [p for p in designs if p['w'] == 12]
|
||
if len(connected) != 1:
|
||
raise ValueError(f'{chainage}: expected one finished road profile')
|
||
remaining = [p for p in designs if p['w'] != 12]
|
||
for _ in range(len(remaining)+1):
|
||
geometry = [LineString(p['pts']) for p in connected]
|
||
joined = [p for p in remaining if any(LineString(p['pts']).distance(g) < .012 for g in geometry)]
|
||
if not joined:
|
||
break
|
||
connected.extend(joined)
|
||
remaining = [p for p in remaining if p not in joined]
|
||
ground = []
|
||
for p in local:
|
||
if p['w'] != 9:
|
||
continue
|
||
for a, b in zip(p['pts'], p['pts'][1:]):
|
||
if abs(a[0]-b[0]) > .02:
|
||
ground.append(tuple(sorted((a, b))))
|
||
design_segments = [tuple(sorted((a, b))) for p in connected for a, b in zip(p['pts'], p['pts'][1:]) if abs(a[0]-b[0]) > .001]
|
||
result.append(profile(chainage, ground, design_segments, len(remaining)))
|
||
return check_chainages(result)
|
||
|
||
|
||
XS_DESIGN_LAYERS = ('XS_ROAD_SURFACE', 'XS_LAYERWORKS', 'XS_KERBS', 'XS_ROAD_SHOULDERS', 'XS_SLOPE_REV')
|
||
|
||
|
||
def read_sections_cad(dxf_path):
|
||
"""Read the 40 sections from the C3277-912-001 CAD drawing (converted to DXF).
|
||
|
||
Each section is a plot frame above a caption strip "Peg Distance (m) N m". Offsets are drawn at
|
||
true horizontal scale and levels stretched (2.5x, from H 1:250 / V 1:100); both scales are measured
|
||
from the grid labels. Natural ground is the XS_GROUND polyline; the design is the road surface plus
|
||
the layerworks, kerbs, shoulders and batters connected to it. Unconnected service markers are excluded.
|
||
"""
|
||
doc = load_dxf(dxf_path)
|
||
msp = doc.modelspace()
|
||
layer = lambda e: e.dxf.layer.split('$')[-1]
|
||
lines = defaultdict(list)
|
||
for e in msp:
|
||
if e.dxftype() == 'LINE':
|
||
lines[layer(e)].append(((e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)))
|
||
grounds = [[tuple(p[:2]) for p in e.get_points()] for e in msp if e.dxftype() == 'LWPOLYLINE' and layer(e) == 'XS_GROUND']
|
||
texts = [(layer(e), e.dxf.insert.x, e.dxf.insert.y, e.dxf.text.strip()) for e in msp if e.dxftype() == 'TEXT']
|
||
horizontal = [(min(a[0], b[0]), max(a[0], b[0]), a[1]) for a, b in lines['XS_FRAME_A'] if abs(a[1]-b[1]) < 1e-6]
|
||
result = []
|
||
for kind, cx, cy, text in texts:
|
||
m = re.fullmatch(r'Peg Distance \(m\) (\d+(?:\.\d+)?) m', text) if kind == 'XS_TEXT_FRAME' else None
|
||
if not m:
|
||
continue
|
||
chainage = float(m.group(1))
|
||
above = sorted({round(y, 6) for x0, x1, y in horizontal if x0 <= cx <= x1 and y > cy})
|
||
if len(above) < 2:
|
||
raise ValueError(f'{chainage}: plot frame not found')
|
||
bottom, top = above[0], above[1]
|
||
x0, x1 = next((a, b) for a, b, y in horizontal if round(y, 6) == bottom and a <= cx <= b)
|
||
inside = lambda x, y, pad=.01: x0-pad <= x <= x1+pad and bottom-pad <= y <= top+pad
|
||
gx = sorted({a[0] for a, b in lines['XS_GRID'] if abs(a[0]-b[0]) < 1e-6 and inside(*a)})
|
||
gy = sorted({a[1] for a, b in lines['XS_GRID'] if abs(a[1]-b[1]) < 1e-6 and inside(*a)})
|
||
offsets = [(float(t), min(gx, key=lambda g: abs(g-x))) for k, x, y, t in texts
|
||
if k == 'XS_TEXT_GRID' and re.fullmatch(r'-?\d{1,2}', t) and x0 <= x <= x1 and bottom-4 <= y < bottom]
|
||
levels = [(float(t), max([g for g in gy if g <= y+1e-6], default=None)) for k, x, y, t in texts
|
||
if k == 'XS_TEXT_GRID' and re.fullmatch(r'1[34]\d{2}', t) and inside(x, y, .5)]
|
||
levels = [(z, g) for z, g in levels if g is not None]
|
||
if len(offsets) < 5 or len(levels) < 2:
|
||
raise ValueError(f'{chainage}: grid labels unreadable')
|
||
h_scale = np.polyfit([o for o, _ in offsets], [x for _, x in offsets], 1)[0]
|
||
v_scale = np.polyfit([z for z, _ in levels], [y for _, y in levels], 1)[0]
|
||
if abs(h_scale-1) > .01 or abs(v_scale-2.5) > .02:
|
||
raise ValueError(f'{chainage}: unexpected drawing scales {h_scale:.3f} / {v_scale:.3f}')
|
||
xo = float(np.median([x-h_scale*o for o, x in offsets]))
|
||
yo = float(np.median([y-v_scale*z for z, y in levels]))
|
||
to_m = lambda p: ((p[0]-xo)/h_scale, (p[1]-yo)/v_scale)
|
||
ground_lines = [g for g in grounds if all(inside(*p) for p in g)]
|
||
if len(ground_lines) != 1:
|
||
raise ValueError(f'{chainage}: expected one natural ground line, found {len(ground_lines)}')
|
||
ground = [tuple(sorted((to_m(a), to_m(b)))) for a, b in zip(ground_lines[0], ground_lines[0][1:]) if abs(a[0]-b[0]) > 1e-6]
|
||
design = [(name, tuple(sorted((to_m(a), to_m(b))))) for name in XS_DESIGN_LAYERS for a, b in lines[name] if inside(*a) and inside(*b)]
|
||
connected = [seg for name, seg in design if name == 'XS_ROAD_SURFACE']
|
||
if not connected:
|
||
raise ValueError(f'{chainage}: no finished road profile')
|
||
remaining = [seg for name, seg in design if name != 'XS_ROAD_SURFACE']
|
||
while True:
|
||
geometry = [LineString(seg) for seg in connected]
|
||
joined = [seg for seg in remaining if any(LineString(seg).distance(g) < .005 for g in geometry)]
|
||
if not joined:
|
||
break
|
||
connected.extend(joined)
|
||
remaining = [seg for seg in remaining if seg not in joined]
|
||
design_segments = [seg for seg in connected if abs(seg[0][0]-seg[1][0]) > .001]
|
||
result.append(profile(chainage, ground, design_segments, len(remaining)))
|
||
return check_chainages(result)
|
||
|
||
|
||
def read_long_section(dxf_path):
|
||
"""Read the level bands under each panel of the C3277-911-001 road long section.
|
||
|
||
Returns one panel per road: rows of chainage with centreline ground level and the design levels at the
|
||
centreline and 2.5 m left and right, wherever the drawing gives them.
|
||
"""
|
||
doc = load_dxf(dxf_path)
|
||
msp = doc.modelspace()
|
||
frame = [((e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)) for e in msp if e.dxftype() == 'LINE' and e.dxf.layer.endswith('CC_LS_FRAME')]
|
||
top = max(max(a[1], b[1]) for a, b in frame)
|
||
panels = sorted((min(a[0], b[0]), max(a[0], b[0])) for a, b in frame if abs(a[1]-top) < 1e-6 and abs(b[1]-top) < 1e-6)
|
||
texts = [(e.dxf.insert.x, e.dxf.insert.y, e.dxf.text.strip(), round(e.dxf.get('rotation', 0))) for e in msp
|
||
if e.dxftype() == 'TEXT' and e.dxf.layer.endswith('CC_LS_TEXT')]
|
||
bands = {'Peg Distance (m)': 'ch', 'Ground Level (m) (@GLO)': 'ground', 'Left': 'left', 'Center': 'centre', 'Right': 'right'}
|
||
result = []
|
||
for x0, x1 in panels:
|
||
labels = [(y, bands[t]) for x, y, t, r in texts if x0 <= x <= x1 and t in bands and r == 0]
|
||
if sorted(b for _, b in labels) != sorted(bands.values()):
|
||
raise ValueError(f'Long-section panel at x {x0:.1f}: level bands not found')
|
||
columns = defaultdict(dict)
|
||
for x, y, t, r in texts:
|
||
if not (x0 <= x <= x1 and r == 90 and re.fullmatch(r'\d+(\.\d+)?', t)):
|
||
continue
|
||
# A value sits in the band whose label is level with it or up to 8 units above it.
|
||
band = min(((ly-y, b) for ly, b in labels if -1 <= ly-y <= 8), default=None)
|
||
if band:
|
||
columns[round(x, 1)][band[1]] = float(t)
|
||
rows = sorted((c for c in columns.values() if 'ch' in c), key=lambda c: c['ch'])
|
||
result.append({'length': rows[-1]['ch'], 'rows': rows})
|
||
return result
|
||
|
||
|
||
def long_section_check(sections, panel):
|
||
"""Compare each cross-section's centreline ground and design level with the long section at that chainage."""
|
||
rows = {round(r['ch'], 3): r for r in panel['rows']}
|
||
centre = int(round(15/STEP))
|
||
side = int(round(2.5/STEP))
|
||
ground, design = [], []
|
||
for s in sections:
|
||
r = rows.get(round(s['ch'], 3))
|
||
if not r:
|
||
continue
|
||
if 'ground' in r:
|
||
ground.append(float(s['ngl'][centre])-r['ground'])
|
||
if 'centre' in r and np.isfinite(s['top'][centre]):
|
||
design.append(float(s['top'][centre])-r['centre'])
|
||
# Left/right are a matter of drawing direction: compare with whichever pairing agrees.
|
||
if 'left' in r and 'right' in r and np.isfinite(s['top'][centre-side]) and np.isfinite(s['top'][centre+side]):
|
||
a = (s['top'][centre-side]-r['left'], s['top'][centre+side]-r['right'])
|
||
b = (s['top'][centre-side]-r['right'], s['top'][centre+side]-r['left'])
|
||
design.extend(min((a, b), key=lambda d: abs(d[0])+abs(d[1])))
|
||
if len(ground) < 30 or len(design) < 30:
|
||
raise ValueError(f'Long section matches too few cross-sections: {len(ground)} ground, {len(design)} design levels')
|
||
stats = lambda d: {'samples': len(d), 'medianAbs': round(float(np.median(np.abs(d))), 4), 'maxAbs': round(float(np.max(np.abs(d))), 4)}
|
||
report = {'ground': stats(ground), 'design': stats(design)}
|
||
if report['ground']['medianAbs'] > .02 or report['design']['medianAbs'] > .02 or report['ground']['maxAbs'] > .1 or report['design']['maxAbs'] > .1:
|
||
raise ValueError(f'Cross-sections disagree with the long section: {report}')
|
||
return report
|
||
|
||
|
||
def dem(surface):
|
||
q = np.frombuffer(base64.b64decode(surface['z']), dtype='<i2')
|
||
grid = np.where(q < 0, np.nan, surface['zmin']+q/1000).reshape(surface['ny'], surface['nx'])
|
||
def at(e, n):
|
||
x, y = (e-surface['x0'])/surface['cell'], (n-surface['y0'])/surface['cell']
|
||
if x < 0 or y < 0 or x > surface['nx']-1 or y > surface['ny']-1:
|
||
return None
|
||
i, j = min(int(x), surface['nx']-2), min(int(y), surface['ny']-2)
|
||
u, v = x-i, y-j
|
||
values = grid[j:j+2, i:i+2].ravel(); weights = np.array([(1-u)*(1-v), u*(1-v), (1-u)*v, u*v])
|
||
valid = np.isfinite(values)
|
||
if not valid.any() or weights[valid].sum() == 0:
|
||
return None
|
||
return float(np.sum(values[valid]*weights[valid])/np.sum(weights[valid]))
|
||
return at
|
||
|
||
|
||
def build_sections(sections, raw, folder, source, long_section=None):
|
||
road = json.loads((folder/'bushveld.json').read_text())
|
||
surface = json.loads((folder/'surface.json').read_text())
|
||
previous = json.loads((folder/'earthworks.json').read_text())
|
||
rows = raw['road']['RDS_LO$0$CC_RDS_NEW_INT_CL']
|
||
line = alignment(rows, rows[0]['pts'][0], 385.411)
|
||
ox, oy = road['origin']['cadX'], road['origin']['cadY']
|
||
def at(ch):
|
||
distance = min(max(ch, 0), line.length)
|
||
point = line.interpolate(distance)
|
||
a, b = line.interpolate(max(0, distance-.05)), line.interpolate(min(line.length, distance+.05))
|
||
direction = np.array([b.x-a.x, b.y-a.y]); direction /= np.linalg.norm(direction)
|
||
return np.array([point.x-ox, point.y-oy]), direction
|
||
level = dem(surface)
|
||
def differences(side):
|
||
errors = []
|
||
for s in sections:
|
||
point, direction = at(s['ch']); normal = np.array([direction[1], -direction[0]])*side
|
||
for offset in np.arange(-2, 2.01, .5):
|
||
index = int(round((offset+15)/STEP)); z = s['top'][index]
|
||
if not np.isfinite(z):
|
||
continue
|
||
e, n = point+normal*offset; value = level(e, n)
|
||
if value is not None:
|
||
errors.append(float(z-value))
|
||
return np.array(errors)
|
||
errors = {side: differences(side) for side in (1, -1)}
|
||
side = min(errors, key=lambda side: np.median(np.abs(errors[side])))
|
||
delta = errors[side]
|
||
if len(delta) < 200 or np.median(np.abs(delta)) > .15 or np.percentile(np.abs(delta), 90) > .35:
|
||
raise ValueError(f'Cross-sections do not agree with finished-level contours: {len(delta)} samples, median {np.median(np.abs(delta))}, p90 {np.percentile(np.abs(delta), 90)}')
|
||
out = []
|
||
for s in sections:
|
||
point, direction = at(s['ch'])
|
||
keep = np.arange(0, len(s['x']), 5) # 0.25 m browser samples
|
||
out.append({'ch': s['ch'], 'e': round(float(point[0]), 4), 'n': round(float(point[1]), 4), 'dir': direction.round(7).tolist(), 'side': side,
|
||
'x': s['x'][keep].round(3).tolist(), 'ngl': s['ngl'][keep].round(4).tolist(),
|
||
'top': [round(float(z), 4) if np.isfinite(z) else None for z in s['top'][keep]],
|
||
'bot': [round(float(z), 4) if np.isfinite(z) else None for z in s['bot'][keep]],
|
||
'cutArea': round(s['cutArea'], 4), 'fillArea': round(s['fillArea'], 4)})
|
||
volumes = [{'from': a['ch'], 'to': b['ch'], 'cut': round((a['cutArea']+b['cutArea'])*.5*(b['ch']-a['ch']), 3),
|
||
'fill': round((a['fillArea']+b['fillArea'])*.5*(b['ch']-a['ch']), 3)} for a, b in zip(sections, sections[1:])]
|
||
section_keys = [s['key'] for s in road['sections'] if s.get('name') == 'Cedar Gate Road']
|
||
lengths = {name: max(s['to'] for s in road['sections'] if s.get('name') == name) for name in {s.get('name') for s in road['sections']}}
|
||
long_report = None
|
||
if long_section:
|
||
named = {min(lengths, key=lambda n: abs(lengths[n]-panel['length'])): panel for panel in long_section}
|
||
long_report = {'check': long_section_check(sections, named['Cedar Gate Road']), 'roads': {
|
||
name: {'length': panel['length'], 'levels': sum('ground' in r and 'centre' in r for r in panel['rows']),
|
||
'maxCentreCut': round(max([r['ground']-r['centre'] for r in panel['rows'] if 'ground' in r and 'centre' in r] or [0]), 3),
|
||
'maxCentreFill': round(max([r['centre']-r['ground'] for r in panel['rows'] if 'ground' in r and 'centre' in r] or [0]), 3)}
|
||
for name, panel in named.items()}}
|
||
for s in road['sections']:
|
||
if s['key'] == section_keys[-1]:
|
||
s['to'] = 385.411
|
||
method = 'End-area between 10 m drawn sections (last strip 5.411 m); natural ground versus connected road formation, including the revised side slopes to be shaped. Service trench symbols excluded.'
|
||
if long_report:
|
||
method += f" Centreline ground and design levels agree with the C3277-911-001 long section to {long_report['check']['ground']['medianAbs']*1000:.0f} mm / {long_report['check']['design']['medianAbs']*1000:.0f} mm median."
|
||
result = {'source': source, 'method': method,
|
||
'sectionKeys': section_keys, 'chainageOffset': 0, 'chainageEnd': 385.411, 'sections': out, 'volumes': volumes,
|
||
'alignment': [[round(float(ch), 3), *at(ch)[0].round(4).tolist()] for ch in np.arange(0, line.length, 2)],
|
||
'totals': {key: round(sum(v[key] for v in volumes), 3) for key in ['cut', 'fill']},
|
||
'validation': {'samples': len(delta), 'meanAbs': round(float(np.mean(np.abs(delta))), 4), 'median': round(float(np.median(delta)), 4),
|
||
'medianAbs': round(float(np.median(np.abs(delta))), 4), 'p90Abs': round(float(np.percentile(np.abs(delta), 90)), 4),
|
||
'note': 'Drawn finished road profile versus independently extracted FRL_CONTOURS; tested both offset directions.'}}
|
||
if previous.get('groundSurface'):
|
||
result['groundSurface'] = previous['groundSurface']
|
||
old = 'Bulk cut/fill quantities are unavailable until the road cross-sections and formation levels are reconciled; no zero-volume claim is made.'
|
||
replacement = 'Cut/fill covers Cedar Gate Road only, from C3277-912-001 Rev A sections 0–385.411 m. Road formation earthworks exclude service trenches and external roads; fill is design compacted fill, not pipe-trench backfill.'
|
||
road['notes'] = [replacement if note == old else note for note in road.get('notes', [])]
|
||
(folder/'earthworks.json').write_text(json.dumps(result, separators=(',', ':'), allow_nan=False))
|
||
(folder/'bushveld.json').write_text(json.dumps(road, separators=(',', ':'), allow_nan=False))
|
||
report = {'sections': len(out), 'chainage': [0, 385.411], 'totals': result['totals'], 'validation': result['validation'],
|
||
'offset_side': side, 'widths': [[s['ch'], round(s['roadWidth'], 3)] for s in sections],
|
||
'excluded_independent_service_paths': sum(s['excludedIndependentPaths'] for s in sections), 'scope': replacement,
|
||
'longSection': long_report,
|
||
'earthworks_sha256': hashlib.sha256((folder/'earthworks.json').read_bytes()).hexdigest()}
|
||
(folder/'earthworks-validation.json').write_text(json.dumps(report, indent=2))
|
||
validation_path = folder/'validation.json'
|
||
if validation_path.exists():
|
||
validation = json.loads(validation_path.read_text()); validation['earthworks'] = report; validation['notes'] = road['notes']
|
||
validation['sha256'] = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in [folder/f'{f}.json' for f in ['bushveld','services','earthworks','schedules','surface']]}
|
||
validation_path.write_text(json.dumps(validation, indent=2))
|
||
print(json.dumps(report, indent=2))
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
parser.add_argument('cross_sections', type=Path, help='C3277-912-001 as DXF (converted from the DWG) or as the plotted PDF')
|
||
parser.add_argument('raw_geometry', type=Path)
|
||
parser.add_argument('model_folder', type=Path)
|
||
parser.add_argument('--long-section', type=Path, help='C3277-911-001 road long section as DXF; checks every cross-section')
|
||
parser.add_argument('--poppler-bin', type=Path, default=Path('/usr/local/bin'))
|
||
args = parser.parse_args()
|
||
if args.cross_sections.suffix.lower() == '.dxf':
|
||
sections = read_sections_cad(args.cross_sections)
|
||
else:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
svg, words = Path(tmp)/'sections.svg', Path(tmp)/'words.html'
|
||
subprocess.run([str(args.poppler_bin/'pdftocairo'), '-svg', str(args.cross_sections), str(svg)], check=True)
|
||
subprocess.run([str(args.poppler_bin/'pdftotext'), '-bbox', str(args.cross_sections), str(words)], check=True)
|
||
sections = read_sections(svg, words)
|
||
long_section = read_long_section(args.long_section) if args.long_section else None
|
||
sources = ' + '.join(p.name.replace('.dxf', '.dwg') if p.suffix.lower() == '.dxf' else p.name for p in [args.cross_sections, args.long_section] if p)
|
||
build_sections(sections, json.loads(args.raw_geometry.read_text()), args.model_folder, sources, long_section)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|