HaomingLuo commited on
Commit
e2a79ac
·
verified ·
1 Parent(s): 9e38820

Add multi-zone residual surrogate with held-out evaluation and runnable predictor

Browse files
README.md CHANGED
@@ -16,6 +16,8 @@ tags:
16
 
17
  # AgentFEM · Morph Rod Surrogates
18
 
 
 
19
  **Predict a flexible rod's shape without averaging its equilibrium branches.**
20
 
21
  ![Computed rod families](assets/overview.png)
 
16
 
17
  # AgentFEM · Morph Rod Surrogates
18
 
19
+ **New: [multi-zone residual surrogate](multizone-v02/README.md)** — a separate 3/6/12-zone actuation model with 8-zone holdout, downloadable weights and a runnable predictor. Original branch-conditioned models below are retained unchanged.
20
+
21
  **Predict a flexible rod's shape without averaging its equilibrium branches.**
22
 
23
  ![Computed rod families](assets/overview.png)
multizone-v02/README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-zone residual surrogate · v0.2
2
+
3
+ This additional model predicts the load-induced angular correction to an intrinsic-curvature shape. It handles a fixed 48-element field representation rather than three named curvature inputs. It is a separate task from the v0.1 microforce branch-conditioned model, not a replacement or a directly comparable leaderboard result.
4
+
5
+ ## Run
6
+
7
+ Install `numpy torch safetensors`, then:
8
+
9
+ ```bash
10
+ python predict.py --curvatures 1 .5 -1 1 .2 .8
11
+ ```
12
+
13
+ The 51 inputs are 48 element-wise intrinsic curvatures, fx/chi, fy/chi, and log10(chi). A 51→96→96→48 tanh network predicts the angle correction; analytically integrated intrinsic curvature supplies the base shape. Weights use safetensors. `config.json` contains training-only normalization statistics. Output is a surrogate estimate, not an equilibrium-certified solution; use the accompanying mechanics solver when certification is required.
14
+
15
+ ## Held-out results
16
+
17
+ | Split | Intrinsic-shape baseline RMSE / L | Learned correction RMSE / L |
18
+ |---|---:|---:|
19
+ | Scenario-held-out test, 3/6/12 zones | 0.03450 | 0.002255 |
20
+ | Zone-count OOD, 8 zones | 0.03387 | 0.009063 |
21
+
22
+ These are centerline errors at 49 nodes, including zero-loading states. Nonzero-only errors, endpoint means and 95th percentiles are in evaluation.json. No runtime advantage over the already fast mechanical solver has been established. The web Space continues to use that solver, not this neural model.
23
+
24
+ Training uses 2,520 states, validation 540, test 540, and zone-count OOD 480. Whole six-state loading paths stay in one split. The validation-best checkpoint is selected over 201 epochs; the test split is not used for selection. No contact, dynamics, multi-branch loading or hardware control is covered by this model.
25
+
26
+ ## 中文
27
+
28
+ 这版模型学习的是“本征曲率形状在外力下还会变多少”,而不是从零预测整个形状。测试集误差约为杆长的 0.226%,未见过的 8 分区工况约为 0.906%。它与原来的多分支模型用途不同,不能直接比较两个版本的误差来判断进步幅度。网页仍采用实际力学求解。
29
+
30
+ Code and model: Apache-2.0. Associated data: CC BY 4.0.
multizone-v02/config.json ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mean": [
3
+ -0.021105827763676643,
4
+ -0.021105827763676643,
5
+ -0.021105827763676643,
6
+ -0.021105827763676643,
7
+ 0.010166754014790058,
8
+ 0.010166754014790058,
9
+ 0.010166754014790058,
10
+ 0.010166754014790058,
11
+ 0.005382698494940996,
12
+ 0.005382698494940996,
13
+ 0.005382698494940996,
14
+ 0.005382698494940996,
15
+ 0.01833205670118332,
16
+ 0.01833205670118332,
17
+ 0.01833205670118332,
18
+ 0.01833205670118332,
19
+ -0.01243000291287899,
20
+ -0.01243000291287899,
21
+ -0.01243000291287899,
22
+ -0.01243000291287899,
23
+ -0.04185189679265022,
24
+ -0.04185189679265022,
25
+ -0.04185189679265022,
26
+ -0.04185189679265022,
27
+ -0.06519726663827896,
28
+ -0.06519726663827896,
29
+ -0.06519726663827896,
30
+ -0.06519726663827896,
31
+ -0.09435442835092545,
32
+ -0.09435442835092545,
33
+ -0.09435442835092545,
34
+ -0.09435442835092545,
35
+ 0.03800766170024872,
36
+ 0.03800766170024872,
37
+ 0.03800766170024872,
38
+ 0.03800766170024872,
39
+ 0.005421004723757505,
40
+ 0.005421004723757505,
41
+ 0.005421004723757505,
42
+ 0.005421004723757505,
43
+ 0.02227204106748104,
44
+ 0.02227204106748104,
45
+ 0.02227204106748104,
46
+ 0.02227204106748104,
47
+ 0.013483509421348572,
48
+ 0.013483509421348572,
49
+ 0.013483509421348572,
50
+ 0.013483509421348572,
51
+ 0.15130436420440674,
52
+ -0.0051275817677378654,
53
+ -3.2511823177337646
54
+ ],
55
+ "std": [
56
+ 0.8128650188446045,
57
+ 0.8128650188446045,
58
+ 0.8128650188446045,
59
+ 0.8128650188446045,
60
+ 0.8196129202842712,
61
+ 0.8196129202842712,
62
+ 0.8196129202842712,
63
+ 0.8196129202842712,
64
+ 0.8226141333580017,
65
+ 0.8226141333580017,
66
+ 0.8226141333580017,
67
+ 0.8226141333580017,
68
+ 0.8281049728393555,
69
+ 0.8281049728393555,
70
+ 0.8281049728393555,
71
+ 0.8281049728393555,
72
+ 0.8390543460845947,
73
+ 0.8390543460845947,
74
+ 0.8390543460845947,
75
+ 0.8390543460845947,
76
+ 0.8081016540527344,
77
+ 0.8081016540527344,
78
+ 0.8081016540527344,
79
+ 0.8081016540527344,
80
+ 0.8240239024162292,
81
+ 0.8240239024162292,
82
+ 0.8240239024162292,
83
+ 0.8240239024162292,
84
+ 0.8097495436668396,
85
+ 0.8097495436668396,
86
+ 0.8097495436668396,
87
+ 0.8097495436668396,
88
+ 0.8323362469673157,
89
+ 0.8323362469673157,
90
+ 0.8323362469673157,
91
+ 0.8323362469673157,
92
+ 0.8128801584243774,
93
+ 0.8128801584243774,
94
+ 0.8128801584243774,
95
+ 0.8128801584243774,
96
+ 0.8216692805290222,
97
+ 0.8216692805290222,
98
+ 0.8216692805290222,
99
+ 0.8216692805290222,
100
+ 0.8180782794952393,
101
+ 0.8180782794952393,
102
+ 0.8180782794952393,
103
+ 0.8180782794952393,
104
+ 0.1490403264760971,
105
+ 0.21210536360740662,
106
+ 0.4438764750957489
107
+ ],
108
+ "output_scale": 0.1,
109
+ "n": 48,
110
+ "architecture": [
111
+ 51,
112
+ 96,
113
+ 96,
114
+ 48
115
+ ],
116
+ "features": "48 piecewise intrinsic curvatures, fx/chi, fy/chi, log10(chi)",
117
+ "prediction": "cumulative intrinsic angle + output_scale * network(standardized features)"
118
+ }
multizone-v02/evaluation.json ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.2",
3
+ "states": 4080,
4
+ "scenes": 680,
5
+ "counts": {
6
+ "train": 2520,
7
+ "validation": 540,
8
+ "test": 540,
9
+ "zone_ood": 480
10
+ },
11
+ "zones_id": [
12
+ 3,
13
+ 6,
14
+ 12
15
+ ],
16
+ "zones_ood": [
17
+ 8
18
+ ],
19
+ "rejected": 0,
20
+ "mesh_checks": 34,
21
+ "max_mesh_tip_difference_over_L": 4.603976254440725e-05,
22
+ "best_epoch": 189,
23
+ "metrics": {
24
+ "train": {
25
+ "intrinsic_shape": {
26
+ "centerline_rmse_over_L": 0.03217696650573636,
27
+ "tip_mean_over_L": 0.04703082547593874,
28
+ "tip_p95_over_L": 0.1389949922209111,
29
+ "nonzero_loading_rmse_over_L": 0.035248100774559976
30
+ },
31
+ "learned_residual": {
32
+ "centerline_rmse_over_L": 0.0007707721406115093,
33
+ "tip_mean_over_L": 0.0010672698493170232,
34
+ "tip_p95_over_L": 0.0032114897744955654,
35
+ "nonzero_loading_rmse_over_L": 0.0008432933805167641
36
+ }
37
+ },
38
+ "validation": {
39
+ "intrinsic_shape": {
40
+ "centerline_rmse_over_L": 0.030841632665898296,
41
+ "tip_mean_over_L": 0.04443062956595426,
42
+ "tip_p95_over_L": 0.13412200027231663,
43
+ "nonzero_loading_rmse_over_L": 0.03378531584280138
44
+ },
45
+ "learned_residual": {
46
+ "centerline_rmse_over_L": 0.002139143213742544,
47
+ "tip_mean_over_L": 0.0023906575722607896,
48
+ "tip_p95_over_L": 0.00985920275845111,
49
+ "nonzero_loading_rmse_over_L": 0.002342985905624226
50
+ }
51
+ },
52
+ "test": {
53
+ "intrinsic_shape": {
54
+ "centerline_rmse_over_L": 0.03449966638469762,
55
+ "tip_mean_over_L": 0.04949992729501256,
56
+ "tip_p95_over_L": 0.15202171303892403,
57
+ "nonzero_loading_rmse_over_L": 0.037792491010603176
58
+ },
59
+ "learned_residual": {
60
+ "centerline_rmse_over_L": 0.0022550218060435672,
61
+ "tip_mean_over_L": 0.0025461652398190942,
62
+ "tip_p95_over_L": 0.010101520708292833,
63
+ "nonzero_loading_rmse_over_L": 0.002469874665768664
64
+ }
65
+ },
66
+ "zone_ood": {
67
+ "intrinsic_shape": {
68
+ "centerline_rmse_over_L": 0.03387460541227201,
69
+ "tip_mean_over_L": 0.05124979340575448,
70
+ "tip_p95_over_L": 0.1401572788163453,
71
+ "nonzero_loading_rmse_over_L": 0.03710777102177594
72
+ },
73
+ "learned_residual": {
74
+ "centerline_rmse_over_L": 0.009062882912828973,
75
+ "tip_mean_over_L": 0.012180452320820882,
76
+ "tip_p95_over_L": 0.040638126307767204,
77
+ "nonzero_loading_rmse_over_L": 0.009927801522037316
78
+ }
79
+ }
80
+ },
81
+ "seconds": 4.511781215667725,
82
+ "mechanics_correction_checks": [
83
+ {
84
+ "id": "z3_008_5",
85
+ "residual": 5.312265384527226e-15,
86
+ "tip_error_over_L": 2.5286101803848858e-11
87
+ },
88
+ {
89
+ "id": "z3_051_5",
90
+ "residual": 4.446925569975155e-11,
91
+ "tip_error_over_L": 2.999757286531255e-08
92
+ },
93
+ {
94
+ "id": "z3_097_5",
95
+ "residual": 7.789608621038927e-16,
96
+ "tip_error_over_L": 4.949868572479711e-13
97
+ },
98
+ {
99
+ "id": "z3_144_5",
100
+ "residual": 1.3740308793209865e-11,
101
+ "tip_error_over_L": 2.148295597082969e-08
102
+ },
103
+ {
104
+ "id": "z3_176_5",
105
+ "residual": 5.722921340540243e-12,
106
+ "tip_error_over_L": 2.746265069278408e-07
107
+ },
108
+ {
109
+ "id": "z6_014_5",
110
+ "residual": 6.901285641049137e-17,
111
+ "tip_error_over_L": 7.371685286925458e-13
112
+ },
113
+ {
114
+ "id": "z6_044_5",
115
+ "residual": 2.206625380615194e-11,
116
+ "tip_error_over_L": 1.0975236022566359e-06
117
+ },
118
+ {
119
+ "id": "z6_077_5",
120
+ "residual": 1.4517147984741103e-11,
121
+ "tip_error_over_L": 8.451519429810074e-07
122
+ },
123
+ {
124
+ "id": "z6_138_5",
125
+ "residual": 4.744584467966025e-17,
126
+ "tip_error_over_L": 3.519878195556559e-08
127
+ },
128
+ {
129
+ "id": "z6_156_5",
130
+ "residual": 3.385439432791542e-11,
131
+ "tip_error_over_L": 1.3208994208734372e-07
132
+ },
133
+ {
134
+ "id": "z12_002_5",
135
+ "residual": 4.696980651641702e-15,
136
+ "tip_error_over_L": 5.641037807459418e-07
137
+ },
138
+ {
139
+ "id": "z12_037_5",
140
+ "residual": 2.122790464208461e-12,
141
+ "tip_error_over_L": 1.8782719327230075e-09
142
+ },
143
+ {
144
+ "id": "z12_083_5",
145
+ "residual": 6.979108458062555e-11,
146
+ "tip_error_over_L": 4.442049511326813e-06
147
+ },
148
+ {
149
+ "id": "z12_111_5",
150
+ "residual": 7.040594326440894e-17,
151
+ "tip_error_over_L": 5.582728434790076e-10
152
+ },
153
+ {
154
+ "id": "z12_172_5",
155
+ "residual": 2.369698582393759e-12,
156
+ "tip_error_over_L": 2.2341706552832602e-07
157
+ }
158
+ ]
159
+ }
multizone-v02/predict.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Apache-2.0. Run: python predict.py --curvatures 1 .5 -1 1 .2 .8"""
2
+ from pathlib import Path
3
+ import argparse,json
4
+ import numpy as np
5
+ import torch
6
+ from torch import nn
7
+ from safetensors.torch import load_file
8
+
9
+ def predict(curvatures,chi=.001,fx=.0003,fy=-.0001):
10
+ root=Path(__file__).resolve().parent;cfg=json.loads((root/'config.json').read_text())
11
+ k=np.asarray(curvatures,float)
12
+ if k.ndim!=1 or len(k) not in [3,6,8,12] or not np.all(np.isfinite(k)) or np.max(np.abs(k))>2.4:raise ValueError('3, 6, 12 zones (8 is OOD), finite curvature in [-2.4,2.4]')
13
+ if chi<=0:raise ValueError('chi must be positive')
14
+ field=k[np.floor((np.arange(48)+.5)*len(k)/48).astype(int)]
15
+ features=np.r_[field,fx/chi,fy/chi,np.log10(chi)]
16
+ x=torch.tensor((features-cfg['mean'])/cfg['std'],dtype=torch.float32)
17
+ net=nn.Sequential(nn.Linear(51,96),nn.Tanh(),nn.Linear(96,96),nn.Tanh(),nn.Linear(96,48))
18
+ net.load_state_dict(load_file(str(root/'residual.safetensors')));net.eval()
19
+ with torch.no_grad():z=np.cumsum(field)/48+cfg['output_scale']*net(x).numpy()
20
+ angle=(np.r_[0,z[:-1]]+z)/2;stretch=1+fx*np.cos(angle)+fy*np.sin(angle)
21
+ xy=np.vstack([np.zeros(2),np.cumsum(stretch[:,None]*np.c_[np.cos(angle),np.sin(angle)]/48,axis=0)])
22
+ return {'z':z.tolist(),'xy':xy.tolist(),'tip':xy[-1].tolist(),'source':'learned_residual_surrogate','equilibrium_verified':False}
23
+ if __name__=='__main__':
24
+ p=argparse.ArgumentParser();p.add_argument('--curvatures',type=float,nargs='+',required=True);p.add_argument('--chi',type=float,default=.001);p.add_argument('--fx',type=float,default=.0003);p.add_argument('--fy',type=float,default=-.0001);a=p.parse_args();print(json.dumps(predict(a.curvatures,a.chi,a.fx,a.fy)))
multizone-v02/residual.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:450959ecdf0bcb0915972db42ed378b9e1feb62a73ae7fa3ad8fcb1499cf0bac
3
+ size 76264
multizone-v02/source/multizone.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reproducible v0.2 multi-zone supplement; preserves v0.1 artifacts."""
2
+ import os
3
+ os.environ.setdefault('OPENBLAS_NUM_THREADS','1')
4
+ os.environ.setdefault('OMP_NUM_THREADS','1')
5
+ from pathlib import Path
6
+ import json,gzip,time
7
+ import numpy as np
8
+ import torch
9
+ from torch import nn
10
+ from safetensors.torch import save_file,load_file
11
+ from rod import Rod,Controls,serial
12
+
13
+ ROOT=Path(__file__).resolve().parents[1]
14
+ OUT=ROOT/'release/dataset/multizone-v02'
15
+ MODEL=ROOT/'release/model/multizone-v02'
16
+ def features(ks,chi,fx,fy):
17
+ field=np.asarray(ks)[np.floor((np.arange(48)+.5)*len(ks)/48).astype(int)]
18
+ return np.r_[field,fx/chi,fy/chi,np.log10(chi)],np.cumsum(field)/48
19
+ def network():
20
+ return nn.Sequential(nn.Linear(51,96),nn.Tanh(),nn.Linear(96,96),nn.Tanh(),nn.Linear(96,48))
21
+ def xy(z,fx,fy):
22
+ angle=(np.c_[np.zeros(len(z)),z[:,:-1]]+z)/2
23
+ stretch=1+fx[:,None]*np.cos(angle)+fy[:,None]*np.sin(angle)
24
+ ds=stretch[:,:,None]*np.stack([np.cos(angle),np.sin(angle)],axis=2)/48
25
+ return np.concatenate([np.zeros((len(z),1,2)),np.cumsum(ds,axis=1)],axis=1)
26
+ def main():
27
+ start=time.time();OUT.mkdir(parents=True,exist_ok=True);MODEL.mkdir(parents=True,exist_ok=True)
28
+ rng=np.random.default_rng(20261015);rod=Rod(48);fine=Rod(96);rows=[];mesh=[];rejected=[]
29
+ for m in [3,6,12,8]:
30
+ total=200 if m!=8 else 80
31
+ order=rng.permutation(total);splits={int(j):('train' if i<140 else 'validation' if i<170 else 'test') for i,j in enumerate(order)}
32
+ for j in range(total):
33
+ split=splits[j] if m!=8 else 'zone_ood';ks=rng.uniform(-2.4,2.4,m);chi=float(10**rng.uniform(-4,-2.5));fx=float(chi*rng.uniform(0,.6));fy=float(chi*rng.uniform(-.6,.6))
34
+ for step,t in enumerate(np.linspace(0,1,6)):
35
+ k=ks*t;c=Controls(chi=chi,fx=fx*t,fy=fy*t,curvatures=tuple(k));_,seed=features(k,chi,c.fx,c.fy);s=rod.solve(c,seed)
36
+ row=dict(id=f'z{m}_{j:03d}_{step}',scene_id=f'z{m}_{j:03d}',split=split,zones=m,t=float(t),chi=chi,fx=c.fx,fy=c.fy,curvatures=k.tolist(),**serial(s))
37
+ if not s['accepted'] or s['stability']!='stable':rejected.append(row);continue
38
+ rows.append(row)
39
+ if step==5 and j%20==0:
40
+ field=k[np.floor((np.arange(96)+.5)*m/96).astype(int)];f=fine.solve(c,np.cumsum(field)/96)
41
+ assert f['accepted'] and f['stability']=='stable'
42
+ mesh.append(dict(id=row['id'],tip_difference_over_L=float(np.linalg.norm(f['tip']-s['tip'])),centerline_rmse_over_L=float(np.sqrt(np.mean(np.sum((f['centerline']-s['centerline'])**2,axis=1))))))
43
+ print('Generated zones',m,'states',len(rows),flush=True)
44
+ for split in ['train','validation','test','zone_ood']:
45
+ with gzip.open(OUT/f'{split}.jsonl.gz','wt') as f:
46
+ for row in rows:
47
+ if row['split']==split:f.write(json.dumps(row)+'\n')
48
+ (OUT/'rejected.json').write_text(json.dumps(rejected))
49
+ (OUT/'mesh-checks.json').write_text(json.dumps(mesh,indent=2))
50
+ items=[features(r['curvatures'],r['chi'],r['fx'],r['fy']) for r in rows]
51
+ x=np.array([a for a,b in items],np.float32);seed=np.array([b for a,b in items],np.float32);z=np.array([r['z'] for r in rows],np.float32)
52
+ ix={s:np.array([i for i,r in enumerate(rows) if r['split']==s]) for s in ['train','validation','test','zone_ood']}
53
+ mean=x[ix['train']].mean(0);std=x[ix['train']].std(0);std[std<1e-6]=1
54
+ X=torch.from_numpy((x-mean)/std);Y=torch.from_numpy((z-seed)/.1)
55
+ torch.set_num_threads(2);torch.manual_seed(15);net=network();opt=torch.optim.Adam(net.parameters(),lr=.0015);best=float('inf');best_epoch=0
56
+ for epoch in range(201):
57
+ net.train()
58
+ for batch in np.array_split(rng.permutation(ix['train']),12):
59
+ opt.zero_grad();loss=(net(X[batch])-Y[batch]).square().mean();loss.backward();opt.step()
60
+ net.eval()
61
+ with torch.no_grad():val=float((net(X[ix['validation']])-Y[ix['validation']]).square().mean())
62
+ if val<best:best=val;best_epoch=epoch;weights={k:v.clone().contiguous() for k,v in net.state_dict().items()}
63
+ if epoch%50==0:print('Epoch',epoch,'validation',val,flush=True)
64
+ save_file(weights,str(MODEL/'residual.safetensors'));net.load_state_dict(load_file(str(MODEL/'residual.safetensors')))
65
+ with torch.no_grad():pred=seed+.1*net(X).numpy()
66
+ fx=np.array([r['fx'] for r in rows]);fy=np.array([r['fy'] for r in rows]);truth=xy(z,fx,fy)
67
+ metrics={}
68
+ for split,idx in ix.items():
69
+ metrics[split]={}
70
+ for name,zz in [('intrinsic_shape',seed),('learned_residual',pred)]:
71
+ err=np.linalg.norm(xy(zz,fx,fy)[idx]-truth[idx],axis=2)
72
+ metrics[split][name]={'centerline_rmse_over_L':float(np.sqrt(np.mean(err**2))),'tip_mean_over_L':float(err[:,-1].mean()),'tip_p95_over_L':float(np.quantile(err[:,-1],.95))}
73
+ nonzero=np.array([rows[i]['t']>0 for i in idx]);metrics[split][name]['nonzero_loading_rmse_over_L']=float(np.sqrt(np.mean(err[nonzero]**2)))
74
+ correction=[]
75
+ for i in ix['test'][5::36]:
76
+ r=rows[i];c=Controls(chi=r['chi'],fx=r['fx'],fy=r['fy'],curvatures=tuple(r['curvatures']))
77
+ corrected=rod.solve(c,pred[i]);assert corrected['accepted']
78
+ correction.append(dict(id=r['id'],residual=corrected['residual'],tip_error_over_L=float(np.linalg.norm(corrected['tip']-r['tip']))))
79
+ report=dict(version='0.2',states=len(rows),scenes=len(set(r['scene_id'] for r in rows)),counts={s:len(i) for s,i in ix.items()},zones_id=[3,6,12],zones_ood=[8],rejected=len(rejected),mesh_checks=len(mesh),max_mesh_tip_difference_over_L=max(r['tip_difference_over_L'] for r in mesh),best_epoch=best_epoch,metrics=metrics,seconds=time.time()-start)
80
+ report['mechanics_correction_checks']=correction
81
+ (MODEL/'config.json').write_text(json.dumps(dict(mean=mean.tolist(),std=std.tolist(),output_scale=.1,n=48,architecture=[51,96,96,48],features='48 piecewise intrinsic curvatures, fx/chi, fy/chi, log10(chi)',prediction='cumulative intrinsic angle + output_scale * network(standardized features)'),indent=2))
82
+ for p in [MODEL/'evaluation.json',OUT/'validation.json',ROOT/'reports/multizone-v02.json']:p.write_text(json.dumps(report,indent=2))
83
+ print(json.dumps(report,indent=2),flush=True)
84
+ if __name__=='__main__':main()
multizone-v02/source/rod.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative planar extensible rod. L=EA=1; chi=EI/(EA L^2).
2
+
3
+ Rod: linear angle FE, midpoint axial elimination. Chain: Ye et al. (2026)
4
+ cell rotations, first cell clamped; kr=chi*n. Independent implementations
5
+ share ONLY the reduced local energy density. No contact or dynamics.
6
+ SPDX-License-Identifier: Apache-2.0
7
+ """
8
+ from dataclasses import dataclass, asdict
9
+ import numpy as np
10
+ from scipy.linalg import solve, eigh
11
+ from scipy.optimize import root
12
+
13
+ @dataclass
14
+ class Controls:
15
+ chi: float = .001
16
+ fx: float = 0.
17
+ fy: float = 0.
18
+ moment: float = 0.
19
+ h: float = 0.
20
+ delta: float = .1
21
+ k1: float = 0.
22
+ k2: float = 0.
23
+ k3: float = 0.
24
+ curvatures: tuple | None = None
25
+
26
+ class Rod:
27
+ def __init__(self, n=48, kind="rod"):
28
+ self.n, self.kind, self.ds = n, kind, 1/n
29
+ if kind == "rod":
30
+ self.d = n
31
+ A = np.zeros((n,n+1)); B = A.copy()
32
+ for i in range(n):
33
+ A[i,i:i+2] = .5
34
+ B[i,i:i+2] = [-n,n]
35
+ self.A,self.B = A[:,1:],B[:,1:]
36
+ self.mass = (A[:,1:].T@A[:,1:])/n
37
+ elif kind == "chain":
38
+ self.d = n-1
39
+ self.A = np.eye(n)[:,1:]
40
+ self.B = np.diff(np.eye(n),axis=0)[:,1:]*n
41
+ self.mass = np.eye(n-1)/n
42
+ else: raise ValueError(kind)
43
+ self.s = (np.arange(n)+.5)/n
44
+
45
+ def terms(self, z, c):
46
+ angle=self.A@z
47
+ ks=np.asarray(c.curvatures if c.curvatures is not None else [c.k1,c.k2,c.k3])
48
+ intrinsic=ks[np.minimum((np.arange(self.B.shape[0])+.5)*len(ks)/self.B.shape[0],len(ks)-1).astype(int)]
49
+ curvature=self.B@z-intrinsic
50
+ a=c.fx+2*c.h
51
+ f=a*np.cos(angle)+c.fy*np.sin(angle)
52
+ fp=-a*np.sin(angle)+c.fy*np.cos(angle)
53
+ g=4*c.h*c.delta
54
+ density=-f-.5*f*f+g*np.cos(angle)
55
+ energy=self.ds*(.5*c.chi*(curvature@curvature)+density.sum())-c.moment*z[-1]
56
+ local_g=-(1+f)*fp-g*np.sin(angle)
57
+ local_h=(1+f)*f-fp*fp-g*np.cos(angle)
58
+ grad=self.ds*(c.chi*self.B.T@curvature+self.A.T@local_g)
59
+ grad[-1]-=c.moment
60
+ tangent=self.ds*(c.chi*self.B.T@self.B+self.A.T@(local_h[:,None]*self.A))
61
+ return energy,grad,tangent
62
+
63
+ def solve(self,c,seed=None):
64
+ z=np.zeros(self.d) if seed is None else np.array(seed,dtype=float).copy()
65
+ it=0; trace=[]; fallback_nfev=0
66
+ for it in range(60):
67
+ _,r,K=self.terms(z,c)
68
+ trace.append(dict(iteration=it,residual_inf=float(np.max(np.abs(r)))))
69
+ if np.max(np.abs(r))<1e-10: break
70
+ try: dz=solve(K,-r,assume_a='sym')
71
+ except Exception: break
72
+ old=np.linalg.norm(r)
73
+ step=1.
74
+ for _ in range(16):
75
+ trial=z+step*dz
76
+ if np.linalg.norm(self.terms(trial,c)[1])<old: break
77
+ step*=.5
78
+ else: break
79
+ z=trial
80
+ trace[-1]['accepted_step_scale']=step
81
+ residual=float(np.max(np.abs(self.terms(z,c)[1])))
82
+ if residual>1e-9:
83
+ sol=root(lambda v:self.terms(v,c)[1],z,jac=lambda v:self.terms(v,c)[2],tol=1e-10)
84
+ z=sol.x; fallback_nfev=sol.nfev
85
+ energy,r,K=self.terms(z,c)
86
+ residual=float(np.max(np.abs(r)))
87
+ angle=self.A@z
88
+ stretch=1+(c.fx+2*c.h)*np.cos(angle)+c.fy*np.sin(angle)
89
+ admissible=bool(np.min(stretch)>(2*c.delta if c.h!=0 else 0))
90
+ good=bool(residual<1e-9 and admissible and np.all(np.isfinite(z)))
91
+ eig=float(eigh(K,self.mass,subset_by_index=[0,0],eigvals_only=True)[0]/c.chi) if good else None
92
+ stability='unknown' if eig is None else ('stable' if eig>1e-4 else 'unstable' if eig< -1e-4 else 'critical')
93
+ segments=self.ds*stretch[:,None]*np.column_stack([np.cos(angle),np.sin(angle)])
94
+ xy=np.vstack([np.zeros(2),np.cumsum(segments,axis=0)])
95
+ # Output resampled on reference coordinate, not current arc length.
96
+ center=np.column_stack([np.interp(np.linspace(0,1,65),np.linspace(0,1,self.n+1),xy[:,j]) for j in range(2)])
97
+ return dict(z=z,centerline=center,angle=angle,strain=stretch-1,energy=float(energy),residual=residual,
98
+ iterations=it+1,accepted=good,lambda_min_over_chi=eig,stability=stability,
99
+ tip=center[-1],min_stretch=float(stretch.min()),solver_trace=trace,fallback_nfev=fallback_nfev)
100
+
101
+ def mode_seed(self,amplitude):
102
+ s=np.arange(1,self.d+1)/self.n
103
+ return amplitude*np.sin(np.pi*s/2)
104
+
105
+ def serial(result):
106
+ return {k:(v.tolist() if isinstance(v,np.ndarray) else v) for k,v in result.items()}