Oscillator phase noise

load "resistor.osdi"
load "capacitor.osdi"
load "inductor.osdi"
load "spice/bjt.osdi"

model i isource
model v vsource
model r resistor
model c capacitor
model l inductor

model q2n2222 sp_bjt ( type=1
  is=15.2f nf=1 bf=105 vaf=98.5 ikf=0.5
  ise=8.2p ne=2 br=4 nr=1 var=20 ikr=0.225
  re=0.373 rb=1.49 rc=0.149 xtb=1.5
  cje=35.5p cjc=12.2p tf=500p tr=85n
  kf=1e-9 af=1
)

r1 (vdd b) r r=47k
r2 (e 0) r r=470
q1 (c b e) q2n2222
c1 (vdd c) c c=2n
l1 (vdd c) l l=200u
c2 (c e) c c=5000p
cb (b 0) c c=100n
vdd (vdd 0) v dc=9
// ipulse (0 c) i type="pulse" val0=0 val1=100m rise=1n fall=1n width=1u delay=1u

control
  abort always

  var fmin=10.0
  var fmax=4e5
  var ptsper=100
  var periods=20
  var maxstep=1.0/(fmax*ptsper)

  // This transient uses trapezoidal integration. The goal is to find the steady-state. 
  // We don't care about small ringing artifacts. 
  options reltol=1e-6 // vntol=1e-9 // abstol=1e-12
  // Transient, store final state
  analysis tran1 tran stop=60m step=maxstep maxstep=maxstep store="ss"
  // Steady state, noiseless
  analysis tran2 tran stop=10u step=maxstep maxstep=maxstep ic="ss" icmode="uic"
  
  // Output files are huge, store only what is absolutely necessary
  save v(c)

  // Avoid trapezoidal ringing that manifests itself as stray peaks around 
  // oscillation frequency harmonics by using Gear integration. 
  // For oscillator startup with Gear we need a small timestep to reduce Gear's damping. 
  // For steady-state the step can be larger. 
  options tran_method="gear"
  
  // Each of these analyses takes a few minutes

  // Steady state, noisy
  // We don't care about 1/f noise fidelity at high frequencies, so oversample=1
  // options tran_debug=0 nr_debug=0
  analysis tran3 tran (
    stop=periods/fmin step=maxstep maxstep=maxstep ic="ss" icmode="uic" noisemode="zoh"
    noisefmax=fmax noisefmin=fmin oversample=1
  )

  // Steady state, no noise (reference)
  analysis tran4 tran (
    stop=periods/fmin step=maxstep maxstep=maxstep ic="ss" icmode="uic" 
  )
  postprocess(PYTHON, "runme.py")
endc

embed "runme.py" <<<FILE
from rawfile import rawread
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import welch, firwin, oaconvolve, fftconvolve, freqz, hilbert
from scipy.interpolate import CubicSpline

# import scipy.fft as fft
# fft.set_workers(48)

# Oscillator transient
fig1 = plt.figure(figsize=(8,8), dpi=100)
fig1.suptitle('LC oscillator')
ax_dict1 = fig1.subplot_mosaic("""
A
B
C
""")

raw = rawread('tran1.raw')
plot = raw.get()

ax_dict1["A"].set_title('Oscillator response')
ax_dict1["A"].set_ylabel('Vc [V]')
ax_dict1["A"].set_xlabel('time [s]')
ax_dict1["A"].plot(plot['time'], plot['c'])


# Oscillator steady-state
raw = rawread('tran2.raw')
plot = raw.get()
x = plot['time']
y = plot['c']

ax_dict1["B"].set_title('Oscillator steady state')
ax_dict1["B"].set_ylabel('Vc [V]')
ax_dict1["B"].set_xlabel('time [s]')
ax_dict1["B"].plot(x, y)

# plt.show()

# Determine period from steady state
y_mean = (y.max()+y.min())/2

# Rising crossings: y[i] < mean and y[i+1] >= mean
idx = np.where((y[:-1] < y_mean) & (y[1:] >= y_mean))[0]

# Linear interpolation of crossing times
t_cross = (
    x[idx]
    + (y_mean - y[idx]) * (x[idx + 1] - x[idx]) / (y[idx + 1] - y[idx])
)

k = np.arange(len(t_cross))
# fit a*k + b to t_cross -> a = T
a, b = np.polyfit(k, t_cross, 1)
T = a

fosc = 1/T
print("fosc=", fosc)


