"""Read-only saved-model audit of shared body-tie placement and preservation.""" import argparse from collections import Counter import hashlib import json import math from pathlib import Path import sys root = Path(__file__).resolve().parents[1] sys.path.insert(0, str(root/'src/backend' if (root/'src/backend').is_dir() else root)) from quantity_review_validation import bend_paths from tie_end_spacing import inspect_visible_ends def audit(job_path, baseline_path): job=json.loads(Path(job_path).read_text());before=json.loads(Path(baseline_path).read_text()) plan=job['result'];old=before['result'];reports=[];changed=set() for group in plan['rebar_groups']: distribution=group.get('quantity_distribution',{}) rhythm=distribution.get('rhythm',{}) if 'end_balance_required' not in rhythm:continue changed.add(group['id']) records=distribution['records'];paths=group['instance_paths_m'] ends=inspect_visible_ends(plan,group,[b['path'] for b in bend_paths(group)]) if not ends['pass'] and rhythm.get('end_spacing_priority') == 'equal_ends': raise ValueError('visible concrete-to-tie end spacing differs: '+json.dumps(ends)) counts=Counter(r['lane'] for r in records) repeat_errors=[] for lane in counts: xs=sorted(r['x_m'] for r in records if r['lane']==lane) repeat_errors.extend(abs(b-a-rhythm['repeat_m']) for a,b in zip(xs,xs[1:])) phase_errors=[] for side in rhythm['side_lanes']: events=sorted((r['x_m'],r['lane']) for r in records if r['lane'] in side) for (x,lane),(y,next_lane) in zip(events,events[1:]): if lane==next_lane:raise ValueError('staggered lane repeat') phase_errors.append(abs(y-x-rhythm['phase_m'])) lengths=[sum(math.dist(a,b) for a,b in zip(path,path[1:])) for path in paths] detail=group['detail_shape_binding']['fabrication']['points_m'] expected=[math.dist(a,b) for a,b in zip(detail,detail[1:])] for path in paths: measured=[math.dist(a,b) for a,b in zip(path,path[1:])] if len(measured)!=len(expected) or max(abs(a-b) for a,b in zip(measured,expected))>.001: raise ValueError('fabrication segments changed') if len(paths)!=group['schedule_count']:raise ValueError('drawing count changed') if max(repeat_errors+phase_errors+[0])>1e-6:raise ValueError('fixed rhythm changed') if max(abs(v-group['unit_length_m']) for v in lengths)>.001:raise ValueError('fabrication length changed') if not group['placement_geometry_check'].get('pass'):raise ValueError('geometry gate missing') if not all(r['contact']['pass'] for r in records):raise ValueError('raw contact failed') if not all(r['pass'] for r in distribution['rounded_contact']):raise ValueError('rounded contact failed') a,b=rhythm['side_lengths_m'];n,m=rhythm['side_counts'] if abs(a-b)>1e-5 and (a-b)*(n-m)<=0:raise ValueError('long side not more populated') reports.append({'block':group['rebar_block'],'mark':group['mark'],'count':len(paths), 'side_counts':rhythm['side_counts'],'side_lengths_m':rhythm['side_lengths_m'], 'repeat_m':rhythm['repeat_m'],'phase_m':rhythm['phase_m'], 'end_balance_required':rhythm['end_balance_required'],'end_margins_m':rhythm['end_margins_m'], 'visible_end_check':ends, 'skew_contact_bars':sum('skew' in r.get('contact_domains',[]) for r in records), 'max_unit_length_error_m':max(abs(v-group['unit_length_m']) for v in lengths), 'geometry_and_contacts_pass':True}) def unchanged(groups): return Counter(json.dumps({k:v for k,v in g.items() if k!='rebar_assembly_label'},sort_keys=True) for g in groups if g.get('id') not in changed) if plan['concrete_components']!=old['concrete_components']:raise ValueError('concrete changed') if unchanged(plan['rebar_groups'])!=unchanged(old['rebar_groups']):raise ValueError('unrelated rebar changed') return {'job':Path(job_path).stem,'baseline':str(baseline_path),'geometryRevision':job.get('geometryRevision'), 'sha256':hashlib.sha256(Path(job_path).read_bytes()).hexdigest(),'groups':reports, 'unrelated_rebar_and_concrete_preserved':True,'engineering_approval':False} if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('job');parser.add_argument('baseline');parser.add_argument('--output',required=True) args=parser.parse_args();report=audit(args.job,args.baseline) with open(args.output,'x') as stream:json.dump(report,stream,ensure_ascii=False,indent=2) print(json.dumps({'groups':len(report['groups']),'bars':sum(g['count'] for g in report['groups']), 'unrelated_preserved':True},ensure_ascii=False))