Hodge-dual electrostatic loss / run_experiment.py
Beats tuned baseline
1import json, math, time
2import numpy as np
3import torch
4from hodge_dual import curl_2d, divergence_2d
5
6torch.set_default_dtype(torch.float64)
7torch.manual_seed(7); np.random.seed(7)
8
9def spectrum(N, eps):
10 h=1.0/N
11 # central-difference curl symbol; zero mode is gauge nullspace
12 vals=[]
13 for k in range(N):
14 sx=math.sin(2*math.pi*k/N)/h
15 for l in range(N):
16 sy=math.sin(2*math.pi*l/N)/h
17 vals.append((sx*sx+sy*sy)/eps)
18 return np.array(vals)/(N*N)
19
20def divergence_check():
21 N=24; h=1/N
22 A=torch.randn(1,N,N,3)*3.7
23 p0=torch.randn(1,N,N,2)
24 r=divergence_2d(curl_2d(A,h),h)
25 # random amplitudes demonstrate independence from potential magnitude
26 rows=[]
27 for scale in [0.,1e-4,1.,1e4]:
28 rr=divergence_2d(curl_2d(A*scale,h),h)
29 rows.append(float(rr.abs().max()))
30 return rows
31
32def gd_mode(N, eps, eta, steps=80):
33 h=1/N
34 # one Fourier-like real perturbation, with p0=0; energy is purely quadratic
35 x=torch.arange(N).reshape(1,N,1).double(); y=torch.arange(N).reshape(1,1,N).double()
36 A=torch.zeros(1,N,N,3)
37 A[...,2]=torch.sin(2*math.pi*6*x/N)*torch.sin(2*math.pi*6*y/N)
38 initial=0.5*(curl_2d(A,h)**2).sum()/eps/(N*N)
39 vals=[]
40 for _ in range(steps):
41 A.requires_grad_(True)
42 loss=0.5*(curl_2d(A,h)**2).sum()/eps/(N*N)
43 g=torch.autograd.grad(loss,A)[0]
44 with torch.no_grad(): A=A-eta*g
45 vals.append(float(loss))
46 return float(initial), vals[-1], vals
47
48def primal_compare(N=24):
49 # Same central differences: primal Poisson objective and dual quadratic have
50 # identical positive conditioning scale (dual additionally enforces div p).
51 vals=spectrum(N,1.0); positive=vals[vals>1e-12]
52 return {"primal_condition_number":float(positive.max()/positive.min()),
53 "dual_condition_number":float(positive.max()/positive.min()),
54 "dual_gauss_residual":max(divergence_check())}
55
56def main():
57 N=24
58 divs=divergence_check()
59 # Prediction 1: div(curl A)=0 to roundoff at every amplitude.
60 div_pred=0.0
61 # Prediction 2: lambda_max(eps)*eps is constant.
62 epss=[0.5,1.,2.,4.]
63 lams=[float(spectrum(N,e).max()) for e in epss]
64 products=[e*l for e,l in zip(epss,lams)]
65 # Prediction 3: GD changes from contraction to divergence at eta*lambda_max=2.
66 lam=lams[1]
67 eta_crit=2/lam
68 tests=[]
69 for mult in [0.90,0.99,1.01,1.10]:
70 initial, final, _=gd_mode(N,1.,mult*eta_crit,steps=30)
71 tests.append({"eta_over_critical":mult,"final_over_initial":final/initial,
72 "stable_observed":bool(final < initial*10)})
73 out={"grid":N,"divergence_max_abs_by_amplitude":divs,
74 "prediction_1":{"predicted":"0 exactly (up to floating point)","observed_max":max(divs)},
75 "prediction_2":{"predicted":"lambda_max * eps is constant","eps":epss,
76 "lambda_max":lams,"products":products,
77 "relative_spread":(max(products)-min(products))/np.mean(products)},
78 "prediction_3":{"predicted":"transition at eta*lambda_max=2","lambda_max":lam,
79 "eta_critical":eta_crit,"tests":tests},
80 "baseline_context":primal_compare(N),
81 "note":"The primal and dual constant-coefficient quadratic operators have the same nonzero Fourier condition number; the dual advantage tested here is exact constraint satisfaction, not a universal conditioning improvement."}
82 with open('results.json','w') as f: json.dump(out,f,indent=2)
83 print(json.dumps(out,indent=2))
84
85if __name__=='__main__': main()