# Computes PSD, aligns bins to fosc, 
# makes sure the lowest nonzero frequency bin is below fmintarget
def psd(x, y, fmintarget, fosc):
  # Integer number of periods
  nper = int(np.floor(x[-1]*fosc))
  
  # Original sampling time
  dxorig = x[-1]/x.shape[0]

  # Number of points in period, round up to even number
  nptsper = int(np.ceil(1/(fosc*dxorig)))
  if nptsper%2!=0:
    nptsper += 1

  # New sampling frequency, greater than original
  dx = 1/(fosc*nptsper)
  fmax = 1/dx

  # Number of periods per segment so that the lower end of spectum is below fmin
  nperinseg = int(np.ceil(fosc/fmintarget))

  # Number of points per segment
  nptsseg = nperinseg*nptsper
  segtime = dx*nptsseg
  fmin = 1/segtime

  # Recording end time
  xend = np.floor(x[-1]/segtime)*segtime

  # Number of interpolated points
  N = int(np.round(xend/dx))
  
  # Interpolate (cubic spline to avoid spurious tones from linear interpolation)
  xs = x[0]+np.arange(N)*dx
  ys = CubicSpline(x, y)(xs)

  # Remove mean
  ys = ys - np.mean(ys)

  print("PSD estimation (Welch)")
  print("  Frequency:       ", fosc)
  print("  Original points: ", x.shape[0])
  print("  Original end:    ", x[-1])
  print("  Recorded periods:", nper)
  print("  Interpolated pts:", N)
  print("  Interpolated end:", xs[-1])
  print("  fmin:            ", fmin)
  print("  fmax:            ", fmax)
  print("  Segment length:  ", nptsseg)
  print("  Period length:   ", nptsper)
  # Sanity checks (must be integer within roundoff error)
  print("  Periods/segment: ", nptsseg/nptsper)
  print("  Segments:        ", N/(nptsseg/2)-1)
  print("  fosc/fmin:       ", fosc/fmin)
  print("  fmax/fmin:       ", fmax/fmin)
  print("  tend/tseg:       ", (xs[-1]+dx)/segtime)
  
  f, Pxx = welch(
      ys,
      fs=1/dx,
      window='hann',
      nperseg=nptsseg,      
      noverlap=nptsseg/2,      
      return_onesided=True,
      scaling='density', 
      average='mean'
  )

  return f, Pxx

# Oscillator noise
# a(t)   ... amplitude noise
# phi(t) ... phase noise
# x(t) = (A + a(t)) cos(w0 t + phi(t))
#      = Re((A + a(t)) exp(j(w0 t + phi(t))))
#      = Re((A + a(t)) exp(j phi(t))) exp(j w0 t) 
#      = Re(V(t) exp(j w0 t))
# V(t) = (A + a(t)) exp(j phi(t))
#      for phi(t) < 1rad
#      = (A + a(t)) (1 + j phi(t))
#      = (A + a(t)) + (A + a(t)) j phi(t)
#      because phi(t)*a(t) is a product of two small signals, approximately
#      = (A + a(t)) + j A phi(t)
#
# Because a(t) and phi(t) vary slowly compared to w0 the Hilbert transform treats them as constants
# By considering H(cos(t)) = sin(t) we get
#   y(t) = H(x(t)) = (A + a(t)) sin(w0 t + phi(t))
#
# The analytical signal xa(t) = x(t) + j y(t) is then 
#   xa(t) = (A + a(t)) cos(w0 t + phi(t)) + j (A + a(t)) sin(w0 t + phi(t))
#         = (A + a(t)) exp(j(w0 t + phi(t)))
#         = (A + a(t)) exp(j phi(t)) exp(j w0 t)
#         = V(t) exp(j w0 t)
#
# |xa(t)| = A + a(t)
# arg(xa(t)) = phi(t) + w0 t
#
# By removing linear phase from arg(xa(t)) we get phi(t). 

# Extracts phase noise
def phaseNoise(x, y, fosc, B, trim=True):
    # Interpolate to next power of 2 points
    n = 2**np.ceil(np.log(x.shape[0])/np.log(2))
    dx = x[-1]/n

    xs = np.arange(x[0], x[-1] + dx, dx)
    ys = CubicSpline(x, y)(xs)

    # Filter signal around fosc with bandwidth 2B
    # Scale taps with sampling rate to maintain frequency resolution
    taps = int(np.ceil(4.0 / (B * dx)))
    if taps % 2 == 0:
        taps += 1
    print(f"  Taps: {taps}")

    bp = firwin(
        taps,
        [fosc-B, fosc+B],
        fs=1/dx,
        pass_zero=False,
        window="blackmanharris"
    )

    # Print filter gain at key frequencies
    fs_filt = 1/dx
    for fname, freq in [("fosc", fosc), ("fosc+B", fosc+B), ("2*fosc", 2*fosc)]:
        w = 2 * np.pi * freq / fs_filt
        _, h = freqz(bp, worN=[w])
        print(f"  Filter gain at {fname}={freq:.1f} Hz: {np.abs(h[0]):.6f} ({20*np.log10(np.abs(h[0])):.2f} dB)")

    ysbp = fftconvolve(ys, bp, mode="same")

    # Hilbert transform
    z = hilbert(ysbp)

    # Trim taps leading and trailing samples
    if trim:
        z = z[taps:-taps]
        xs = xs[taps:-taps]

    # Downmix to baseband and compute phase 
    z_bb = z * np.exp(-1j * 2*np.pi * fosc * xs)
    phase = np.unwrap(np.angle(z_bb))

    # Remove remaining linear trend -> phase jitter
    a, b = np.polyfit(xs, phase, 1)
    phi = phase - (a*xs + b)

    return xs, phi


