25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
220 lines
11 KiB
Python
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()
|