fix(ground): 측량 레이어로 운곡교 지형 추출 대상 제한

This commit is contained in:
ypshim committed 2026-11-10 09:54:34 +09:00
1 parent 38854cbe4c
commit 4ef8412d89
5 files changed
+254

No files matched your search

@@ -12,6 +12,8 @@ import numpy as np
from ezdxf import recover
from pyproj import Transformer
from pierzainer_ground.dxf_terrain import _densify,read_source_document
from pierzainer_ground.config import GroundConfig
from pierzainer_ground.dxf_terrain import read_terrain_dxf
from pierzainer_ground.dtm import DTM
ROOT=Path(__file__).resolve().parent.parent
@@ -63,6 +65,33 @@ def read_survey_block(source, project, doc=None):
block=doc.blocks.get(project['terrain_block'])
if block is None:
raise ValueError(f"측량 블록이 없습니다: {project['terrain_block']}")
# A plan's modelspace mixes contours with borehole symbols and design
# outlines that also carry Z. Prefer the established semantic reader when
# this drawing declares known survey layers; retain the block reader for
# original survey XREFs with their own layer conventions.
cfg=GroundConfig(cad_axes=project.get('cad_axes','YX'),use_profile_points=False,
z_min=project.get('z_min',0),z_max=project.get('z_max',200),
contour_step=project['contour_step_m'],dedup_xy=project['dedup_m'])
allowed=cfg.layers.spot_block+cfg.layers.spot_text+cfg.layers.contour
if project.get('layers'):
allowed=tuple(layer for layer in allowed if layer in project['layers'])
if (block.name.lower()=='*model_space' and project.get('prefer_recognized_layers',True)
and any(e.dxf.layer in allowed for e in block)):
cfg.allowed_terrain_layers=allowed
terrain=read_terrain_dxf(source,cfg,document=doc)
a=terrain.points
if project.get('exclude_zero'):
a=a[np.abs(a[:,2])>1e-6]
if len(a)<3:
raise ValueError('인식된 측량 레이어에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
if project.get('terrain_crs') and project.get('crs'):
east,north=Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True).transform(a[:,1],a[:,0])
a=np.column_stack([north,east,a[:,2]])
if not np.isfinite(a).all():
raise ValueError('측량 레이어 좌표 변환 결과가 유효하지 않습니다.')
sources=terrain.stats['by_source']
return a,{'semantic_layer_filter':1,'survey_points':sources['spot'],
'spot_text_points':sources['text'],'contour_points':sources['contour']},terrain.stats['contours']
to_model=(Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True)
if project.get('terrain_crs') and project.get('crs') else None)
points=[]; contours=[]; types=Counter()
@@ -44,4 +44,27 @@ class SurveyPolylineTests(unittest.TestCase):
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
self.assertNotIn('rejected_height_outliers',counts);self.assertEqual(points[:,2].max(),84)
def test_known_plan_layers_exclude_near_height_bore_and_base(self):
doc=ezdxf.new();msp=doc.modelspace()
for y,z in [(0,30),(100,32)]:
msp.add_lwpolyline([(0,y),(100,y)],dxfattribs={'layer':'CAA001','elevation':z})
doc.blocks.new('SPOT').add_circle((0,0),.1)
msp.add_blockref('SPOT',(20,50,31),dxfattribs={'layer':'CA002'})
msp.add_text('30.5',dxfattribs={'layer':'CA0021','insert':(80,50)})
for layer in ['CV-BORE-SYMB','CZ-BASE','CF-BRDG-NEWC']:
msp.add_lwpolyline([(45,45),(55,45),(55,55)],dxfattribs={'layer':layer,'elevation':27.5})
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
self.assertGreaterEqual(points[:,2].min(),30)
self.assertEqual(counts['semantic_layer_filter'],1)
self.assertEqual(counts['survey_points'],1)
self.assertEqual(counts['spot_text_points'],1)
self.assertTrue(any(np.array_equal(p,[50,20,31]) for p in points))
self.assertTrue(any(np.array_equal(p,[50,80,30.5]) for p in points))
def test_known_layers_do_not_fall_back_to_symbols_when_empty(self):
doc=ezdxf.new();msp=doc.modelspace()
msp.add_lwpolyline([(0,0),(10,0)],dxfattribs={'layer':'CAA001','elevation':0})
msp.add_lwpolyline([(0,0),(10,0),(10,10)],dxfattribs={'layer':'CV-BORE-SYMB','elevation':28})
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
if __name__=='__main__':unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""Regenerate the user's two inputs in a separate folder, reusing exact caches."""
import hashlib
import json
import shutil
import sys
from pathlib import Path
import numpy as np
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'pierzainer_ground'))
from pierzainer_ground.drawing_job import DrawingJob,build_drawing
old=root/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
target=root/'pierzainer_ground/data/output/ungok_corrected_20261110'
evidence=root/'.verify/ungok-terrain-fixed-20261110'
evidence.mkdir(parents=True,exist_ok=True)
settings=json.loads((old/'generation_settings.json').read_text())
old_file=old/'ground_model_3d_with_maps.json'
sources=[Path(settings['drawing']),Path(settings['piers']),old_file]
digest=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
before={str(p):digest(p) for p in sources}
if (target/'ground_model_3d_with_maps.json').exists():
raise SystemExit('Corrected result already exists; preserve it rather than overwrite.')
(target/'cache').mkdir(parents=True,exist_ok=True)
for path in (old/'cache').iterdir():
if path.name.endswith(('.dxf','.dxf.source.json')) or path.name=='map_layers.json':
shutil.copyfile(path,target/'cache'/path.name)
settings.update(output=str(target),allow_download=False)
log=[]
data=build_drawing(DrawingJob(**settings),lambda text:(log.append(text),print(text,flush=True)))
original=json.loads(old_file.read_text())
def metrics(d):
g=d['terrain_grid'];z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
edges=np.concatenate([np.abs(np.diff(z,axis=0)).ravel(),np.abs(np.diff(z,axis=1)).ravel()]);edges=edges[np.isfinite(edges)]
return {'valid_nodes':int(np.isfinite(z).sum()),'min_el':float(np.nanmin(z)),'max_el':float(np.nanmax(z)),
'max_adjacent_dz':float(edges.max()),'edges_over_5m':int((edges>5).sum()),'edges_over_8m':int((edges>8).sum())}
checks={
'original_inputs_and_previous_result_unchanged':all(digest(p)==before[str(p)] for p in sources),
'all_16_centers_have_ground':len(data['piers'])==16 and all(p['ground']['center'] is not None for p in data['piers']),
'same_structure_ids_and_coordinates':[(p['id'],p['X'],p['Y']) for p in data['piers']]==[(p['id'],p['X'],p['Y']) for p in original['piers']],
'same_grid_layout':all(data['terrain_grid'][k]==original['terrain_grid'][k] for k in ('origin','step','nx','ny')),
'semantic_source_filter_used':data['terrain']['source_counts'].get('semantic_layer_filter')==1,
'no_adjacent_jumps_over_5m':metrics(data)['edges_over_5m']==0,
'both_embedded_maps_retained':data['map_status']['status']=='mapped' and {l['id'] for l in data['visualization']['layers']}=={'satellite','ground'},
'original_source_provenance_matches':data['surface_provenance']['sha256']==before[str(sources[0])] and data['surface_provenance']['pier_sha256']==before[str(sources[1])],
}
report={'result':'PASS' if all(checks.values()) else 'FAIL','checks':checks,'before':metrics(original),'after':metrics(data),'output':str(target/'ground_model_3d_with_maps.json'),'inputs_sha256':before,'terrain':data['terrain'],'log':log,
'centers':[{'id':new['id'],'before':prior['ground']['center'],'after':new['ground']['center']} for prior,new in zip(original['piers'],data['piers'])]}
(evidence/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
print(json.dumps(report,ensure_ascii=False))
if not all(checks.values()):raise SystemExit(1)
+102
View File
@@ -0,0 +1,102 @@
"""Read-only terrain diagnostics: grid slopes, isolated peaks, and source TIN."""
import json
import sys
from pathlib import Path
import numpy as np
from collections import Counter
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'pierzainer_ground'))
from pierzainer_ground.survey_terrain import read_survey_block
from pierzainer_ground.dxf_terrain import read_source_document, read_terrain_dxf
from pierzainer_ground.dtm import DTM
from pierzainer_ground.config import GroundConfig
out=ROOT/'.verify/ungok-terrain-review-20261110'
out.mkdir(parents=True,exist_ok=True)
folder=ROOT/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
data=json.loads((folder/'ground_model_3d_with_maps.json').read_text())
settings=json.loads((folder/'generation_settings.json').read_text())
g=data['terrain_grid']; z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
edges=[]
for j in range(g['ny']):
for i in range(g['nx']):
for dj,di in [(0,1),(1,0)]:
if j+dj<g['ny'] and i+di<g['nx'] and np.isfinite(z[j,i]) and np.isfinite(z[j+dj,i+di]):
edges.append({'row':j,'col':i,'to_row':j+dj,'to_col':i+di,'z':float(z[j,i]),'to_z':float(z[j+dj,i+di]),'dz':float(abs(z[j+dj,i+di]-z[j,i]))})
edges.sort(key=lambda e:-e['dz'])
summary={'file':str(folder),'terrain':data['terrain'],'provenance':data.get('surface_provenance'),'grid':{k:v for k,v in g.items() if k!='z'},'valid_nodes':int(np.isfinite(z).sum()),'z_range':[float(np.nanmin(z)),float(np.nanmax(z))],'adjacent_edge_count':len(edges),'edges_over_5m':sum(e['dz']>5 for e in edges),'edges_over_8m':sum(e['dz']>8 for e in edges),'worst_edges':edges[:12]}
source=next((folder/'cache').glob('*.dxf'))
doc,_=read_source_document(source)
cfg=GroundConfig()
points,counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'prefer_recognized_layers':False},doc=doc)
lo=np.array(g['origin'])-settings['max_edge'];hi=np.array(g['origin'])+[(g['nx']-1)*g['step'],(g['ny']-1)*g['step']]+settings['max_edge']
points=points[np.all((points[:,:2]>=lo)&(points[:,:2]<=hi),axis=1)]
dtm=DTM(points,settings['max_edge'])
summary['source_counts']=counts
summary['raw_height_layers']=dict(Counter(e.dxf.layer for e in doc.blocks.get(settings['terrain_block']) if e.dxftype() in ('POLYLINE','LWPOLYLINE')))
# Counterfactual only: use the drawing's two contour layers, without replacing
# the generated user model or changing source geometry.
contours,contour_counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'layers':['CAA001','CAA002']},doc=doc)
contours=contours[np.all((contours[:,:2]>=lo)&(contours[:,:2]<=hi),axis=1)]
clean_dtm=DTM(contours,settings['max_edge'])
gx,gy=np.meshgrid(g['origin'][0]+np.arange(g['nx'])*g['step'],g['origin'][1]+np.arange(g['ny'])*g['step'])
clean=clean_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
clean_edges=np.concatenate([np.abs(np.diff(clean,axis=0)).ravel(),np.abs(np.diff(clean,axis=1)).ravel()])
clean_edges=clean_edges[np.isfinite(clean_edges)]
summary['contour_only_diagnostic']={'layers':['CAA001','CAA002'],'counts':contour_counts,'valid_nodes':int(np.isfinite(clean).sum()),'z_range':[float(np.nanmin(clean)),float(np.nanmax(clean))],'max_adjacent_dz':float(clean_edges.max()),'edges_over_5m':int((clean_edges>5).sum()),'edges_over_8m':int((clean_edges>8).sum()),'not_applied':True}
typed_cfg=GroundConfig(use_profile_points=False,z_min=settings['z_min'],z_max=settings['z_max'])
typed_cfg.allowed_terrain_layers=typed_cfg.layers.spot_block+typed_cfg.layers.spot_text+typed_cfg.layers.contour
typed=read_terrain_dxf(source,typed_cfg,document=doc)
typed_local=typed.points[np.all((typed.points[:,:2]>=lo)&(typed.points[:,:2]<=hi),axis=1)]
typed_dtm=DTM(typed_local,settings['max_edge'])
typed_grid=typed_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
typed_edges=np.concatenate([np.abs(np.diff(typed_grid,axis=0)).ravel(),np.abs(np.diff(typed_grid,axis=1)).ravel()])
typed_edges=typed_edges[np.isfinite(typed_edges)]
summary['typed_layer_diagnostic']={'layers':list(typed_cfg.allowed_terrain_layers),'counts':typed.stats['by_source'],'valid_nodes':int(np.isfinite(typed_grid).sum()),'z_range':[float(np.nanmin(typed_grid)),float(np.nanmax(typed_grid))],'max_adjacent_dz':float(typed_edges.max()),'edges_over_5m':int((typed_edges>5).sum()),'edges_over_8m':int((typed_edges>8).sum()),'covered_piers':sum(typed_dtm.z_at(p['X'],p['Y']) is not None for p in data['piers']),'not_applied':True}
summary['pier_diagnostic']=[]
for pier in data['piers']:
observed=clean_dtm.z_at(pier['X'],pier['Y'])
summary['pier_diagnostic'].append({'id':pier['id'],'generated':pier['ground']['center'],'contour_only':observed,'difference':None if observed is None else observed-pier['ground']['center']})
for e in summary['worst_edges']:
triangles=[]
for row,col in [(e['row'],e['col']),(e['to_row'],e['to_col'])]:
xy=np.array(g['origin'])+np.array([col,row])*g['step']; k=int(dtm.tri.find_simplex(xy-dtm.origin))
triangles.append({'xy':xy.tolist(),'vertices':points[dtm.tri.simplices[k]].tolist() if k>=0 else [],'predicted':dtm.z_at(*xy)})
e['source_triangles']=triangles
# Raw constant-height CAD entities retained in the selected TIN: look for
# closed design outlines vs survey contours near the worst interpolation.
raw=[]
for entity in doc.blocks.get(settings['terrain_block']):
if entity.dxftype() not in ('LWPOLYLINE','POLYLINE'):continue
pts=np.array([tuple(p) for p in (entity.vertices_in_wcs() if entity.dxftype()=='LWPOLYLINE' else entity.points_in_wcs())])
if len(pts)<2 or np.ptp(pts[:,2])>1e-6:continue
raw.append({'handle':entity.dxf.handle,'layer':entity.dxf.layer,'z':float(pts[0,2]),'vertices':len(pts),'closed':bool(entity.closed if entity.dxftype()=='LWPOLYLINE' else entity.is_closed),'xy_min':pts[:,:2].min(0).tolist(),'xy_max':pts[:,:2].max(0).tolist(),'xyz':pts.tolist()})
for e in summary['worst_edges'][:6]:
for tri in e['source_triangles']:
tri['entities']=[]
for north,east,height in tri['vertices']:
candidates=[]
for entity in raw:
if abs(entity['z']-height)>1e-3:continue
xy=np.array(entity['xyz'])[:,:2]; d=float(np.linalg.norm(xy-[east,north],axis=1).min())
candidates.append((d,entity))
candidates.sort(key=lambda r:r[0])
tri['entities'].append([{'distance_to_vertex':round(d,4),**{k:v for k,v in ent.items() if k!='xyz'}} for d,ent in candidates[:2]])
(out/'report.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
print(json.dumps({k:v for k,v in summary.items() if k not in ('worst_edges','provenance')},ensure_ascii=False))
for e in summary['worst_edges'][:3]:print(json.dumps(e,ensure_ascii=False))
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
fig,axes=plt.subplots(1,3,figsize=(16,5),constrained_layout=True)
extent=[g['origin'][0],g['origin'][0]+(g['nx']-1)*g['step'],g['origin'][1],g['origin'][1]+(g['ny']-1)*g['step']]
for ax,values,title in [(axes[0],z,'Generated: all height-bearing layers'),(axes[1],typed_grid,'Diagnostic: survey spots + contour layers')]:
im=ax.imshow(values,origin='lower',extent=extent,vmin=20,vmax=47,aspect='auto',cmap='terrain');ax.set_title(title);ax.set_xlabel('Northing (m)');ax.set_ylabel('Easting (m)')
fig.colorbar(im,ax=axes[:2],label='EL (m)',shrink=.7)
row=summary['worst_edges'][0]['to_row'];axis=g['origin'][0]+np.arange(g['nx'])*g['step']
axes[2].plot(axis,z[row],label='Generated');axes[2].plot(axis,typed_grid[row],label='Survey spots + contours');axes[2].set_title(f'Transect E={g["origin"][1]+row*g["step"]:.3f} m');axes[2].set_xlabel('Northing (m)');axes[2].set_ylabel('EL (m)');axes[2].legend();axes[2].grid(alpha=.25)
fig.savefig(out/'comparison.png',dpi=150);plt.close(fig)
except ImportError:
print('Plot dependency unavailable; numeric report preserved.')
+49
View File
@@ -0,0 +1,49 @@
// Run with Windows Node against isolated Chrome CDP 9444 and Vite 5173.
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/ungok-terrain-fixed-20261110/browser');
fs.mkdirSync(out,{recursive:true});
const report={checks:[],errors:[]};let ws;
const delay=ms=>new Promise(r=>setTimeout(r,ms));
(async()=>{
const tab=await(await fetch('http://127.0.0.1:9444/json/new?about:blank',{method:'PUT'})).json();
ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
let seq=0;const pending=new Map();
ws.onmessage=({data})=>{const m=JSON.parse(data),p=pending.get(m.id);if(p){clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);}if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);};
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>reject(Error('Timeout '+method)),30000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}));});
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;};
const until=async expression=>{for(let i=0;i<150;i++){if(await ev(expression))return;await delay(200);}throw Error('Wait '+expression);};
const check=(name,value)=>{assert.ok(value,name);report.checks.push(name);console.log('PASS',name);};
const click=expression=>ev(`(()=>{const e=${expression};if(!e||e.disabled)throw Error('Missing target '+${JSON.stringify(expression)});e.click();})()`);
const file=async(selector,name)=>{const d=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:d.root.nodeId,selector});await send('DOM.setFileInputFiles',{nodeId,files:[name]});};
const shot=async name=>{const s=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(s.data,'base64'));};
await send('Page.enable');await send('Runtime.enable');await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`);
const settings=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/generation_settings.json')));
const workbook='\\\\wsl.localhost\\Ubuntu'+settings.piers.replaceAll('/','\\');
await ev('window.openSampleProject()');await file('#file',workbook);
await until(`document.getElementById('file-name').textContent.includes('piers.xlsx')`);
await ev(`(()=>{const e=document.getElementById('project-name');e.value='운곡교 지반 수정 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);await click(`document.getElementById('nav-shape')`);await click(`document.getElementById('project-ground-open')`);
check('actual workbook lists all 16 structures',await ev(`document.querySelectorAll('[data-ground-pier]').length===16`));
await file('#ground-import',path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json'));
await until(`document.getElementById('project-ground-status').textContent.includes('16/16')`);
check('all structures have applicable corrected ground',await ev(`[...document.querySelectorAll('[data-ground-pier]')].every(e=>!e.disabled)`));
await click(`document.getElementById('project-ground-select-all')`);
check('all 16 structures are selected for application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===16`));
await click(`document.getElementById('project-ground-apply')`);
await click(`document.getElementById('project-ground-regenerate')`);
await until(`document.getElementById('project-ground-regenerate').disabled`);
check('all changes regenerate successfully',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await shot('01-corrected-manager');await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await delay(1000);await shot('02-corrected-terrain');
await send('Browser.setDownloadBehavior',{behavior:'deny'});
await ev(`(()=>{window.__ungokSaved=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(t=>{try{window.__ungokSaved=JSON.parse(t);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);await until(`document.getElementById('save-dialog').open`);await click(`document.getElementById('sv-save')`);await until(`!!window.__ungokSaved?.groundModel`);
const saved=await ev(`window.__ungokSaved`);fs.writeFileSync(path.join(out,'verified.pzr.json'),JSON.stringify(saved));
check('saved project retains 16 corrected ground records and two maps',saved.groundModel.piers.length===16&&saved.groundModel.mapLayers.length===2);
const expected=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json')));
check('saved terrain elevations equal generated JSON',JSON.stringify(saved.groundModel.terrainGrid.z)===JSON.stringify(expected.terrain_grid.z));
check('browser runtime has no exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();});