# PSD and phase noise
fig2 = plt.figure(figsize=(8,10), dpi=100)
fig2.suptitle('LC oscillator noise')
ax_dict = fig2.subplot_mosaic("""
A
B
C
""")

ax_dict["A"].set_title('PSD')
ax_dict["A"].set_ylabel('PSD [V^2/Hz]')
ax_dict["A"].set_xlabel('f [Hz]')

ax_dict["B"].set_title('Phase noise SSB PSD')
ax_dict["B"].set_ylabel('PSD [dBc/Hz]')
ax_dict["B"].set_xlabel('offset f [Hz]')

ax_dict["C"].set_title('Phase jitter')
ax_dict["C"].set_ylabel('Phase jitter [rad]')
ax_dict["C"].set_xlabel('t [s]')

# Bandwidth around oscillation frequency for computing SSB phase noise
Bssb = 150e3

# Bandwidth around oscillation frequency for computing phase jitter
Bjitter = 25e3

# Fmin target
fmin_desired = 10

print()
print("Noisy signal")
raw = rawread('tran3.raw')
plot = raw.get()
xn = plot['time']
yn = plot['c']
fn, Pxxn = psd(xn, yn, fmin_desired, fosc)
mask = (fn>=240e3) & (fn<250e3)
fn = fn[mask]
Pxxn = Pxxn[mask]
ax_dict["A"].semilogy(fn, Pxxn, 'b', label="noisy")

print(f"SSB phase noise of noisy signal, B={Bssb}")
xjn, pjn = phaseNoise(xn, yn, fosc, Bssb)
fn, Sphi = psd(xjn, pjn, fmin_desired, fosc)
L = 10 * np.log10(0.5 * Sphi)
# Remove DC, keep bins up to Bssb
mask = (fn>0) & (fn<=Bssb)
fn = fn[mask]
L = L[mask]
ax_dict["B"].semilogx(fn, L, 'b', label="noisy")

print(f"Phase jitter of noisy signal, B={Bjitter}")
xjn, pjn = phaseNoise(xn, yn, fosc, Bjitter)
ax_dict["C"].plot(xjn, pjn, 'b', label="noisy")

ax_dict1["C"].set_title('Oscillator with noise')
ax_dict1["C"].set_ylabel('Vc [V]')
ax_dict1["C"].set_xlabel('time [s]')
ax_dict1["C"].plot(xn, yn)


print()
print("Noiseless signal")
raw = rawread('tran4.raw')
plot = raw.get()
x = plot['time']
y = plot['c']
f, Pxx = psd(x, y, fmin_desired, fosc)
mask = (f>=240e3) & (f<250e3)
f = f[mask]
Pxx = Pxx[mask]
ax_dict["A"].semilogy(f, Pxx, 'r', label="noiseless")

print(f"SSB phase noise of noiseless signal, B={Bssb}")
xjn, pjn = phaseNoise(x, y, fosc, Bssb)
fn, Sphi = psd(xjn, pjn, fmin_desired, fosc)
L = 10 * np.log10(0.5 * Sphi)
# Remove DC, keep bins up to Bssb
mask = (fn>0) & (fn<=Bssb)
fn = fn[mask]
L = L[mask]
ax_dict["B"].semilogx(fn, L, 'r', label="noiseless")

print(f"Phase jitter of noiseless signal, B={Bjitter}")
xj, pj = phaseNoise(x, y, fosc, Bjitter)
ax_dict["C"].plot(xj, pj, 'r', label="noiseless")


ax_dict["A"].legend(loc='upper right')
ax_dict["B"].legend(loc='upper right')
ax_dict["C"].legend(loc='lower right')

fig1.tight_layout()
fig2.tight_layout()

plt.show()
>>>FILE


