#!/usr/bin/env python # coding: utf-8 # In[1]: import numpy as np import pyoscode import matplotlib.pyplot as plt plt.ion() import scipy.special as sp import scipy.integrate from scipy.optimize import minimize_scalar import warnings warnings.filterwarnings('ignore') from ipywidgets import interact, IntSlider, FloatSlider,FloatLogSlider get_ipython().run_line_magic('config', "InlineBackend.figure_formats = ['svg']") get_ipython().run_line_magic('matplotlib', 'inline') from IPython.core.display import HTML HTML(""" """) # In[2]: def wkb_step_airy(t,x,dx,h,order,n): times = np.linspace(t,t+h,n) ds0 = 1j*(np.sqrt(t)) ds1 = -1./4*(t**(-1)) ds2 = 1j*5./32*(t**(-5./2)) ds3 = 15./64*(t**(-4.)) s0 = 1j*2./3*((times)**(3./2)-t**(3./2)) s1 = -1./4*np.log((times)/t) s2 = -1j*5./48*((times)**(-3./2)-t**(-3./2)) s3 = -5./64*((times)**(-3)-t**(-3)) alls = [s0,s1,s2,s3] allds = [ds0,ds1,ds2,ds3] fp = np.exp(sum(alls[:order+1])) fm = np.conj(fp) dfp = sum(allds[:order+1]) dfm = np.conj(dfp) Ap = (dx - x*dfm)/(dfp - dfm) Am = (dx - x*dfp)/(dfm - dfp) y = Ap*fp + Am*fm return times,y # # pyoscode: fast solutions of oscillatory ODEs in physics # # # # ### Fruzsina Agocs # # # #
#
#
#
#
# # To run these slides in jupyter,\ # head to https://github.com/fruzsinaagocs/oscode and # # # # ## Introduction # - pyoscode is a numerical solver for **osc**illatory **o**rdinary **d**ifferential **e**quations # - ODEs can be oscillatory through # - an oscillatory forcing term, $$\ddot{x} + x = C\cos{\omega t},\; \omega \gg 1$$ # - oscillatory coefficients, $$\dot{x} = xt + 10x\cos{\omega t},\; \omega \gg 1$$ # - slowly changing, but high frequency, $$\ddot{x} + 2\gamma(t)\dot{x} + \omega^2(t) x = 0,\; \omega \gg 1$$ # - oscode deals with the third type: # - one-dimensional # - ordinary # - $\omega(t)$, $\gamma(t)$ vary slowly for _at least part of the integration range_ # ### Motivation # - all extremely common in physics: pendula, suspension, circuits, celestial mechanics, ... # - even if $\omega(t)$ changes slowly, if $\omega \gg 1$, numerical solution slow # - e.g. $\ddot{x} + tx = 0$, the Airy equation: # In[3]: def f(t,x): return np.array([x[1],-t*x[0]]) ti = 1.0 tf = 1e3 x0 = np.array([sp.airy(-ti)[0], -sp.airy(-ti)[1]]) # In[4]: get_ipython().run_cell_magic('time', '', 'sol = scipy.integrate.solve_ivp(f,(ti,tf),x0,rtol=1e-3)\nprint(sol.success)\nprint("x(t={})={:.4f}, dx/dt={:.4f}".format(tf,sol.y[0][-1],sol.y[1][-1]))\nprint("analytic solution: x={:.4f}, dx/dt={:.4f}".format(sp.airy(-tf)[0],-sp.airy(-tf)[1]))\n') # - why the noticeable wall time? # - `scipy.integrate.solve_ivp`'s default method is 4(5) order Runge-Kutta # - Runge-Kutta excellent for non-stiff, non-oscillatory equations/regions # - cannot deal with high-frequency oscillations (will see why) # - the Airy frequency $\omega(t) = \sqrt{t}$, steadily increases with time # ## A brief summary of the algorithm # # Based on [arxiv:1906.01421](https://arxiv.org/abs/1906.01421) # ### Runge-Kutta (RK) steps # # - $ \dot{\vec{x}} = F(\vec{x}) $ # - have (estimate of) solution $x$ at $t_i$, want to get $x$ at $t_i+h$ # - Taylor expand: # # $$x(t_i+h) = x(t_i) + hF(x(t_i)) + \frac{1}{2}h^2\left.\frac{dF}{dt}\right\vert_{t=t_i} + \mathcal{O}(h^3)$$ # # - and replace derivatives of $F$ with evaluations of $F$ at gridpoints: # # $$ x(t_i+h) = x(t_i) + \sum_{j,\;t_i only affecting amplitude/phase # ### “RKWKB” method # # - step along the numerical solution (initial value problem solver) # - 2 components: # - _adaptive stepsize_ : change stepsize ($h$) based on estimate of local error # - _switching_ : between RK and WKB steps # - whichever gives larger $h$ to keep local error within user-specified tolerance # - use common set of function evaluations $\to$ minimum evaluations per step # - local error estimated as difference between $n^{\mathrm{th}}$ and $(n-1)^{\mathrm{th}}$ method # - details in [arxiv:1906.01421](https://arxiv.org/abs/1906.01421) # In[6]: n = 40. ts1 = np.linspace(-n/2,n/2,100000) ws1 = np.log(np.sqrt((n**2-1.)))-np.log(1+ts1**2) gs1 = np.zeros_like(ts1) ti1 = ts1[0] tf_dense = ts1[-1] xi1 = np.sqrt(1.+ti1**2)/n*np.cos(n*np.arctan(ti1)) dxi1 = 1./(n*np.sqrt(1+ti1**2))*(ti1*np.cos(n*np.arctan(ti1))-n*np.sin(n*np.arctan(ti1))) xs_ana = np.sqrt(1.+ts1**2)/n*(np.cos(n*np.arctan(ts1))) solution1 = pyoscode.solve(ts1,ws1,gs1,ti1,tf_dense,xi1,dxi1,logw=True,rtol=1e-3) def burst_f(t,y): return np.array([y[1],-(n**2-1.)/(1.+t**2)**2*y[0]]) def burst_rk(tf): sol = scipy.integrate.solve_ivp(burst_f,(ti1,tf),np.array([xi1,dxi1]),rtol=1e-3) t_rk = sol.t final_ind = np.argmax(t_rk >= tf) t_rk = t_rk[:final_ind+1] x_rk = sol.y[0][:final_ind+1] plt.figure(figsize=(10,2.5)) plt.tight_layout() plt.title("scipy.integrate.solve_ivp solving the equation $\ddot{x}+\\frac{n^2-1}{(1+t^2)^2}x=0$, with $n=40$") plt.plot(ts1,xs_ana,label='analytic solution',color='black',lw=0.7) plt.xlabel('t') plt.ylabel('x') plt.plot(t_rk,x_rk ,'.',label='RK',ms=9.0,color='C1') plt.legend() def burst(tf): t_osc = np.array(solution1['t']) final_ind = np.argmax(t_osc >= tf) t_osc = t_osc[:final_ind+1] x_osc = np.array(solution1['sol'])[:final_ind+1] types = np.array(solution1['types'])[:final_ind+1] t_rk = t_osc[types==0] x_rk = x_osc[types==0] t_wkb = t_osc[types==1] x_wkb = x_osc[types==1] plt.figure(figsize=(10,2.5)) plt.tight_layout() plt.title("oscode solving the equation $\ddot{x}+\\frac{n^2-1}{(1+t^2)^2}x=0$, with $n=40$") plt.plot(ts1,xs_ana,label='analytic solution',color='black',lw=0.7) plt.xlabel('t') plt.ylabel('x') plt.plot(t_rk,x_rk ,'.',label='RK',ms=9.0,color='C1') plt.plot(t_wkb,x_wkb,'.',label='WKB',ms=9.0,color='C0') plt.legend() interact(burst, tf=FloatSlider(min=ti1, max=tf_dense, step=1.0, continuous_update=False, value=ti1)); interact(burst_rk, tf=FloatSlider(min=ti1, max=tf_dense, step=1.0, continuous_update=False, value=ti1)); # In[7]: def burst2(n2): ts2 = np.linspace(-n2,n2,500000) ws2 = np.log(np.sqrt((n2**2-1.)))-np.log(1+ts2**2) gs2 = np.zeros_like(ts2) ti2 = ts2[0] tf_dense = ts2[-1] xi2 = np.sqrt(1.+ti2**2)/n2*np.cos(n2*np.arctan(ti2)) dxi2 = 1./(n2*np.sqrt(1+ti2**2))*(ti2*np.cos(n2*np.arctan(ti2))-n2*np.sin(n2*np.arctan(ti2))) xs_ana2 = np.sqrt(1.+ts2**2)/n2*(np.cos(n2*np.arctan(ts2))) solution2 = pyoscode.solve(ts2,ws2,gs2,ti2,tf_dense,xi2,dxi2,logw=True,rtol=1e-3) t_osc = np.array(solution2['t']) oscs = np.zeros(len(t_osc)) for i,t in enumerate(t_osc): if i!=0: oscs[i] = ((n2*np.arctan(t)/(2*np.pi))-(n2*np.arctan(t_osc[i-1])/(2*np.pi))) else: oscs[i] = None types = np.array(solution2['types']) t_rk = t_osc[types==0] t_wkb = t_osc[types==1] plt.figure(figsize=(10,5)) plt.tight_layout() plt.title("Number of oscillations traversed per step, $\ddot{{x}}+\\frac{{n^2-1}}{{(1+t^2)^2}}x=0$, n={}".format(n2)) plt.xlabel('t') plt.ylabel('$N_{\mathrm{osc}}$') plt.semilogy(t_osc,oscs,color='black') plt.semilogy(t_rk,oscs[types==0] ,'.',label='RK',ms=9.0,color='C1') plt.semilogy(t_wkb,oscs[types==1],'.',label='WKB',ms=9.0,color='C0') plt.ylim((1e-2,1e4)) plt.legend() interact(burst2, n2=FloatLogSlider(base=10, min=2, max=5, step=1), continuous_update=False); # - Airy equation revisited with pyoscode (~2 s runtime with `scipy`): # In[8]: ts = np.linspace(0,1e3,5000) ws = np.sqrt(ts) gs = np.zeros_like(ts) # Initial conditions ti = 1.0 tf = ts[-1] x0 = sp.airy(-ti)[0] dx0 = -sp.airy(-ti)[1] # In[9]: get_ipython().run_cell_magic('time', '', 'pyoscode_sol = pyoscode.solve(ts,ws,gs,ti,tf,x0,dx0,rtol=1e-3)\npyosc_y = pyoscode_sol[\'sol\']\npyosc_dy = pyoscode_sol[\'dsol\']\nprint("x(t={})={:.4f}, dx/dt={:.4f}".format(tf,pyosc_y[-1],pyosc_dy[-1]))\nprint("analytic solution: x={:.4f}, dx/dt={:.4f}".format(sp.airy(-tf)[0],-sp.airy(-tf)[1]))\n') # ## Examples in physics # ### Cosmology # # - origins of large-scale structure thought to have been quantum fluctuations # - fluctuations grew during a period of accelerated expansion, inflation ($t < \;\sim 10^{-32}$ s) # - power spectrum of _gauge-invariant curvature perturbations_ $\to$ cosmic microwave background (CMB) # - to infer physics of early universe, need to assume or compute (primordial) power spectrum # # # # # - power spectrum: $|$amplitude$|^2$ as a function of Fourier wavenumber $k$ # - computation of spectrum _can be_ bottleneck in forward modelling, e.g. # - closed universes [arxiv:1907.08524](https://arxiv.org/abs/1907.08524) # - axion monodromy models [arxiv:1502.02114](https://arxiv.org/abs/1502.02114) # - initial conditions set at very early times [arXiv:2002.07042](https://arxiv.org/abs/2002.07042) # # # # # # - need time-evolution of gauge-invariant curvature perturbations of wavenumber $k$: # - Mukhanov-Sasaki equation (assuming spatially flat universe, single-field inflation) # $$ \ddot{\mathcal{R}}_k + 2\left( \frac{\ddot{\phi}}{\phi} -\frac{1}{2} \dot{\phi}^2 + \frac{3}{2}\right)\dot{\mathcal{R}}_k + \left( \frac{k}{aH} \right)^2\mathcal{R}_k = 0 $$ # - $a$: scale factor, $H$: Hubble parameter, $\phi$: inflaton field # - using $N = \ln{a}$ as independent variable # - oscillator with time-dependent frequency and damping # - frequency $\propto k \to$ can get large, computationally challenging # - $\omega$, $\gamma$ not closed-form functions # # In[10]: import re pair = re.compile(r'\(([^,\)]+),([^,\)]+)\)') def parse_pair(s): s = s.decode('utf-8') return complex(*map(float, pair.match(s).groups())) f1 = "images/bingo-singlek-k1e-5.txt" f1ref = "images/bingo-singlek-k1e-5-ref.txt" f1wkb = "images/rkwkb-single-k1e-5.txt" d1 = np.genfromtxt(f1) d1ref = np.genfromtxt(f1ref) d1wkb = np.genfromtxt(f1wkb,dtype=complex,converters={1:parse_pair, 2:parse_pair}) n1 = d1[:,0] n1ref = d1ref[:,0] n1wkb = d1wkb[:,0] rk1 = d1[:,1] rk1ref = d1ref[:,1] rk1wkb = d1wkb[:,1] rk1steps = d1wkb[:,3] plt.figure(figsize=(10,5)) plt.title('Evolution of a single primordial perturbation $\mathcal{R}_k$ of Fourier wavenumber $k$') plt.plot(n1ref,rk1ref,color='black',lw=0.7) plt.plot(n1wkb[rk1steps==0],rk1wkb[rk1steps==0],'.',color='C1',ms=9.0,label='RK step') plt.plot(n1wkb[rk1steps==1],rk1wkb[rk1steps==1],'.',color='C0',ms=9.0,label='WKB step') plt.ticklabel_format(axis='y',style='sci',scilimits=(-2,2)) plt.legend() plt.xlabel('N') plt.ylabel('$\Re{\{ \mathcal{R}_k \}} $') plt.show() # In[11]: fs1 = "images/singlek-kd-bd-2a.txt" fs2 = "images/singlek-kd-bd-2a.txt" fref = "images/singlek-kd-bg-2a-ref.txt" d1 = np.genfromtxt(fs1,dtype=complex,converters={1:parse_pair,2:parse_pair,4:parse_pair}) d2 = np.genfromtxt(fs2,dtype=complex,converters={1:parse_pair,2:parse_pair,4:parse_pair}) dref = np.genfromtxt(fref) n1 = d1[:,0] n2 = d2[:,0] nref = dref[:,0] rk1 = d1[:,1] rk2 = d2[:,1] rkref = dref[:,1] wkb1 = d1[:,3] wkb2 = d2[:,3] plt.figure(figsize=(10,5)) plt.title('Evolution of a single primordial perturbation $\mathcal{R}_k$ of Fourier wavenumber $k$') plt.plot(nref,rkref,color='black',lw=0.7) plt.plot(n1[wkb1==1],(rk1[wkb1==1]),'.',color='C0',label='WKB step',ms=9.) plt.plot(n1[wkb1==0],(rk1[wkb1==0]),'.',color='C1',label='RK step',ms=9.) plt.ticklabel_format(axis='y',style='sci',scilimits=(-2,2)) plt.xlabel('N') plt.ylabel('$\Re{\{ \mathcal{R}_k \}} $') plt.legend() plt.show() # ### Quantum mechanics # # - time-independent, one-dimensional Schrödinger equation: # # $$ \Psi''(x) + 2m(E-V(x))\Psi(x) = 0 $$ # # - $\Psi$ is wavefunction, $V(x)$ potential well, $E$ are _unknown_ energy eigenvalues of the system to be determined # - a harmonic oscillator with time-dependent frequency that can be real or imaginary # - a _boundary value problem_ : # - continuity of $\Psi$ and $\Psi'$ required at edges of potential well where $E = V(x)$ # - $\Psi(x)\to 0$ far outside the well where $V(x) \gg E$ # - can rephrase as optimisation problem: # - guess lower and upper bound for $n^{\mathrm{th}}$ eigenvalue $E_n$ # - start from initial guess for $E_n$ within bounds # - propagate two solutions from outside the potential well ( _initial value problems_ ) # - check for continuity between two solutions, $\Psi_L$ and $\Psi_R$, at midpoint # - eigenvalue found when $\left\vert\frac{\Psi_L'}{\Psi_L}-\frac{\Psi_R'}{\Psi_R}\right\vert = 0$ # - Try potential of the form $V(x) = x^2 + \lambda x^4$, the anharmonic oscillator # - $\lambda=1$, find $n^{\mathrm{th}}$ energy levels with $n=\{$50, 100, 1000, 10000$\}$ # - Numerical values using a conventional method: $E_n=$ 417.05626, 1035.5442, 21932, 7840, 471103.80 # In[12]: l=1.0 m=0.5 def Ei(n): """ Energy eigenvalues (if analytic solution available) """ return np.sqrt(2.0)*(n-0.5) def V(t): """ Potential well """ return t**2 + l*t**4 def w(t,E): """ Frequency term in the Schrodinger equation """ return np.sqrt(2*m*(complex(E)-V(t))); def f(E): """ Function to minimize wrt E to give the energy eigenvalues """ # Boundaries of integration tl = -((E)**(0.25))-2.0 tr = -tl tm = 0.5 # Grid of w, g t = np.linspace(tl.real,tr.real,30000) ws = np.log(w(t,E)) g = np.zeros(t.shape) sol_l = pyoscode.solve(t,ws,g,tl,tm,0,1e-3,logw=True,rtol=1e-5) sol_r = pyoscode.solve(t,ws,g,tr,tm,0,1e-3,h=-1,logw=True,rtol=1e-5) psi_l = sol_l["sol"][-1] psi_r = sol_r["sol"][-1] dpsi_l = sol_l["dsol"][-1] dpsi_r = sol_r["dsol"][-1] try: return abs(dpsi_l/psi_l - dpsi_r/psi_r) except ZeroDivisionError: return 1000.0 def plot(ns,Es): plt.figure(figsize=(10,5)) t_v = np.linspace(-6,6,500) plt.plot(t_v,V(t_v),color='black',label='V(x)') for j,n,E in zip(range(len(ns)),ns,Es): # Boundaries of integration tl = -((E)**(0.25))-1.0 tr = -tl tm = 0.0 # Grid of w, g t = np.linspace(tl.real,tr.real,30000) ws = np.log(w(t,E)) g = np.zeros(t.shape) sol_l = pyoscode.solve(t,ws,g,tl,tr/2.,0,1e-3,logw=True,rtol=1e-5) x_l = sol_l['sol'] ts_l = sol_l['t'] types_l = sol_l['types'] for i,typ in enumerate(types_l): if typ==1 and 0 not in types_l[i:]: firstwkb = i break; t_eval = np.linspace(ts_l[firstwkb],tm,2000) sol = pyoscode.solve(t,ws,g,tl,tr,0,1e-3,logw=True,rtol=1e-5,t_eval=t_eval) x_eval = sol['x_eval'] x_l = x_l[:firstwkb] ts_l = ts_l[:firstwkb] Ts_l = np.concatenate((np.array(ts_l),t_eval)) Xs_l = np.concatenate((np.array(x_l),x_eval)) maxx = max(np.real(Xs_l)) Xs_l = Xs_l/maxx*4*np.sqrt(E) plt.plot(Ts_l,Xs_l+E,color='C{}'.format(j),label='$\Psi_n(x)$, n={}, $E_n$={:.4f}'.format(n,E)) plt.plot(-1*Ts_l,Xs_l+E,color='C{}'.format(j)) plt.xlabel('x') plt.legend(loc='lower left') plt.show() bounds = [ (416.5,417.5),(1035,1037),(21930,21940),(471100,471110)] ress = [] for bound in bounds: res = minimize_scalar(f,bounds=bound,method='bounded') print("Eigenenergy found: {}".format(res.x)) ress.append(res.x) plot([50,100],ress[:2]) # ### Other examples # # - modelling the mechanics of the cochlea [doi:10.1155/2014/150637](https://doi.org/10.1155/2014/150637) # - cochlea map sounds of different frequencies onto unique positions on the basilar membrane # - normal modes in black holes [doi:10.1103/PhysRevD.35.3632](https://doi.org/10.1103/PhysRevD.35.3632) # - resonant, nonradial perturbations of black holes caused by external perturbations # - scattering of radio waves from overdense meteor trains [doi:10.1016/0032-0633(92)90045-P](https://doi.org/10.1016/0032-0633(92)90045-P) # - symmetry allows radio waves to be decomposed into partial waves, apply WKB to each # ## The interface # - draws from `scipy.integrate.solve_ivp(fun, t_span, y0, ...)` # - `fun`: defines differential equation # - `t_span`: integration range # - `y0`: initial conditions # - minimal setup: # In[13]: import numpy as np import scipy.special as sp import pyoscode # Set up differential equation: x'' + tx = 0. ts = np.linspace(0,40.0,5000) ws = np.sqrt(ts) gs = np.zeros_like(ts) # Initial conditions ti = 1.0 tf = 40.0 x0 = sp.airy(-ti)[0] dx0 = -sp.airy(-ti)[1] solution = pyoscode.solve(ts, ws, gs, ti, tf, x0, dx0) solution.keys() # In[14]: # Compute analytic solution for comparison analytic = np.array([sp.airy(-t)[0] for t in ts]) plt.figure(figsize=(10,5)) plt.plot(ts,analytic,label='analytic solution',color='black',lw=0.7) plt.plot(solution['t'],solution['sol'],'.',color='C1',label='pyoscode',ms=9.0) plt.show() # - optional arguments allow _dense output_ # - output solution at user-specified points # - no extra steps or evaluations of the RHS of the ODE $\to$ computationally cheap # - not trivial for highly oscillatory solutions (Agocs, “Dense output for highly oscillatory numerical solutions” in prep.) # In[15]: # We'll get the solution at these points@ t_eval = np.linspace(15,35,600) dense_solution = pyoscode.solve(ts, ws, gs, ti, tf, x0, dx0, t_eval=t_eval) plt.figure(figsize=(10,5)) plt.plot(ts,analytic,label='analytic solution',color='black',lw=0.7) plt.plot(dense_solution['t'],dense_solution['sol'],'.',color='C1',label='pyoscode internal step',ms=9.0) plt.plot(t_eval, dense_solution['x_eval'], color='C1', label='pyoscode dense output') plt.xlabel('x') plt.ylabel('y(x)') plt.legend() plt.show() # ### Documentation # # - https://github.com/fruzsinaagocs/oscode # - open source software, to be submitted to JOSS # - python & C++ documentation at https://oscode.readthedocs.io # - examples available for both interfaces # ### Extending pyoscode # - multiple dimensions # - modified Magnus expansion instead of (inherently 1D) WKB by [arxiv:1907.11638](https://arxiv.org/abs/1907.11638) # - more work under way (Schöneberg and Agocs, “Beyond the traditional WKB approximation of Boltzmann equations” in prep.) # - adapt basis function $f_{\pm}$ to problem in question: # - WKB is suitable for highly oscillatory solutions with slowly changing frequencies # - different basis may be suitable for e.g. a problem exhibiting sawtooth-shaped oscillations # - adjust order of WKB expansion on the fly # ## Thank you, questions welcome! # ### References #