Files
willtools-demo/beamcalc/tools/eriksson_extract.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

220 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""Pull the result tables out of an Eriksson Beam v5.9 PDF, deterministically.
python beamcalc/tools/eriksson_extract.py <package.pdf> <out.json>
Uses pymupdf word coordinates: every printed table row is one text line, so
words are grouped by their y position and split on the known column count.
Nothing is inferred -- a row that does not have the expected number of
numeric cells is skipped and counted in `skipped`. Only numbers are kept;
the title block (client / job / engineer) is not copied out.
Tables recognised (first run in the document only -- the "END CHECK" strut
and tie re-run that some packages append is ignored on purpose):
summary -- the "Summary" page numbers (reactions, bearing, strength, service)
flexure -- In-Place Strength / Flexure Summary Table (14 columns)
shear -- In-Place Strength / Shear Summary Table (15 columns)
tension -- In-Place Service / Tension: station, psi, cracking psi, Mz
comp_total -- Total Compression: station, psi, Mz
comp_sust -- Sustained Compression: station, psi, Mz
deflection -- Camber and Deflections: rows {case, stage, mult, values[11]}
"""
from __future__ import annotations
import json
import re
import sys
import pymupdf
NUM = re.compile(r'^-?\d+(\.\d+)?$')
def _num(t):
if t == 'NC':
return None
return float(t)
def lines_of(page):
rows = {}
for x0, y0, x1, y1, word, b, l, n in page.get_text('words'):
rows.setdefault(round(y0), []).append((x0, word))
out = []
for y in sorted(rows):
out.append([w for x, w in sorted(rows[y])])
return out
FLEX_COLS = ['x_ft', 'util', 'mu', 'phi', 'tc', 'phi_mn', 'mcr12', 'c', 'theta',
'as', 'as_c', 'aps', 'loss', 'd']
SHEAR_COLS = ['x_ft', 'util', 'vu', 'phi_vn', 'vc', 'vs_prov', 'bwd', 'legs',
'av_min', 'av_prov', 'av_req', 's', 's_max', 'f_act', 'f_alw']
def parse_row(words, cols):
if len(words) != len(cols):
return None
row = {}
for c, w in zip(cols, words):
if c == 'tc':
row[c] = w
continue
if not (NUM.match(w) or w == 'NC'):
return None
row[c] = _num(w)
return row
def extract(pdf_path):
doc = pymupdf.open(pdf_path)
out = {'flexure': [], 'shear': [], 'tension': [], 'comp_total': [], 'comp_sust': [],
'deflection': [], 'summary': {}, 'skipped': 0, 'pages': len(doc)}
mode = None # which table the following numeric rows belong to
stage = None # 'strength' / 'service' / 'stripping' ... from page headers
defl_stage = None
defl_case = None
seen_summary = False
seen_run_end = False
for pno, page in enumerate(doc, 1):
text = page.get_text()
if 'Project Information' in text and seen_summary:
# a second Eriksson run begins (END CHECK etc.) -- stop
seen_run_end = True
if seen_run_end:
break
for words in lines_of(page):
line = ' '.join(words)
# --- section headers drive the mode
if line.startswith('In-Place Strength Summary'):
stage = 'strength'; mode = None
elif line.startswith('In-Place Service Summary'):
stage = 'service'; mode = None
elif line.startswith('Camber and Deflections') and 'Note' not in line:
stage = 'deflection'; mode = 'defl'
elif line.split(' ')[0] in ('Stripping', 'Handling', 'Erection', 'Transfer') and 'Summary' in line:
stage = 'handling'; mode = None
elif line == 'Flexure' and stage == 'strength':
mode = 'flexure'
elif line.startswith('Pure Shear') and stage == 'strength':
mode = 'shear'
elif line.startswith('Loc. Util Vu') and stage == 'strength':
mode = 'shear'
elif line.startswith('Loc. Util.') and stage == 'strength':
mode = 'flexure' if mode != 'shear' else mode
elif line.startswith('Precast') and stage == 'service':
pass
elif line.startswith('Location Tension Cracking Mz'):
mode = 'tension'
elif line.startswith('Total Compression'):
mode = 'comp_total'
elif line.startswith('Sustained Compression'):
mode = 'comp_sust'
elif line.startswith('Servicability Class') or line.startswith('Location Tension Class'):
mode = 'svc_class'
elif line.startswith('Torsion') and stage == 'strength':
mode = None
if stage == 'deflection':
if line in ('Start of Construction', 'End of Construction'):
defl_stage = line
elif line.startswith('Final') and len(words) == 1:
defl_stage = 'Final'
elif line.startswith('Deflections Occuring Prior'):
defl_stage = 'Final prior'
elif line.startswith('Deflections Occuring After'):
defl_stage = 'Final after'
elif line == 'Total Deflections':
defl_stage = 'Total'
elif len(words) == 1 and not NUM.match(words[0]):
if words[0] in ('SW', 'Dnc', 'Dt', 'Dc', 'Live', 'Roof', 'Wind', 'Seismic', 'PS', 'Total'):
defl_case = words[0]
elif len(words) == 2 and words[0] == 'Sust.' and words[1] == 'Live':
defl_case = 'Sust. Live'
elif len(words) == 12 and all(NUM.match(w) for w in words) and defl_case:
out['deflection'].append({'stage': defl_stage, 'case': defl_case,
'mult': float(words[0]),
'values': [float(w) for w in words[1:]],
'page': pno})
defl_case = None
elif len(words) == 11 and all(NUM.match(w) for w in words) and defl_case is None and not out.get('defl_x'):
out['defl_x'] = [float(w) for w in words]
continue
if mode == 'flexure':
r = parse_row(words, FLEX_COLS)
if r:
r['page'] = pno; out['flexure'].append(r)
elif words and NUM.match(words[0]) and len(words) > 8:
out['skipped'] += 1
elif mode == 'shear':
r = parse_row(words, SHEAR_COLS)
if r:
r['page'] = pno; out['shear'].append(r)
elif words and NUM.match(words[0]) and len(words) > 8:
out['skipped'] += 1
elif mode == 'tension' and len(words) == 4 and all(NUM.match(w) for w in words):
out['tension'].append({'x_ft': float(words[0]), 'f': float(words[1]),
'f_cr': float(words[2]), 'mz': float(words[3]), 'page': pno})
elif mode in ('comp_total', 'comp_sust') and len(words) == 3 and all(NUM.match(w) for w in words):
out[mode].append({'x_ft': float(words[0]), 'f': float(words[1]),
'mz': float(words[2]), 'page': pno})
# --- the one-page Summary block (regex over the page text)
if 'Stage: In-Place Strength' in text and 'Stage: In-Place Service' in text and not seen_summary:
seen_summary = True
s = out['summary']
flat = text.replace('\n', ' ')
def grab(pattern, cast=float):
m = re.search(pattern, flat)
return cast(m.group(1)) if m else None
s['flex_util'] = grab(r'Stage: In-Place Strength.*?Utilization\s*:\s*([\d.]+)')
s['flex_phi_mn'] = grab(r'Stage: In-Place Strength.*?ϕMn \(k-ft\):\s*([\d.-]+)')
s['flex_mu'] = grab(r'Stage: In-Place Strength.*?Mu \(k-ft\):\s*([\d.-]+)')
s['av_s'] = grab(r'Av/s \(in²/ft\):\s*([\d.]+)')
s['vc'] = grab(r'Vc \(k\)\s*:\s*([\d.]+)')
s['vu'] = grab(r'Vu \(k\)\s*:\s*([\d.]+)')
s['defl_total_up'] = grab(r'Defl\. Total\s*↑ \(in\):\s*([\d.-]+)')
s['defl_total_down'] = grab(r'Defl\. Total.*?↓ \(in\):\s*([\d.-]+)')
s['defl_total_limit'] = grab(r'Defl\. Total.*?Limit \(in\):\s*([\d.]+)')
m = re.search(r'LL\s*↑ \(in\):\s*([\d.-]+)\s*↓ \(in\):\s*([\d.-]+)\s*Limit \(in\):\s*([\d.]+)', flat)
if m:
s['defl_live_up'], s['defl_live_down'], s['defl_live_limit'] = (float(v) for v in m.groups())
m = re.search(r'Defl\. Total\s*↑ \(in\):\s*([\d.-]+)\s*↓ \(in\):\s*([\d.-]+)\s*Limit \(in\):\s*([\d.]+)', flat)
if m:
s['defl_total_up'], s['defl_total_down'], s['defl_total_limit'] = (float(v) for v in m.groups())
s['comp_tot_max'] = grab(r'Comp\. Tot\. Max \(psi\):\s*([\d.]+)')
s['comp_sust_max'] = grab(r'Comp\. Sust\.Max \(psi\):\s*([\d.]+)')
s['tension_max'] = grab(r'Tension\s*Max \(psi\):\s*([\d.]+)')
s['class_u_limit'] = grab(r'Class U Limit \(psi\):\s*([\d.]+)')
s['class_t_limit'] = grab(r'Class T Limit \(psi\):\s*([\d.]+)')
m = re.search(r'Fy\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)', flat)
if m:
g = [float(v) for v in m.groups()]
s['reactions'] = {'deadnc': g[0:2], 'deadc': g[2:4], 'live': g[4:6], 'roof': g[6:8]}
m = re.search(r'Left\s+([\d.]+)\s+([\d.]+)\s+.*?Right\s+([\d.]+)\s+([\d.]+)', flat)
if m:
s['bearing'] = {'left': {'vu': float(m.group(1)), 'phi_vn_plain': float(m.group(2))},
'right': {'vu': float(m.group(3)), 'phi_vn_plain': float(m.group(4))}}
m = re.search(r'Stage: Stripping.*?Max Shear \(k\)\s*:\s*([\d.]+)\s*Max Tension \(k\):\s*([\d.]+).*?Compression \(psi\):\s*(\d+)/(\d+)\s*Tension \(psi\)\s*:\s*(\d+) / (\d+).*?Utilization:\s*([\d.]+)\s*ϕMn \(k-ft\):\s*([\d.]+)\s*Mu \(k-ft\):\s*([\d.]+)', flat)
if m:
g = m.groups()
s['stripping'] = {'max_shear': float(g[0]), 'max_tension': float(g[1]),
'comp': [float(g[2]), float(g[3])], 'tens': [float(g[4]), float(g[5])],
'util': float(g[6]), 'phi_mn': float(g[7]), 'mu': float(g[8])}
s['page'] = pno
return out
def main():
src, dst = sys.argv[1], sys.argv[2]
data = extract(src)
with open(dst, 'w', encoding='utf-8') as fh:
json.dump(data, fh, indent=1)
print('%s: flexure %d rows, shear %d, tension %d, comp %d/%d, defl rows %d, skipped %d, summary keys %d'
% (dst, len(data['flexure']), len(data['shear']), len(data['tension']),
len(data['comp_total']), len(data['comp_sust']), len(data['deflection']),
data['skipped'], len(data['summary'])))
if __name__ == '__main__':
main()