quantum-playground 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qplay/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """qplay: a small physics and maths playground.
2
+
3
+ Modules
4
+ -------
5
+ integrators time-stepping schemes (Euler, RK4, velocity Verlet, adaptive wrapper)
6
+ mechanics oscillators, pendulums, projectiles, N-body gravity
7
+ waves 1D wave and heat equations, Fourier series, spectra
8
+ em point-charge fields, Laplace solver, charged-particle motion
9
+ quantum Schroedinger solvers (1D and 2D), tunnelling, double slit, hydrogen orbitals, spin-1/2,
10
+ oscillator eigenstates and coherent states
11
+ statmech 2D Ising model and Onsager's exact solution
12
+ relativity Lorentz transformations and special-relativity helpers
13
+ kinetic hard-sphere gas, Maxwell-Boltzmann distribution, ideal-gas law
14
+ atomic angular momentum, spin-orbit coupling, Zeeman effect, hydrogen fine structure
15
+ nuclear radioactive decay, Bateman decay chains, Monte Carlo decay
16
+ circuits RC charging and discharging, series RLC resonance
17
+ chaos Lorenz system, logistic map, Mandelbrot/Julia sets
18
+ symbolic SymPy helpers: Euler-Lagrange equations, Lagrangian -> simulation
19
+ hamiltonian Legendre transform, Hamilton's equations, Hamiltonian -> simulation
20
+ dynamics phase portraits and fixed-point classification for 2D systems
21
+ complexplane domain colouring and the argument principle
22
+ linalg Gram-Schmidt, power iteration, how a matrix maps the unit circle
23
+ numerics root finding, quadrature, finite differences, Monte Carlo
24
+ plotting figure saving and GIF helpers
25
+ demos ready-made figures, run with ``python -m qplay run <name>``
26
+ animations GIFs, run with ``python -m qplay animate <name>``
27
+ """
28
+
29
+ __version__ = "0.3.0"
qplay/__main__.py ADDED
@@ -0,0 +1,58 @@
1
+ """Command line.
2
+
3
+ qplay list show demos and animations
4
+ qplay run <name|all> [--out DIR] render demo figures (PNG)
5
+ qplay animate <name|all> [--frames N] [--fps F] [--out DIR] render animations (GIF)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from . import __version__, animations, demos
13
+
14
+
15
+ def _summary(fn):
16
+ doc = (fn.__doc__ or "").strip().splitlines()
17
+ return doc[0] if doc else ""
18
+
19
+
20
+ def main(argv=None):
21
+ parser = argparse.ArgumentParser(prog="qplay", description="Physics and maths playground")
22
+ parser.add_argument("--version", action="version", version=__version__)
23
+ sub = parser.add_subparsers(dest="command", required=True)
24
+ sub.add_parser("list", help="list demos and animations")
25
+ run = sub.add_parser("run", help="render demos to PNG files")
26
+ run.add_argument("names", nargs="+", help="demo names, or 'all'")
27
+ run.add_argument("--out", default="figures", help="output directory (default: figures)")
28
+ anim = sub.add_parser("animate", help="render animations to GIF files")
29
+ anim.add_argument("names", nargs="+", help="animation names, or 'all'")
30
+ anim.add_argument("--out", default="figures", help="output directory (default: figures)")
31
+ anim.add_argument("--frames", type=int, default=120, help="number of frames (default: 120)")
32
+ anim.add_argument("--fps", type=int, default=24, help="frames per second (default: 24)")
33
+ args = parser.parse_args(argv)
34
+
35
+ if args.command == "list":
36
+ print("Demos (qplay run NAME):")
37
+ for name in sorted(demos.DEMOS):
38
+ print(f" {name:22s}{_summary(demos.DEMOS[name])}")
39
+ print("Animations (qplay animate NAME):")
40
+ for name in sorted(animations.ANIMATIONS):
41
+ print(f" {name:22s}{_summary(animations.ANIMATIONS[name])}")
42
+ return 0
43
+
44
+ registry = demos.DEMOS if args.command == "run" else animations.ANIMATIONS
45
+ names = sorted(registry) if args.names == ["all"] else args.names
46
+ for name in names:
47
+ try:
48
+ if args.command == "run":
49
+ print(demos.run(name, args.out))
50
+ else:
51
+ print(animations.render(name, args.out, n_frames=args.frames, fps=args.fps))
52
+ except KeyError as err:
53
+ parser.error(str(err.args[0]))
54
+ return 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ raise SystemExit(main())
qplay/animations.py ADDED
@@ -0,0 +1,172 @@
1
+ """Animated GIFs. Each animation takes ``n_frames`` and returns ``(fig, update, frames)``.
2
+
3
+ Register one with ``@animation("name")``; render with ``python -m qplay animate name``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+ from matplotlib.colors import PowerNorm
13
+
14
+ from . import kinetic, mechanics, plotting, quantum, statmech
15
+
16
+ ANIMATIONS: dict = {}
17
+
18
+
19
+ def animation(name):
20
+ def register(fn):
21
+ ANIMATIONS[name] = fn
22
+ return fn
23
+
24
+ return register
25
+
26
+
27
+ def render(name, outdir="figures", n_frames=120, fps=24):
28
+ """Render one animation to ``<outdir>/<name>.gif`` and return the path."""
29
+ if name not in ANIMATIONS:
30
+ raise KeyError(f"unknown animation {name!r}; available: {', '.join(sorted(ANIMATIONS))}")
31
+ fig, update, frames = ANIMATIONS[name](n_frames)
32
+ return plotting.save_gif(fig, update, frames, Path(outdir) / f"{name}.gif", fps=fps)
33
+
34
+
35
+ @animation("double-pendulum")
36
+ def double_pendulum_anim(n_frames):
37
+ """A chaotic double pendulum with a fading trail."""
38
+ sub = 10 # integrate on a finer grid so the trail is smooth
39
+ t, y = mechanics.double_pendulum([2.2, 2.6, 0.0, 0.0], t_max=15, n=n_frames * sub)
40
+ x1, y1, x2, y2 = mechanics.double_pendulum_xy(y)
41
+ fig, ax = plt.subplots(figsize=(4, 4))
42
+ ax.set(xlim=(-2.2, 2.2), ylim=(-2.2, 2.2), aspect="equal", xticks=[], yticks=[])
43
+ ax.grid(False)
44
+ (trail,) = ax.plot([], [], color="C1", lw=1, alpha=0.6)
45
+ (rods,) = ax.plot([], [], "o-", color="k", lw=2, ms=7, mfc="C0")
46
+ label = ax.text(0.03, 0.95, "", transform=ax.transAxes, va="top")
47
+
48
+ def update(frame):
49
+ i = frame * sub
50
+ rods.set_data([0, x1[i], x2[i]], [0, y1[i], y2[i]])
51
+ start = max(0, i - 25 * sub)
52
+ trail.set_data(x2[start:i + 1], y2[start:i + 1])
53
+ label.set_text(f"t = {t[i]:.1f} s")
54
+
55
+ return fig, update, range(n_frames)
56
+
57
+
58
+ @animation("wavepacket")
59
+ def wavepacket_anim(n_frames):
60
+ """A Gaussian wavepacket tunnelling through and reflecting off a barrier."""
61
+ x = np.linspace(-60, 60, 2048, endpoint=False)
62
+ V = np.where(np.abs(x) < 1.0, 2.0, 0.0)
63
+ psi0 = quantum.gaussian_wavepacket(x, x0=-20, sigma=3, k0=1.5)
64
+ steps = 3000
65
+ times, psi = quantum.split_step(psi0, x, V + quantum.absorbing_layer(x, 10.0),
66
+ dt=0.01, steps=steps, save_every=max(1, steps // n_frames))
67
+ fig, ax = plt.subplots(figsize=(6, 3.2))
68
+ ax.fill_between(x, V / 2.0 * 0.15, color="0.85")
69
+ (line,) = ax.plot([], [], color="C0")
70
+ (real,) = ax.plot([], [], color="C1", lw=0.6, alpha=0.6)
71
+ ax.set(xlim=(-45, 45), ylim=(-0.4, 0.4), xlabel="x")
72
+ label = ax.text(0.02, 0.92, "", transform=ax.transAxes)
73
+
74
+ def update(i):
75
+ line.set_data(x, np.abs(psi[i]) ** 2)
76
+ real.set_data(x, psi[i].real * 0.6)
77
+ label.set_text(rf"$|\psi|^2$ and Re $\psi$, t = {times[i]:.1f}")
78
+
79
+ return fig, update, range(min(n_frames, len(psi)))
80
+
81
+
82
+ @animation("figure-eight")
83
+ def figure_eight_anim(n_frames):
84
+ """Three equal masses chasing each other around a figure-eight."""
85
+ fe = mechanics.FIGURE_EIGHT
86
+ _, pos, _ = mechanics.nbody(fe["masses"], fe["pos0"], fe["vel0"], fe["period"], n=n_frames)
87
+ fig, ax = plt.subplots(figsize=(5, 2.8))
88
+ ax.plot(pos[:, 0, 0], pos[:, 0, 1], color="0.85", lw=1)
89
+ ax.set(xlim=(-1.2, 1.2), ylim=(-0.45, 0.45), aspect="equal", xticks=[], yticks=[])
90
+ ax.grid(False)
91
+ bodies = ax.scatter(pos[0, :, 0], pos[0, :, 1], s=60, c=["C0", "C1", "C2"], zorder=3)
92
+
93
+ def update(i):
94
+ bodies.set_offsets(pos[i])
95
+
96
+ return fig, update, range(n_frames)
97
+
98
+
99
+ @animation("double-slit")
100
+ def double_slit_anim(n_frames):
101
+ """A 2D wavepacket passing through two slits and interfering."""
102
+ steps = 1600
103
+ run = quantum.double_slit_experiment(save_every=max(1, steps // n_frames))
104
+ x, y, psi = run["x"], run["y"], run["psi"]
105
+ amp = np.abs(psi)
106
+ # square-root colour scale: the transmitted ~13% stays visible next to the bright incident packet
107
+ norm = PowerNorm(gamma=0.5, vmin=0, vmax=np.percentile(amp, 99.9))
108
+ fig, ax = plt.subplots(figsize=(4.6, 4.25))
109
+ fig.subplots_adjust(left=0.02, right=0.98, bottom=0.02, top=0.98)
110
+ fig.patch.set_facecolor("#0B0B0D") # dark margins, so the GIF sits cleanly on dark pages
111
+ im = ax.imshow(amp[0], origin="lower", extent=[x[0], x[-1], y[0], y[-1]], cmap="magma", norm=norm)
112
+ ax.contour(x, y, run["V"], [1], colors="c", linewidths=0.8)
113
+ ax.set(xlim=(-15, 50), ylim=(-30, 30), xticks=[], yticks=[])
114
+ ax.grid(False)
115
+ label = ax.text(0.02, 0.94, "", transform=ax.transAxes, color="w")
116
+
117
+ def update(i):
118
+ im.set_data(amp[i])
119
+ label.set_text(rf"$|\psi|$, t = {run['times'][i]:.1f}")
120
+
121
+ return fig, update, range(min(n_frames, len(psi)))
122
+
123
+
124
+ @animation("ising")
125
+ def ising_anim(n_frames):
126
+ """Domains coarsening in the 2D Ising model at the critical temperature."""
127
+ snaps: list = []
128
+ statmech.ising(L=128, T=statmech.T_CRITICAL, sweeps=n_frames, start="hot", seed=3, snapshots=snaps)
129
+ fig, ax = plt.subplots(figsize=(4, 4))
130
+ im = ax.imshow(snaps[0], cmap="gray", interpolation="nearest", vmin=-1, vmax=1)
131
+ ax.set(xticks=[], yticks=[])
132
+ ax.grid(False)
133
+ title = ax.set_title("")
134
+
135
+ def update(i):
136
+ im.set_data(snaps[i])
137
+ title.set_text(rf"Ising at $T_c$, sweep {i + 1}")
138
+
139
+ return fig, update, range(n_frames)
140
+
141
+
142
+ @animation("gas")
143
+ def gas_anim(n_frames):
144
+ """Discs in a box: every one starts at the same speed and the speeds spread into Maxwell-Boltzmann."""
145
+ gas = kinetic.gas_box(n=400, dim=2, radius=0.009, t_max=8, save_every=2, seed=4)
146
+ stride = max(1, (len(gas.t) - 1) // max(1, n_frames - 1))
147
+ idx = np.arange(0, len(gas.t), stride)[:n_frames]
148
+ speeds = np.linalg.norm(gas.velocities, axis=-1)
149
+ kT = gas.temperature()
150
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7.6, 3.8), gridspec_kw={"width_ratios": [1, 1]})
151
+ ax1.set(xlim=(0, 1), ylim=(0, 1), aspect="equal", xticks=[], yticks=[])
152
+ ax1.grid(False)
153
+ dots = ax1.scatter(*gas.positions[0].T, s=9, c=speeds[0], cmap="plasma", vmin=0, vmax=2.5)
154
+ bins = np.linspace(0, 3, 31)
155
+ vs = np.linspace(0, 3, 200)
156
+ ax2.plot(vs, kinetic.maxwell_boltzmann_speed(vs, kT, dim=2), "k", lw=1.2, label="Maxwell-Boltzmann (2D)")
157
+ bars = ax2.bar(bins[:-1], np.zeros(30), width=bins[1] - bins[0], align="edge", color="C0", alpha=0.6)
158
+ ax2.set(xlim=(0, 3), ylim=(0, 2.2), xlabel="speed", yticks=[])
159
+ ax2.legend(loc="upper right", fontsize=8)
160
+ label = ax2.set_title("")
161
+
162
+ def update(frame):
163
+ k = idx[frame]
164
+ dots.set_offsets(gas.positions[k])
165
+ dots.set_array(speeds[k])
166
+ window = speeds[max(0, k - 40):k + 1].ravel() # smooth the histogram over recent steps
167
+ hist, _ = np.histogram(window, bins=bins, density=True)
168
+ for bar, h in zip(bars, hist):
169
+ bar.set_height(h)
170
+ label.set_text(f"t = {gas.t[k]:.2f}")
171
+
172
+ return fig, update, range(len(idx))
qplay/atomic.py ADDED
@@ -0,0 +1,86 @@
1
+ r"""Atomic structure: angular momentum matrices, spin-orbit coupling, the Zeeman effect and
2
+ hydrogen fine structure.
3
+
4
+ For one electron with orbital angular momentum $l$ and spin $s = \tfrac12$ in a magnetic field
5
+ $B$ along $z$ (energies in units where $\hbar = 1$):
6
+
7
+ $$H = \xi\,\mathbf L\cdot\mathbf S + \mu_B B\,(L_z + g_s S_z).$$
8
+
9
+ At $B = 0$ the levels are labelled by $j = l\pm\tfrac12$ with
10
+ $E = \tfrac{\xi}{2}\,[\,j(j+1) - l(l+1) - \tfrac34\,]$. A weak field splits each into
11
+ $E = g_J m_j \mu_B B$ with the Landé factor $g_J$; a strong field gives the Paschen-Back limit
12
+ $E \to \mu_B B\,(m_l + g_s m_s)$.
13
+
14
+ Hydrogen fine structure (Dirac, to order $\alpha^2$):
15
+ $$E_{nj} = -\frac{R_\infty hc}{n^2}\left[1 + \frac{\alpha^2}{n^2}\left(\frac{n}{j+\tfrac12} - \frac34\right)\right].$$
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ from scipy import constants
22
+
23
+ ALPHA = constants.alpha
24
+ RYDBERG_EV = constants.physical_constants["Rydberg constant times hc in eV"][0]
25
+ MU_B_EV_PER_T = constants.physical_constants["Bohr magneton in eV/T"][0]
26
+ ELECTRON_G = -constants.physical_constants["electron g factor"][0] # about 2.0023
27
+
28
+
29
+ def angular_momentum(j):
30
+ r"""Matrices ``(Jx, Jy, Jz)`` for spin ``j`` in the basis $m = j, j-1, \dots, -j$ ($\hbar = 1$).
31
+
32
+ Built from $J_\pm|j,m\rangle = \sqrt{j(j+1) - m(m\pm1)}\,|j,m\pm1\rangle$.
33
+ """
34
+ m = np.arange(j, -j - 1, -1)
35
+ jp = np.diag(np.sqrt(j * (j + 1) - m[1:] * (m[1:] + 1)), 1).astype(complex) # raising
36
+ jm = jp.conj().T
37
+ return (jp + jm) / 2, (jp - jm) / 2j, np.diag(m).astype(complex)
38
+
39
+
40
+ def basis_labels(l, s=0.5):
41
+ r"""``(m_l, m_s)`` for each basis vector of :func:`spin_orbit_zeeman_hamiltonian`."""
42
+ return [(ml, ms) for ml in np.arange(l, -l - 1, -1) for ms in np.arange(s, -s - 1, -1)]
43
+
44
+
45
+ def spin_orbit_zeeman_hamiltonian(l, xi, B, mu_b=1.0, gs=2.0, s=0.5):
46
+ r"""$H = \xi\,\mathbf L\cdot\mathbf S + \mu_B B\,(L_z + g_s S_z)$ in the $|m_l, m_s\rangle$ basis.
47
+
48
+ ``xi`` and ``mu_b * B`` share whatever energy unit you choose.
49
+ """
50
+ Lx, Ly, Lz = angular_momentum(l)
51
+ Sx, Sy, Sz = angular_momentum(s)
52
+ il, is_ = np.eye(Lz.shape[0]), np.eye(Sz.shape[0])
53
+ LS = sum(np.kron(a, b) for a, b in ((Lx, Sx), (Ly, Sy), (Lz, Sz)))
54
+ return xi * LS + mu_b * B * (np.kron(Lz, is_) + gs * np.kron(il, Sz))
55
+
56
+
57
+ def zeeman_levels(l, xi, B_values, mu_b=1.0, gs=2.0):
58
+ """Sorted energy levels for each field in ``B_values``; shape ``(len(B_values), 2(2l+1))``."""
59
+ return np.array([np.linalg.eigvalsh(spin_orbit_zeeman_hamiltonian(l, xi, B, mu_b, gs))
60
+ for B in np.atleast_1d(B_values)])
61
+
62
+
63
+ def lande_g(l, j, s=0.5, gs=2.0):
64
+ r"""$g_J = 1 + (g_s - 1)\,\dfrac{j(j+1) + s(s+1) - l(l+1)}{2j(j+1)}$."""
65
+ return 1 + (gs - 1) * (j * (j + 1) + s * (s + 1) - l * (l + 1)) / (2 * j * (j + 1))
66
+
67
+
68
+ def spin_orbit_energy(l, j, xi, s=0.5):
69
+ r"""$\tfrac{\xi}{2}\,[\,j(j+1) - l(l+1) - s(s+1)\,]$, the eigenvalue of $\xi\,\mathbf L\cdot\mathbf S$."""
70
+ return xi / 2 * (j * (j + 1) - l * (l + 1) - s * (s + 1))
71
+
72
+
73
+ def hydrogen_fine_structure_eV(n, j):
74
+ r"""Hydrogen level $E_{nj}$ in eV including fine structure to order $\alpha^2$ (see module docstring)."""
75
+ return -RYDBERG_EV / n**2 * (1 + ALPHA**2 / n**2 * (n / (j + 0.5) - 0.75))
76
+
77
+
78
+ def dirac_energy_eV(n, j):
79
+ r"""Exact Dirac energy minus $m_ec^2$:
80
+ $E = m_ec^2\left[\left(1 + \dfrac{\alpha^2}{(n - \delta)^2}\right)^{-1/2} - 1\right]$,
81
+ $\delta = j + \tfrac12 - \sqrt{(j+\tfrac12)^2 - \alpha^2}$."""
82
+ mc2 = constants.physical_constants["electron mass energy equivalent in MeV"][0] * 1e6
83
+ k = j + 0.5
84
+ delta = k - np.sqrt(k**2 - ALPHA**2)
85
+ x = (ALPHA / (n - delta)) ** 2
86
+ return mc2 * (1 / np.sqrt(1 + x) - 1)
qplay/chaos.py ADDED
@@ -0,0 +1,83 @@
1
+ r"""Chaos and fractals: Lorenz system, logistic map, Mandelbrot and Julia sets.
2
+
3
+ Logistic map: $x_{n+1} = r x_n (1 - x_n)$.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+ from .integrators import solve
11
+
12
+
13
+ def lorenz(state0=(1.0, 1.0, 1.0), sigma=10.0, rho=28.0, beta=8 / 3, t_max=40.0, n=4000):
14
+ """Lorenz system. Returns ``(t, y)`` with columns ``x, y, z``."""
15
+ def rhs(_, s):
16
+ x, y, z = s
17
+ return [sigma * (y - x), x * (rho - z) - y, x * y - beta * z]
18
+
19
+ return solve(rhs, state0, (0, t_max), n, rtol=1e-9, atol=1e-11)
20
+
21
+
22
+ def logistic_map(r, x0=0.5, n=100):
23
+ """First ``n`` iterates (starting with ``x0``) of the logistic map."""
24
+ x = np.empty(n)
25
+ x[0] = x0
26
+ for i in range(n - 1):
27
+ x[i + 1] = r * x[i] * (1 - x[i])
28
+ return x
29
+
30
+
31
+ def bifurcation(r_values, n_transient=500, n_keep=100, x0=0.5):
32
+ """Attractor of the logistic map for each ``r``. Returns flat arrays ``(r, x)`` for scatter plots."""
33
+ r_values = np.asarray(r_values, float)
34
+ x = np.full_like(r_values, x0)
35
+ for _ in range(n_transient):
36
+ x = r_values * x * (1 - x)
37
+ rs, xs = [], []
38
+ for _ in range(n_keep):
39
+ x = r_values * x * (1 - x)
40
+ rs.append(r_values.copy())
41
+ xs.append(x.copy())
42
+ return np.concatenate(rs), np.concatenate(xs)
43
+
44
+
45
+ def lyapunov_logistic(r, n=20000, x0=0.4, n_transient=200):
46
+ r"""Lyapunov exponent $\lambda=\langle\ln|r(1-2x)|\rangle$; positive means chaos."""
47
+ x = x0
48
+ for _ in range(n_transient):
49
+ x = r * x * (1 - x)
50
+ total = 0.0
51
+ for _ in range(n):
52
+ x = r * x * (1 - x)
53
+ total += np.log(abs(r * (1 - 2 * x)) + 1e-300)
54
+ return total / n
55
+
56
+
57
+ def escape_time(c, max_iter=100, z0=0.0):
58
+ """Iterations of $z\\to z^2+c$ before $|z|>2$ (``max_iter`` if it never escapes)."""
59
+ c = np.asarray(c, complex)
60
+ z = np.broadcast_to(np.asarray(z0, complex), c.shape).copy()
61
+ count = np.full(c.shape, max_iter, dtype=int)
62
+ alive = np.ones(c.shape, dtype=bool)
63
+ for i in range(max_iter):
64
+ z[alive] = z[alive] ** 2 + c[alive]
65
+ escaped = alive & (np.abs(z) > 2)
66
+ count[escaped] = i
67
+ alive &= ~escaped
68
+ return count
69
+
70
+
71
+ def _grid(xlim, ylim, width, height):
72
+ X, Y = np.meshgrid(np.linspace(*xlim, width), np.linspace(*ylim, height))
73
+ return X + 1j * Y
74
+
75
+
76
+ def mandelbrot(xlim=(-2.2, 0.8), ylim=(-1.2, 1.2), width=600, height=400, max_iter=100):
77
+ return escape_time(_grid(xlim, ylim, width, height), max_iter)
78
+
79
+
80
+ def julia(c, xlim=(-1.6, 1.6), ylim=(-1.2, 1.2), width=600, height=400, max_iter=100):
81
+ """Julia set for $z\\to z^2 + c$: the grid supplies the starting points $z_0$."""
82
+ z0 = _grid(xlim, ylim, width, height)
83
+ return escape_time(np.full(z0.shape, c, dtype=complex), max_iter, z0=z0)
qplay/circuits.py ADDED
@@ -0,0 +1,75 @@
1
+ r"""Electric circuits: capacitor charge and discharge, and the series RLC circuit.
2
+
3
+ Series RLC with source $V(t)$ (charge $q$ on the capacitor, current $I = \dot q$):
4
+
5
+ $$L\ddot q + R\dot q + \frac{q}{C} = V(t).$$
6
+
7
+ With $L = 0$ this is the RC circuit, time constant $\tau = RC$. The undamped resonant angular
8
+ frequency is $\omega_0 = 1/\sqrt{LC}$, the quality factor $Q = \frac1R\sqrt{L/C}$, and for a drive
9
+ $V_0\cos\omega t$ the steady current amplitude is $V_0/|Z|$ with
10
+ $Z = R + i\left(\omega L - \frac{1}{\omega C}\right)$.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+ from .integrators import solve
18
+
19
+
20
+ def _source(V):
21
+ return V if callable(V) else (lambda t, V=float(V): V)
22
+
23
+
24
+ def rc_discharge(t, V0, R, C):
25
+ r"""Capacitor voltage while discharging: $V = V_0 e^{-t/RC}$."""
26
+ return V0 * np.exp(-np.asarray(t, float) / (R * C))
27
+
28
+
29
+ def rc_charge(t, V0, R, C):
30
+ r"""Capacitor voltage while charging from a supply $V_0$: $V = V_0(1 - e^{-t/RC})$."""
31
+ return V0 * -np.expm1(-np.asarray(t, float) / (R * C))
32
+
33
+
34
+ def rc_circuit(R, C, V=0.0, q0=0.0, t_max=1.0, n=500):
35
+ r"""Integrate $R\dot q + q/C = V(t)$. ``V`` is a number or a function of $t$.
36
+
37
+ Returns ``(t, q, I)``.
38
+ """
39
+ src = _source(V)
40
+ t, y = solve(lambda t, q: [(src(t) - q[0] / C) / R], [q0], (0, t_max), n)
41
+ q = y[:, 0]
42
+ return t, q, (np.array([src(ti) for ti in t]) - q / C) / R
43
+
44
+
45
+ def rlc_series(R, L, C, V=0.0, q0=0.0, i0=0.0, t_max=1.0, n=1000):
46
+ r"""Integrate $L\ddot q + R\dot q + q/C = V(t)$. Returns ``(t, q, I)``."""
47
+ src = _source(V)
48
+
49
+ def rhs(t, y):
50
+ q, i = y
51
+ return [i, (src(t) - R * i - q / C) / L]
52
+
53
+ t, y = solve(rhs, [q0, i0], (0, t_max), n)
54
+ return t, y[:, 0], y[:, 1]
55
+
56
+
57
+ def resonant_frequency(L, C):
58
+ r"""$\omega_0 = 1/\sqrt{LC}$ in rad/s."""
59
+ return 1 / np.sqrt(L * C)
60
+
61
+
62
+ def quality_factor(R, L, C):
63
+ r"""$Q = \dfrac{1}{R}\sqrt{\dfrac{L}{C}}$; the resonance full width at half power is $\omega_0/Q = R/L$."""
64
+ return np.sqrt(L / C) / R
65
+
66
+
67
+ def impedance(omega, R, L, C):
68
+ r"""Complex impedance $Z = R + i(\omega L - 1/\omega C)$ of the series circuit."""
69
+ omega = np.asarray(omega, float)
70
+ return R + 1j * (omega * L - 1 / (omega * C))
71
+
72
+
73
+ def current_amplitude(omega, V0, R, L, C):
74
+ r"""Steady-state current amplitude $I_0 = V_0/|Z|$ for a drive $V_0\cos\omega t$."""
75
+ return V0 / np.abs(impedance(omega, R, L, C))
qplay/complexplane.py ADDED
@@ -0,0 +1,41 @@
1
+ r"""Complex functions: domain colouring and the argument principle.
2
+
3
+ Argument principle: $\frac{1}{2\pi i}\oint_C \frac{f'(z)}{f(z)}\,dz = N_{\text{zeros}} - N_{\text{poles}}$ inside $C$,
4
+ which equals the number of times $f(C)$ winds around $0$.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from matplotlib.colors import hsv_to_rgb
11
+
12
+
13
+ def complex_grid(xlim=(-2, 2), ylim=(-2, 2), n=500):
14
+ X, Y = np.meshgrid(np.linspace(*xlim, n), np.linspace(*ylim, n))
15
+ return X + 1j * Y
16
+
17
+
18
+ def domain_coloring(f, xlim=(-2, 2), ylim=(-2, 2), n=500):
19
+ """RGB image of ``f``: hue shows $\\arg f$ (red = positive real), brightness bands show $\\log_2|f|$.
20
+
21
+ Returns an array of shape ``(n, n, 3)`` with row 0 at ``ylim[0]`` (use ``origin="lower"``).
22
+ """
23
+ with np.errstate(all="ignore"):
24
+ w = f(complex_grid(xlim, ylim, n))
25
+ hue = (np.angle(w) / (2 * np.pi)) % 1.0
26
+ bands = np.log2(np.abs(w)) % 1.0
27
+ bad = ~np.isfinite(w)
28
+ value = np.where(bad, 1.0, 0.55 + 0.45 * np.nan_to_num(bands))
29
+ sat = np.where(bad, 0.0, 0.85)
30
+ return hsv_to_rgb(np.stack([np.nan_to_num(hue), sat, value], axis=-1))
31
+
32
+
33
+ def winding_number(f, center=0.0, radius=1.0, n=4000):
34
+ """How many times ``f`` maps the circle ``|z - center| = radius`` around the origin.
35
+
36
+ By the argument principle this is (zeros - poles) inside the circle, counted with multiplicity.
37
+ The circle must not pass through a zero or pole.
38
+ """
39
+ theta = np.linspace(0, 2 * np.pi, n + 1)
40
+ w = f(center + radius * np.exp(1j * theta))
41
+ return int(round(np.sum(np.diff(np.unwrap(np.angle(w)))) / (2 * np.pi)))