You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1655 lines
68 KiB
1655 lines
68 KiB
"""
|
|
Tesla Coil Spark Course - Image Generation Script
|
|
|
|
Generates all course images programmatically using matplotlib, schemdraw, and numpy.
|
|
Run this script from the spark-lessons directory.
|
|
|
|
Usage:
|
|
python generate_images.py # Generate all images
|
|
python generate_images.py --part 1 # Generate Part 1 images only
|
|
python generate_images.py --category graphs # Generate only graphs
|
|
"""
|
|
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.patches as patches
|
|
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch, Circle, Rectangle, Wedge
|
|
import numpy as np
|
|
import schemdraw
|
|
import schemdraw.elements as elm
|
|
from pathlib import Path
|
|
import argparse
|
|
|
|
# ============================================================================
|
|
# CONFIGURATION
|
|
# ============================================================================
|
|
|
|
# Common styling
|
|
STYLE = {
|
|
'dpi': 150,
|
|
'figure_facecolor': 'white',
|
|
'text_color': '#2C3E50',
|
|
'primary_color': '#3498DB', # Blue
|
|
'secondary_color': '#E74C3C', # Red
|
|
'accent_color': '#2ECC71', # Green
|
|
'warning_color': '#F39C12', # Orange
|
|
'grid_alpha': 0.3,
|
|
'font_family': 'sans-serif',
|
|
'title_size': 14,
|
|
'label_size': 12,
|
|
'tick_size': 10,
|
|
'legend_size': 10,
|
|
}
|
|
|
|
# Directories
|
|
BASE_DIR = Path(__file__).parent
|
|
ASSETS_DIRS = {
|
|
'fundamentals': BASE_DIR / 'lessons' / '01-fundamentals' / 'assets',
|
|
'optimization': BASE_DIR / 'lessons' / '02-optimization' / 'assets',
|
|
'spark-physics': BASE_DIR / 'lessons' / '03-spark-physics' / 'assets',
|
|
'advanced-modeling': BASE_DIR / 'lessons' / '04-advanced-modeling' / 'assets',
|
|
'shared': BASE_DIR / 'assets' / 'shared',
|
|
}
|
|
|
|
# Create directories if they don't exist
|
|
for dir_path in ASSETS_DIRS.values():
|
|
dir_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ============================================================================
|
|
# UTILITY FUNCTIONS
|
|
# ============================================================================
|
|
|
|
def set_style(ax):
|
|
"""Apply common styling to a matplotlib axis"""
|
|
ax.spines['top'].set_visible(False)
|
|
ax.spines['right'].set_visible(False)
|
|
ax.tick_params(labelsize=STYLE['tick_size'])
|
|
ax.grid(True, alpha=STYLE['grid_alpha'], linestyle='--')
|
|
|
|
|
|
def save_figure(fig, filename, directory='fundamentals'):
|
|
"""Save figure to appropriate directory"""
|
|
filepath = ASSETS_DIRS[directory] / filename
|
|
fig.savefig(filepath, dpi=STYLE['dpi'], bbox_inches='tight', facecolor='white')
|
|
plt.close(fig)
|
|
print(f"[OK] Generated: {filepath}")
|
|
|
|
|
|
# ============================================================================
|
|
# PART 1: FUNDAMENTALS IMAGES
|
|
# ============================================================================
|
|
|
|
def generate_complex_plane_admittance():
|
|
"""Image 3: Complex plane showing Y and Z phasors"""
|
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
|
|
|
|
# Left: Admittance plane
|
|
Y_real = 10 # mS
|
|
Y_imag = 15 # mS
|
|
|
|
ax1.arrow(0, 0, Y_real, 0, head_width=1, head_length=0.8, fc=STYLE['primary_color'], ec=STYLE['primary_color'], linewidth=2)
|
|
ax1.arrow(0, 0, 0, Y_imag, head_width=0.8, head_length=1, fc=STYLE['secondary_color'], ec=STYLE['secondary_color'], linewidth=2)
|
|
ax1.arrow(0, 0, Y_real, Y_imag, head_width=1, head_length=1, fc='black', ec='black', linewidth=2.5)
|
|
|
|
ax1.text(Y_real/2, -2, 'Re{Y} = G\n(Conductance)', ha='center', fontsize=STYLE['label_size'], color=STYLE['primary_color'])
|
|
ax1.text(-2, Y_imag/2, 'Im{Y} = B\n(Susceptance)', ha='center', fontsize=STYLE['label_size'], color=STYLE['secondary_color'], rotation=90)
|
|
ax1.text(Y_real/2 + 1, Y_imag/2 + 1, f'Y = {Y_real}+j{Y_imag} mS', fontsize=STYLE['label_size'], fontweight='bold')
|
|
|
|
# Draw angle
|
|
angle = np.arctan2(Y_imag, Y_real)
|
|
arc = Wedge((0, 0), 3, 0, np.degrees(angle), facecolor='yellow', alpha=0.3, edgecolor='black')
|
|
ax1.add_patch(arc)
|
|
ax1.text(4, 2, f'θ_Y = {np.degrees(angle):.1f}°', fontsize=STYLE['label_size'])
|
|
|
|
ax1.set_xlim(-5, 20)
|
|
ax1.set_ylim(-5, 20)
|
|
ax1.set_xlabel('Real Axis (mS)', fontsize=STYLE['label_size'])
|
|
ax1.set_ylabel('Imaginary Axis (mS)', fontsize=STYLE['label_size'])
|
|
ax1.set_title('Admittance (Y) Plane', fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax1.axhline(y=0, color='k', linewidth=0.5)
|
|
ax1.axvline(x=0, color='k', linewidth=0.5)
|
|
ax1.grid(True, alpha=STYLE['grid_alpha'])
|
|
ax1.set_aspect('equal')
|
|
|
|
# Right: Impedance plane
|
|
Z_real = 30 # Ω
|
|
Z_imag = -45 # Ω (capacitive)
|
|
|
|
ax2.arrow(0, 0, Z_real, 0, head_width=3, head_length=2, fc=STYLE['primary_color'], ec=STYLE['primary_color'], linewidth=2)
|
|
ax2.arrow(0, 0, 0, Z_imag, head_width=2, head_length=3, fc=STYLE['secondary_color'], ec=STYLE['secondary_color'], linewidth=2)
|
|
ax2.arrow(0, 0, Z_real, Z_imag, head_width=3, head_length=3, fc='black', ec='black', linewidth=2.5)
|
|
|
|
ax2.text(Z_real/2, 5, 'Re{Z} = R\n(Resistance)', ha='center', fontsize=STYLE['label_size'], color=STYLE['primary_color'])
|
|
ax2.text(-5, Z_imag/2, 'Im{Z} = X\n(Reactance)', ha='center', fontsize=STYLE['label_size'], color=STYLE['secondary_color'], rotation=90)
|
|
ax2.text(Z_real/2 + 3, Z_imag/2 - 5, f'Z = {Z_real}{Z_imag:+}j Ω', fontsize=STYLE['label_size'], fontweight='bold')
|
|
|
|
# Draw angle
|
|
angle_Z = np.arctan2(Z_imag, Z_real)
|
|
arc2 = Wedge((0, 0), 8, np.degrees(angle_Z), 0, facecolor='lightblue', alpha=0.3, edgecolor='black')
|
|
ax2.add_patch(arc2)
|
|
ax2.text(10, -8, f'φ_Z = {np.degrees(angle_Z):.1f}°', fontsize=STYLE['label_size'])
|
|
|
|
ax2.text(Z_real/2, Z_imag - 10, 'Capacitive\n(φ_Z < 0)', ha='center', fontsize=10, style='italic', color=STYLE['secondary_color'])
|
|
|
|
ax2.set_xlim(-15, 50)
|
|
ax2.set_ylim(-60, 15)
|
|
ax2.set_xlabel('Real Axis (Ω)', fontsize=STYLE['label_size'])
|
|
ax2.set_ylabel('Imaginary Axis (Ω)', fontsize=STYLE['label_size'])
|
|
ax2.set_title('Impedance (Z) Plane', fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax2.axhline(y=0, color='k', linewidth=0.5)
|
|
ax2.axvline(x=0, color='k', linewidth=0.5)
|
|
ax2.grid(True, alpha=STYLE['grid_alpha'])
|
|
ax2.set_aspect('equal')
|
|
|
|
fig.suptitle('Complex Plane Representation: Admittance vs Impedance\nNote: φ_Z = -θ_Y',
|
|
fontsize=STYLE['title_size']+2, fontweight='bold', y=1.02)
|
|
|
|
save_figure(fig, 'complex-plane-admittance.png', 'fundamentals')
|
|
|
|
|
|
def generate_phase_angle_visualization():
|
|
"""Image 4: Impedance phasors showing different phase angles"""
|
|
fig, ax = plt.subplots(figsize=(10, 8))
|
|
|
|
# Define impedances with different phase angles
|
|
impedances = [
|
|
(100, 0, '0°', 'Pure Resistive', STYLE['primary_color']),
|
|
(100, -58, '-30°', 'Slightly Capacitive', STYLE['accent_color']),
|
|
(71, -71, '-45°', 'Balanced (often unachievable)', STYLE['warning_color']),
|
|
(50, -87, '-60°', 'More Capacitive', 'purple'),
|
|
(26, -97, '-75°', 'Highly Capacitive (typical spark)', STYLE['secondary_color']),
|
|
]
|
|
|
|
for Z_real, Z_imag, angle_label, description, color in impedances:
|
|
# Draw phasor
|
|
ax.arrow(0, 0, Z_real, Z_imag, head_width=5, head_length=4,
|
|
fc=color, ec=color, linewidth=2, alpha=0.7, length_includes_head=True)
|
|
|
|
# Label
|
|
offset_x = 5 if Z_real > 50 else -15
|
|
offset_y = -5
|
|
ax.text(Z_real + offset_x, Z_imag + offset_y,
|
|
f'{angle_label}\n{description}',
|
|
fontsize=9, ha='left' if Z_real > 50 else 'right', color=color, fontweight='bold')
|
|
|
|
# Power factor
|
|
pf = Z_real / np.sqrt(Z_real**2 + Z_imag**2)
|
|
ax.text(Z_real + offset_x, Z_imag + offset_y - 8,
|
|
f'PF = {pf:.3f}',
|
|
fontsize=8, ha='left' if Z_real > 50 else 'right', color=color, style='italic')
|
|
|
|
# Highlight typical spark range
|
|
theta1 = np.radians(-75)
|
|
theta2 = np.radians(-55)
|
|
r = 110
|
|
angles = np.linspace(theta1, theta2, 50)
|
|
x = r * np.cos(angles)
|
|
y = r * np.sin(angles)
|
|
ax.fill(np.concatenate([[0], x, [0]]), np.concatenate([[0], y, [0]]),
|
|
alpha=0.2, color='lightcoral', label='Typical Tesla Coil Spark Range')
|
|
|
|
# Add note about -45°
|
|
ax.annotate('Often mathematically\nimpossible for sparks!',
|
|
xy=(71, -71), xytext=(80, -40),
|
|
arrowprops=dict(arrowstyle='->', color='red', lw=2),
|
|
fontsize=10, color='red', fontweight='bold',
|
|
bbox=dict(boxstyle='round,pad=0.5', facecolor='yellow', alpha=0.7))
|
|
|
|
ax.set_xlim(-20, 120)
|
|
ax.set_ylim(-110, 20)
|
|
ax.set_xlabel('Re{Z} = Resistance (kΩ)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Im{Z} = Reactance (kΩ)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Impedance Phase Angles and Their Physical Meanings',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.axhline(y=0, color='k', linewidth=0.8)
|
|
ax.axvline(x=0, color='k', linewidth=0.8)
|
|
ax.grid(True, alpha=STYLE['grid_alpha'])
|
|
ax.legend(loc='upper right', fontsize=STYLE['legend_size'])
|
|
ax.set_aspect('equal')
|
|
|
|
save_figure(fig, 'phase-angle-visualization.png', 'fundamentals')
|
|
|
|
|
|
def generate_phase_constraint_graph():
|
|
"""Image 5: Graph of minimum achievable phase angle vs capacitance ratio"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
# Calculate φ_Z,min vs r
|
|
r = np.linspace(0, 3, 300)
|
|
phi_Z_min = -np.degrees(np.arctan(2 * np.sqrt(r * (1 + r))))
|
|
|
|
# Plot curve
|
|
ax.plot(r, phi_Z_min, linewidth=3, color=STYLE['primary_color'], label='φ_Z,min = -atan(2√[r(1+r)])')
|
|
|
|
# Mark critical point r = 0.207
|
|
r_crit = 0.207
|
|
phi_crit = -np.degrees(np.arctan(2 * np.sqrt(r_crit * (1 + r_crit))))
|
|
ax.plot(r_crit, phi_crit, 'ro', markersize=12, label=f'Critical: r = {r_crit:.3f}, φ_Z,min = -45°')
|
|
|
|
# Shade impossible region
|
|
ax.fill_between(r, phi_Z_min, -45, alpha=0.3, color='red', label='Impossible Region')
|
|
|
|
# Add -45° line
|
|
ax.axhline(y=-45, color='green', linestyle='--', linewidth=2, label='Traditional "Matched" Target (-45°)')
|
|
|
|
# Shade typical Tesla coil region
|
|
ax.axvspan(0.5, 2.0, alpha=0.2, color='yellow', label='Typical Tesla Coil Range')
|
|
|
|
# Annotations for geometric examples
|
|
examples = [
|
|
(0.5, 'Short topload,\nlong spark'),
|
|
(1.0, 'Balanced\ngeometry'),
|
|
(2.0, 'Large topload,\nshort spark'),
|
|
]
|
|
|
|
for r_ex, text in examples:
|
|
phi_ex = -np.degrees(np.arctan(2 * np.sqrt(r_ex * (1 + r_ex))))
|
|
ax.plot(r_ex, phi_ex, 'ks', markersize=8)
|
|
ax.annotate(text, xy=(r_ex, phi_ex), xytext=(r_ex + 0.3, phi_ex - 5),
|
|
fontsize=9, ha='left',
|
|
arrowprops=dict(arrowstyle='->', color='black', lw=1))
|
|
|
|
ax.set_xlim(0, 3)
|
|
ax.set_ylim(-90, 0)
|
|
ax.set_xlabel('r = C_mut / C_sh', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Minimum Impedance Phase φ_Z,min (degrees)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Topological Phase Constraint: Minimum Achievable Phase Angle',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.grid(True, alpha=STYLE['grid_alpha'])
|
|
ax.legend(loc='lower left', fontsize=STYLE['legend_size'] - 1)
|
|
|
|
# Add text box with key insight
|
|
textstr = 'Key Insight:\nWhen r ≥ 0.207, achieving -45° is\nmathematically impossible regardless\nof resistance value!'
|
|
props = dict(boxstyle='round', facecolor='wheat', alpha=0.8)
|
|
ax.text(2.3, -20, textstr, fontsize=10, verticalalignment='top', bbox=props)
|
|
|
|
save_figure(fig, 'phase-constraint-graph.png', 'fundamentals')
|
|
|
|
|
|
def generate_admittance_vector_addition():
|
|
"""Image 7: Vector diagram showing parallel admittance addition"""
|
|
fig, ax = plt.subplots(figsize=(8, 8))
|
|
|
|
# Branch 1: Y1 = G + jB1
|
|
G = 8
|
|
B1 = 12
|
|
ax.arrow(0, 0, G, B1, head_width=0.8, head_length=0.6,
|
|
fc=STYLE['primary_color'], ec=STYLE['primary_color'], linewidth=2.5, label='Y₁ = G + jB₁')
|
|
ax.text(G/2 - 2, B1/2 + 1, 'Y₁ = G + jB₁\n(R || C_mut)', fontsize=11, color=STYLE['primary_color'], fontweight='bold')
|
|
|
|
# Branch 2: Y2 = jB2
|
|
B2 = 6
|
|
ax.arrow(0, 0, 0, B2, head_width=0.6, head_length=0.5,
|
|
fc=STYLE['secondary_color'], ec=STYLE['secondary_color'], linewidth=2.5, label='Y₂ = jB₂')
|
|
ax.text(1, B2/2, 'Y₂ = jB₂\n(C_sh)', fontsize=11, color=STYLE['secondary_color'], fontweight='bold')
|
|
|
|
# Total: Y_total = Y1 + Y2
|
|
Y_total_real = G
|
|
Y_total_imag = B1 + B2
|
|
ax.arrow(0, 0, Y_total_real, Y_total_imag, head_width=1, head_length=0.8,
|
|
fc='black', ec='black', linewidth=3, label='Y_total = Y₁ + Y₂', zorder=10)
|
|
ax.text(Y_total_real/2 + 2, Y_total_imag/2, 'Y_total\n= G + j(B₁+B₂)',
|
|
fontsize=12, fontweight='bold', bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.7))
|
|
|
|
# Draw parallelogram construction lines
|
|
ax.plot([G, G], [B1, Y_total_imag], 'k--', alpha=0.4, linewidth=1)
|
|
ax.plot([0, G], [B2, Y_total_imag], 'k--', alpha=0.4, linewidth=1)
|
|
|
|
# Draw components
|
|
ax.plot([0, G], [0, 0], 'b:', linewidth=2, alpha=0.5)
|
|
ax.text(G/2, -0.8, 'G (conductance)', fontsize=10, ha='center', color='blue')
|
|
|
|
ax.plot([0, 0], [0, B1], 'r:', linewidth=2, alpha=0.5)
|
|
ax.text(-1.2, B1/2, 'B₁', fontsize=10, ha='center', color='red')
|
|
|
|
ax.plot([0, 0], [B1, Y_total_imag], 'r:', linewidth=2, alpha=0.5)
|
|
ax.text(-1.2, (B1 + Y_total_imag)/2, 'B₂', fontsize=10, ha='center', color='red')
|
|
|
|
ax.set_xlim(-3, 15)
|
|
ax.set_ylim(-2, 22)
|
|
ax.set_xlabel('Real Part: G = 1/R (mS)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Imaginary Part: B = ωC (mS)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Parallel Admittance Addition (Vector/Phasor Method)',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.axhline(y=0, color='k', linewidth=0.8)
|
|
ax.axvline(x=0, color='k', linewidth=0.8)
|
|
ax.grid(True, alpha=STYLE['grid_alpha'])
|
|
ax.legend(loc='upper left', fontsize=STYLE['legend_size'])
|
|
ax.set_aspect('equal')
|
|
|
|
# Add formula
|
|
formula_text = 'For parallel branches:\nY_total = Y₁ + Y₂ + Y₃ + ...\n(Admittances add directly!)'
|
|
ax.text(10, 3, formula_text, fontsize=10,
|
|
bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.7))
|
|
|
|
save_figure(fig, 'admittance-vector-addition.png', 'fundamentals')
|
|
|
|
|
|
# ============================================================================
|
|
# PART 2: OPTIMIZATION IMAGES
|
|
# ============================================================================
|
|
|
|
def generate_power_vs_resistance_curves():
|
|
"""Image 9: Graph of power delivered vs resistance"""
|
|
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 10), sharex=True)
|
|
|
|
# Parameters
|
|
f = 200e3 # 200 kHz
|
|
omega = 2 * np.pi * f
|
|
C_mut = 8e-12 # 8 pF
|
|
C_sh = 4e-12 # 4 pF
|
|
C_total = C_mut + C_sh
|
|
V_top = 350e3 # 350 kV
|
|
|
|
# Calculate optimal resistances
|
|
R_opt_power = 1 / (omega * C_total)
|
|
R_opt_phase = 1 / (omega * np.sqrt(C_mut * (C_mut + C_sh)))
|
|
|
|
# Resistance range
|
|
R = np.logspace(3, 8, 500) # 1 kΩ to 100 MΩ
|
|
|
|
# Calculate power (simplified - assumes fixed V_top)
|
|
# P ≈ 0.5 * V² * Re{Y}
|
|
G = 1 / R
|
|
B1 = omega * C_mut
|
|
B2 = omega * C_sh
|
|
|
|
# Re{Y} = G * B2² / (G² + (B1 + B2)²)
|
|
Re_Y = G * B2**2 / (G**2 + (B1 + B2)**2)
|
|
P = 0.5 * V_top**2 * Re_Y / 1000 # Convert to kW
|
|
|
|
# Calculate phase angle
|
|
Im_Y = B2 * (G**2 + B1*(B1 + B2)) / (G**2 + (B1 + B2)**2)
|
|
phi_Z = -np.degrees(np.arctan(Im_Y / Re_Y))
|
|
|
|
# Plot 1: Power vs Resistance
|
|
ax1.semilogx(R/1000, P, linewidth=3, color=STYLE['primary_color'], label='Power Delivered')
|
|
ax1.axvline(x=R_opt_power/1000, color='red', linestyle='--', linewidth=2,
|
|
label=f'R_opt_power = {R_opt_power/1000:.1f} kΩ')
|
|
ax1.axvline(x=R_opt_phase/1000, color='green', linestyle='--', linewidth=2,
|
|
label=f'R_opt_phase = {R_opt_phase/1000:.1f} kΩ')
|
|
|
|
# Mark maximum
|
|
max_idx = np.argmax(P)
|
|
ax1.plot(R[max_idx]/1000, P[max_idx], 'ro', markersize=12, zorder=10)
|
|
ax1.annotate(f'Maximum Power\n{P[max_idx]:.1f} kW',
|
|
xy=(R[max_idx]/1000, P[max_idx]),
|
|
xytext=(R[max_idx]/5000, P[max_idx]*0.8),
|
|
fontsize=11, fontweight='bold',
|
|
arrowprops=dict(arrowstyle='->', color='red', lw=2),
|
|
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.8))
|
|
|
|
ax1.set_ylabel('Power Delivered (kW)', fontsize=STYLE['label_size'])
|
|
ax1.set_title('Power Transfer vs Spark Resistance',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax1.grid(True, alpha=STYLE['grid_alpha'], which='both')
|
|
ax1.legend(loc='upper right', fontsize=STYLE['legend_size'])
|
|
set_style(ax1)
|
|
|
|
# Plot 2: Phase angle vs Resistance
|
|
ax2.semilogx(R/1000, phi_Z, linewidth=3, color=STYLE['secondary_color'], label='Impedance Phase φ_Z')
|
|
ax2.axvline(x=R_opt_power/1000, color='red', linestyle='--', linewidth=2, alpha=0.7)
|
|
ax2.axvline(x=R_opt_phase/1000, color='green', linestyle='--', linewidth=2, alpha=0.7)
|
|
ax2.axhline(y=-45, color='orange', linestyle=':', linewidth=2, label='-45° Reference')
|
|
|
|
# Find minimum phase magnitude
|
|
min_phase_idx = np.argmax(phi_Z) # Closest to 0 (least negative)
|
|
ax2.plot(R[min_phase_idx]/1000, phi_Z[min_phase_idx], 'gs', markersize=10, zorder=10)
|
|
ax2.annotate(f'Minimum |φ_Z|\n{phi_Z[min_phase_idx]:.1f}°',
|
|
xy=(R[min_phase_idx]/1000, phi_Z[min_phase_idx]),
|
|
xytext=(R[min_phase_idx]*2/1000, phi_Z[min_phase_idx] + 5),
|
|
fontsize=10, fontweight='bold',
|
|
arrowprops=dict(arrowstyle='->', color='green', lw=2))
|
|
|
|
# Shade typical range
|
|
ax2.axhspan(-75, -55, alpha=0.2, color='lightcoral', label='Typical Spark Range')
|
|
|
|
ax2.set_xlabel('Spark Resistance R (kΩ)', fontsize=STYLE['label_size'])
|
|
ax2.set_ylabel('Impedance Phase φ_Z (degrees)', fontsize=STYLE['label_size'])
|
|
ax2.set_title('Impedance Phase vs Spark Resistance',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax2.grid(True, alpha=STYLE['grid_alpha'], which='both')
|
|
ax2.legend(loc='lower right', fontsize=STYLE['legend_size'])
|
|
set_style(ax2)
|
|
|
|
fig.suptitle(f'Power and Phase Optimization (f = {f/1000:.0f} kHz, C_total = {C_total*1e12:.0f} pF)',
|
|
fontsize=STYLE['title_size']+2, fontweight='bold', y=0.995)
|
|
|
|
save_figure(fig, 'power-vs-resistance-curves.png', 'optimization')
|
|
|
|
|
|
def generate_frequency_shift_with_loading():
|
|
"""Image 13: Graph showing resonant frequency shift as spark grows"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
# Spark length
|
|
L_spark = np.linspace(0, 3, 100) # 0 to 3 meters
|
|
|
|
# C_sh increases with length (~6.6 pF/m = 2 pF/foot)
|
|
C_sh_0 = 3e-12 # Base capacitance (3 pF)
|
|
C_sh_per_meter = 6.6e-12 # pF per meter
|
|
C_sh = C_sh_0 + C_sh_per_meter * L_spark
|
|
|
|
# Base parameters
|
|
L_secondary = 50e-3 # 50 mH
|
|
C_topload = 15e-12 # 15 pF
|
|
k = 0.15 # Coupling coefficient
|
|
|
|
# Unloaded resonance
|
|
f0 = 1 / (2 * np.pi * np.sqrt(L_secondary * C_topload)) / 1000 # kHz
|
|
|
|
# Loaded resonance (simplified - just secondary with increased capacitance)
|
|
C_loaded = C_topload + C_sh
|
|
f_loaded = 1 / (2 * np.pi * np.sqrt(L_secondary * C_loaded)) / 1000 # kHz
|
|
|
|
# Coupled system poles (simplified)
|
|
# Lower pole shifts down more, upper pole shifts up slightly
|
|
f_lower = f_loaded * (1 - k/2)
|
|
f_upper = f_loaded * (1 + k/2)
|
|
|
|
# Plot
|
|
ax.plot(L_spark, f_loaded, linewidth=3, color=STYLE['primary_color'], label='Loaded Resonance (secondary)')
|
|
ax.plot(L_spark, f_lower, linewidth=2.5, color=STYLE['secondary_color'], label='Lower Pole (coupled system)')
|
|
ax.plot(L_spark, f_upper, linewidth=2.5, color=STYLE['accent_color'], label='Upper Pole (coupled system)')
|
|
ax.axhline(y=f0, color='black', linestyle='--', linewidth=2, label=f'Unloaded f₀ = {f0:.1f} kHz')
|
|
|
|
# Annotations
|
|
ax.annotate('',
|
|
xy=(2.5, f_loaded[-1]), xytext=(2.5, f0),
|
|
arrowprops=dict(arrowstyle='<->', color='red', lw=2))
|
|
ax.text(2.6, (f0 + f_loaded[-1])/2,
|
|
f'Shift:\n{f0 - f_loaded[-1]:.1f} kHz\n({(f0 - f_loaded[-1])/f0*100:.1f}%)',
|
|
fontsize=10, fontweight='bold', color='red')
|
|
|
|
# Mark typical operating point
|
|
L_typical = 2.0
|
|
idx_typical = np.argmin(np.abs(L_spark - L_typical))
|
|
ax.plot(L_typical, f_lower[idx_typical], 'rs', markersize=12)
|
|
ax.annotate('Typical operating\npoint (2 m spark)',
|
|
xy=(L_typical, f_lower[idx_typical]),
|
|
xytext=(L_typical - 0.8, f_lower[idx_typical] + 5),
|
|
fontsize=10,
|
|
arrowprops=dict(arrowstyle='->', color='black', lw=1.5),
|
|
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.7))
|
|
|
|
# Add C_sh growth annotation
|
|
ax2 = ax.twinx()
|
|
ax2.plot(L_spark, C_sh * 1e12, 'g--', linewidth=2, alpha=0.6, label='C_sh (pF)')
|
|
ax2.set_ylabel('C_sh (pF)', fontsize=STYLE['label_size'], color='green')
|
|
ax2.tick_params(axis='y', labelcolor='green')
|
|
|
|
ax.set_xlabel('Spark Length (meters)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Frequency (kHz)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Resonant Frequency Shift with Spark Loading\n(C_sh ≈ 6.6 pF/m = 2 pF/foot)',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.grid(True, alpha=STYLE['grid_alpha'])
|
|
ax.legend(loc='upper right', fontsize=STYLE['legend_size'])
|
|
|
|
# Key insight box
|
|
textstr = 'Critical: For accurate power\nmeasurement, retune to loaded\npole for each spark length!'
|
|
props = dict(boxstyle='round', facecolor='wheat', alpha=0.9, edgecolor='red', linewidth=2)
|
|
ax.text(0.15, f0 - 3, textstr, fontsize=11, verticalalignment='top', bbox=props, fontweight='bold')
|
|
|
|
save_figure(fig, 'frequency-shift-with-loading.png', 'optimization')
|
|
|
|
|
|
# ============================================================================
|
|
# PART 3: SPARK PHYSICS IMAGES
|
|
# ============================================================================
|
|
|
|
def generate_energy_budget_breakdown():
|
|
"""Image 18: Pie chart showing energy distribution per meter"""
|
|
fig, ax = plt.subplots(figsize=(9, 9))
|
|
|
|
# Energy components (percentage)
|
|
labels = [
|
|
'Ionization Energy\n(40-50%)',
|
|
'Channel Heating\n(20-30%)',
|
|
'Radiation Losses\n(10-20%)',
|
|
'Shock Wave /\nAcoustic (5-10%)',
|
|
'Electrohydrodynamic\nWork (5-10%)'
|
|
]
|
|
sizes = [45, 25, 15, 8, 7]
|
|
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8']
|
|
explode = (0.05, 0, 0, 0, 0) # Emphasize ionization
|
|
|
|
wedges, texts, autotexts = ax.pie(sizes, explode=explode, labels=labels, colors=colors,
|
|
autopct='%1.1f%%', startangle=90, textprops={'fontsize': 11})
|
|
|
|
# Enhance text
|
|
for autotext in autotexts:
|
|
autotext.set_color('white')
|
|
autotext.set_fontweight('bold')
|
|
autotext.set_fontsize(12)
|
|
|
|
for text in texts:
|
|
text.set_fontsize(11)
|
|
text.set_fontweight('bold')
|
|
|
|
ax.set_title('Energy Distribution per Meter of Spark Growth\n(QCW Mode, ε ≈ 10 J/m)',
|
|
fontsize=STYLE['title_size']+1, fontweight='bold', pad=20)
|
|
|
|
# Add note
|
|
note = ('Theoretical minimum: ~0.5 J/m\n'
|
|
'Actual QCW: 5-15 J/m\n'
|
|
'Burst mode: 30-100+ J/m')
|
|
ax.text(1.4, -1.1, note, fontsize=10,
|
|
bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8),
|
|
verticalalignment='top')
|
|
|
|
ax.axis('equal')
|
|
|
|
save_figure(fig, 'energy-budget-breakdown.png', 'spark-physics')
|
|
|
|
|
|
def generate_thermal_diffusion_vs_diameter():
|
|
"""Image 20: Graph of thermal time constant vs channel diameter"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
# Channel diameter range
|
|
d = np.logspace(-5, -2, 300) # 10 μm to 10 mm
|
|
|
|
# Thermal diffusivity of air
|
|
alpha_thermal = 2e-5 # m²/s
|
|
|
|
# Thermal time constant: τ = d² / (4α)
|
|
tau = d**2 / (4 * alpha_thermal) * 1000 # Convert to ms
|
|
|
|
# Plot
|
|
ax.loglog(d * 1e6, tau, linewidth=3, color=STYLE['primary_color'], label='τ = d² / (4α)')
|
|
|
|
# Mark key points
|
|
points = [
|
|
(50e-6, 'Thin streamer\n(50 μm)', 'top'),
|
|
(100e-6, 'Typical streamer\n(100 μm)', 'top'),
|
|
(1e-3, 'Thin leader\n(1 mm)', 'bottom'),
|
|
(5e-3, 'Thick leader\n(5 mm)', 'bottom'),
|
|
]
|
|
|
|
for d_point, label, valign in points:
|
|
tau_point = d_point**2 / (4 * alpha_thermal) * 1000
|
|
ax.plot(d_point * 1e6, tau_point, 'ro', markersize=10)
|
|
|
|
y_offset = tau_point * 2.5 if valign == 'top' else tau_point / 2.5
|
|
ax.annotate(f'{label}\nτ ≈ {tau_point:.2f} ms',
|
|
xy=(d_point * 1e6, tau_point),
|
|
xytext=(d_point * 1e6, y_offset),
|
|
fontsize=9, ha='center',
|
|
arrowprops=dict(arrowstyle='->', color='black', lw=1),
|
|
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.7))
|
|
|
|
# Shade regimes
|
|
ax.axvspan(10, 200, alpha=0.2, color='purple', label='Streamer Regime')
|
|
ax.axvspan(500, 10000, alpha=0.2, color='orange', label='Leader Regime')
|
|
|
|
# Add convection note
|
|
textstr = ('Note: Observed persistence\nlonger than pure thermal\ndiffusion due to:\n'
|
|
'• Convection\n'
|
|
'• Ionization memory\n'
|
|
'• Buoyancy effects')
|
|
props = dict(boxstyle='round', facecolor='lightblue', alpha=0.8)
|
|
ax.text(30, 200, textstr, fontsize=10, verticalalignment='top', bbox=props)
|
|
|
|
ax.set_xlabel('Channel Diameter d (μm)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Thermal Time Constant τ (ms)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Thermal Diffusion Time vs Channel Diameter\n(α = 2×10⁻⁵ m²/s for air)',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.grid(True, alpha=STYLE['grid_alpha'], which='both')
|
|
ax.legend(loc='upper left', fontsize=STYLE['legend_size'])
|
|
|
|
save_figure(fig, 'thermal-diffusion-vs-diameter.png', 'spark-physics')
|
|
|
|
|
|
def generate_voltage_division_vs_length_plot():
|
|
"""Image 24: Graph showing how V_tip decreases as spark grows"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
# Parameters
|
|
C_mut = 10e-12 # 10 pF
|
|
C_sh_per_meter = 6.6e-12 # 6.6 pF/m
|
|
V_topload = 350 # kV
|
|
E_propagation = 0.5 # MV/m
|
|
|
|
# Spark length
|
|
L = np.linspace(0, 3, 300) # 0 to 3 meters
|
|
|
|
# C_sh grows with length
|
|
C_sh = C_sh_per_meter * L
|
|
C_sh[0] = 0.1e-12 # Avoid division by zero
|
|
|
|
# Voltage division (open-circuit limit)
|
|
V_tip_ratio = C_mut / (C_mut + C_sh)
|
|
V_tip = V_topload * V_tip_ratio
|
|
|
|
# Electric field at tip (simplified: E_tip ≈ V_tip / L)
|
|
E_tip = V_tip / L
|
|
E_tip[0] = V_topload / 0.001 # Avoid infinity at L=0
|
|
|
|
# Plot V_tip ratio
|
|
ax1 = ax
|
|
ax1.plot(L, V_tip_ratio, linewidth=3, color=STYLE['primary_color'], label='V_tip / V_topload')
|
|
ax1.set_xlabel('Spark Length L (meters)', fontsize=STYLE['label_size'])
|
|
ax1.set_ylabel('Voltage Ratio V_tip / V_topload', fontsize=STYLE['label_size'], color=STYLE['primary_color'])
|
|
ax1.tick_params(axis='y', labelcolor=STYLE['primary_color'])
|
|
ax1.grid(True, alpha=STYLE['grid_alpha'])
|
|
|
|
# Add E_tip on secondary axis
|
|
ax2 = ax1.twinx()
|
|
ax2.plot(L, E_tip, linewidth=2.5, color=STYLE['secondary_color'], linestyle='--', label='E_tip (MV/m)')
|
|
ax2.axhline(y=E_propagation, color='green', linestyle=':', linewidth=2, label=f'E_propagation = {E_propagation} MV/m')
|
|
ax2.set_ylabel('Electric Field E_tip (MV/m)', fontsize=STYLE['label_size'], color=STYLE['secondary_color'])
|
|
ax2.tick_params(axis='y', labelcolor=STYLE['secondary_color'])
|
|
ax2.set_ylim(0, 5)
|
|
|
|
# Find where E_tip = E_propagation (growth stalls)
|
|
idx_stall = np.argmin(np.abs(E_tip - E_propagation))
|
|
L_stall = L[idx_stall]
|
|
|
|
ax1.axvline(x=L_stall, color='red', linestyle='--', linewidth=2, alpha=0.7)
|
|
ax1.annotate(f'Growth Stalls\n(E_tip = E_prop)\nL ≈ {L_stall:.2f} m',
|
|
xy=(L_stall, V_tip_ratio[idx_stall]),
|
|
xytext=(L_stall + 0.5, V_tip_ratio[idx_stall] + 0.2),
|
|
fontsize=11, fontweight='bold', color='red',
|
|
arrowprops=dict(arrowstyle='->', color='red', lw=2),
|
|
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.8))
|
|
|
|
# Annotations
|
|
ax1.annotate('Sub-linear scaling:\nV_tip drops even if\nV_topload maintained',
|
|
xy=(1.5, V_tip_ratio[150]),
|
|
xytext=(2.0, 0.7),
|
|
fontsize=10,
|
|
arrowprops=dict(arrowstyle='->', color='black', lw=1.5),
|
|
bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.7))
|
|
|
|
# Formula
|
|
formula = f'V_tip = V_topload × C_mut/(C_mut + C_sh)\nC_sh ≈ {C_sh_per_meter*1e12:.1f} pF/m'
|
|
ax1.text(0.1, 0.2, formula, fontsize=10,
|
|
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
|
|
|
|
ax1.set_title('Capacitive Divider Effect: V_tip vs Spark Length\n(Open-circuit limit, R → ∞)',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
|
|
# Combine legends
|
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper right', fontsize=STYLE['legend_size'])
|
|
|
|
save_figure(fig, 'voltage-division-vs-length-plot.png', 'spark-physics')
|
|
|
|
|
|
def generate_length_vs_energy_scaling():
|
|
"""Image 26: Log-log plot showing L vs E scaling for different modes"""
|
|
fig, ax = plt.subplots(figsize=(10, 8))
|
|
|
|
# Energy range
|
|
E = np.logspace(0, 3, 100) # 1 J to 1000 J
|
|
|
|
# Different scaling relationships
|
|
# Burst mode: L ∝ √E (slope = 0.5)
|
|
epsilon_burst = 50 # J/m
|
|
L_burst = np.sqrt(E / epsilon_burst)
|
|
|
|
# QCW: L ∝ E^0.7 (slope = 0.7)
|
|
epsilon_qcw = 10 # J/m
|
|
L_qcw = (E / epsilon_qcw)**0.7 / 3 # Normalize
|
|
|
|
# Ideal linear: L ∝ E (slope = 1.0)
|
|
epsilon_ideal = 5 # J/m
|
|
L_ideal = E / epsilon_ideal / 10 # Normalize
|
|
|
|
# Plot
|
|
ax.loglog(E, L_burst, linewidth=3, color=STYLE['secondary_color'],
|
|
label='Burst Mode: L ∝ √E (slope = 0.5)', marker='o', markevery=10)
|
|
ax.loglog(E, L_qcw, linewidth=3, color=STYLE['primary_color'],
|
|
label='QCW: L ∝ E^0.7 (slope = 0.7)', marker='s', markevery=10)
|
|
ax.loglog(E, L_ideal, linewidth=2, color='green', linestyle='--',
|
|
label='Ideal: L ∝ E (slope = 1.0)', marker='^', markevery=10)
|
|
|
|
# Add slope annotations
|
|
for E_point, L_point, slope, color, offset in [
|
|
(10, L_burst[np.argmin(np.abs(E - 10))], '0.5', STYLE['secondary_color'], (1.5, 0.7)),
|
|
(50, L_qcw[np.argmin(np.abs(E - 50))], '0.7', STYLE['primary_color'], (1.5, 1.0)),
|
|
(200, L_ideal[np.argmin(np.abs(E - 200))], '1.0', 'green', (1.3, 1.0)),
|
|
]:
|
|
ax.annotate(f'Slope = {slope}',
|
|
xy=(E_point, L_point),
|
|
xytext=(E_point * offset[0], L_point * offset[1]),
|
|
fontsize=10, color=color, fontweight='bold',
|
|
arrowprops=dict(arrowstyle='->', color=color, lw=1.5))
|
|
|
|
# Add data points (simulated realistic observations)
|
|
# Burst mode data
|
|
E_burst_data = np.array([5, 10, 20, 50, 100])
|
|
L_burst_data = np.sqrt(E_burst_data / 55) * (1 + 0.1 * np.random.randn(5))
|
|
ax.plot(E_burst_data, L_burst_data, 'r*', markersize=12, label='Burst Mode (measured)')
|
|
|
|
# QCW data
|
|
E_qcw_data = np.array([10, 25, 50, 100, 200])
|
|
L_qcw_data = (E_qcw_data / 10)**0.72 / 3 * (1 + 0.08 * np.random.randn(5))
|
|
ax.plot(E_qcw_data, L_qcw_data, 'b*', markersize=12, label='QCW (measured)')
|
|
|
|
ax.set_xlabel('Energy E (Joules)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Spark Length L (meters)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Freau\'s Empirical Scaling: Spark Length vs Energy\n(Sub-linear scaling due to capacitive divider)',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.grid(True, alpha=STYLE['grid_alpha'], which='both')
|
|
ax.legend(loc='upper left', fontsize=STYLE['legend_size'])
|
|
|
|
# Physical explanation box
|
|
textstr = ('Physical Explanation:\n'
|
|
'• Burst: Voltage-limited\n'
|
|
' (V_tip drops with length)\n'
|
|
'• QCW: Better voltage\n'
|
|
' maintenance via ramping\n'
|
|
'• Ideal: Constant ε,\n'
|
|
' constant E_tip')
|
|
props = dict(boxstyle='round', facecolor='lightyellow', alpha=0.9)
|
|
ax.text(2, 3, textstr, fontsize=10, verticalalignment='top', bbox=props)
|
|
|
|
save_figure(fig, 'length-vs-energy-scaling.png', 'spark-physics')
|
|
|
|
|
|
# ============================================================================
|
|
# PART 4: ADVANCED MODELING IMAGES
|
|
# ============================================================================
|
|
|
|
def generate_capacitance_matrix_heatmap():
|
|
"""Image 34: Heatmap visualization of 11×11 capacitance matrix"""
|
|
fig, ax = plt.subplots(figsize=(10, 9))
|
|
|
|
# Create realistic 11×11 capacitance matrix (topload + 10 segments)
|
|
n = 11
|
|
C_matrix = np.zeros((n, n))
|
|
|
|
# Diagonal elements (large positive)
|
|
for i in range(n):
|
|
if i == 0: # Topload
|
|
C_matrix[i, i] = 25.0
|
|
else: # Segments (decreasing toward tip)
|
|
C_matrix[i, i] = 15.0 - i * 0.8
|
|
|
|
# Off-diagonal elements (negative, stronger for adjacent)
|
|
for i in range(n):
|
|
for j in range(i+1, n):
|
|
distance = abs(i - j)
|
|
if distance == 1: # Adjacent
|
|
C_matrix[i, j] = -3.5 + np.random.rand() * 0.5
|
|
elif distance == 2:
|
|
C_matrix[i, j] = -1.2 + np.random.rand() * 0.3
|
|
elif distance == 3:
|
|
C_matrix[i, j] = -0.5 + np.random.rand() * 0.2
|
|
else:
|
|
C_matrix[i, j] = -0.1 - np.random.rand() * 0.05
|
|
|
|
C_matrix[j, i] = C_matrix[i, j] # Symmetric
|
|
|
|
# Plot heatmap
|
|
im = ax.imshow(C_matrix, cmap='RdBu_r', aspect='equal')
|
|
|
|
# Colorbar
|
|
cbar = plt.colorbar(im, ax=ax)
|
|
cbar.set_label('Capacitance (pF)', fontsize=STYLE['label_size'])
|
|
|
|
# Labels
|
|
labels = ['Top'] + [f'Seg{i}' for i in range(1, n)]
|
|
ax.set_xticks(np.arange(n))
|
|
ax.set_yticks(np.arange(n))
|
|
ax.set_xticklabels(labels)
|
|
ax.set_yticklabels(labels)
|
|
|
|
# Rotate x labels
|
|
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
|
|
|
|
# Add grid
|
|
ax.set_xticks(np.arange(n)-.5, minor=True)
|
|
ax.set_yticks(np.arange(n)-.5, minor=True)
|
|
ax.grid(which="minor", color="gray", linestyle='-', linewidth=0.5)
|
|
|
|
# Annotations
|
|
ax.text(-1.5, n/2, 'Rows', fontsize=12, rotation=90, ha='center', va='center', fontweight='bold')
|
|
ax.text(n/2, -1.5, 'Columns', fontsize=12, ha='center', va='center', fontweight='bold')
|
|
|
|
# Add some value annotations
|
|
for i in range(min(3, n)):
|
|
for j in range(min(3, n)):
|
|
text = ax.text(j, i, f'{C_matrix[i, j]:.1f}',
|
|
ha="center", va="center", color="black", fontsize=8)
|
|
|
|
ax.set_title('Maxwell Capacitance Matrix (11×11)\nTopload + 10 Spark Segments',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
|
|
# Add notes
|
|
note = ('• Diagonal: Large positive (self-capacitance)\n'
|
|
'• Off-diagonal: Negative (mutual)\n'
|
|
'• Adjacent elements: Stronger coupling\n'
|
|
'• Matrix is symmetric')
|
|
fig.text(0.02, 0.02, note, fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8),
|
|
verticalalignment='bottom')
|
|
|
|
save_figure(fig, 'capacitance-matrix-heatmap.png', 'advanced-modeling')
|
|
|
|
|
|
def generate_resistance_taper_initialization():
|
|
"""Image 36: Graph showing initial resistance distribution"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
# Position along spark
|
|
position = np.linspace(0, 1, 100) # 0 = base, 1 = tip
|
|
|
|
# Three initialization strategies
|
|
R_base = 10e3 # 10 kΩ
|
|
R_tip = 1e6 # 1 MΩ
|
|
|
|
# 1. Uniform (wrong)
|
|
R_uniform = np.ones_like(position) * np.sqrt(R_base * R_tip)
|
|
|
|
# 2. Linear taper
|
|
R_linear = R_base + (R_tip - R_base) * position
|
|
|
|
# 3. Quadratic taper (recommended)
|
|
R_quadratic = R_base + (R_tip - R_base) * position**2
|
|
|
|
# Physical bounds
|
|
R_min = R_base + (10e3 - R_base) * position
|
|
R_max = 100e3 + (100e6 - 100e3) * position**2
|
|
|
|
# Plot
|
|
ax.semilogy(position, R_uniform, linewidth=2, linestyle=':', color='red',
|
|
label='Uniform (poor)', alpha=0.7)
|
|
ax.semilogy(position, R_linear, linewidth=2.5, linestyle='--', color=STYLE['primary_color'],
|
|
label='Linear Taper (better)')
|
|
ax.semilogy(position, R_quadratic, linewidth=3, color=STYLE['accent_color'],
|
|
label='Quadratic Taper (recommended)')
|
|
|
|
# Shade physical bounds
|
|
ax.fill_between(position, R_min, R_max, alpha=0.15, color='gray', label='Physical Bounds')
|
|
ax.semilogy(position, R_min, 'k--', linewidth=1, alpha=0.5)
|
|
ax.semilogy(position, R_max, 'k--', linewidth=1, alpha=0.5)
|
|
|
|
# Annotations
|
|
ax.annotate('Hot leader\n(low R)', xy=(0, R_base), xytext=(0.1, R_base/3),
|
|
fontsize=10, arrowprops=dict(arrowstyle='->', color='black', lw=1))
|
|
ax.annotate('Cold streamer\n(high R)', xy=(1, R_tip), xytext=(0.85, R_tip*3),
|
|
fontsize=10, arrowprops=dict(arrowstyle='->', color='black', lw=1))
|
|
|
|
# Formula
|
|
formula = ('Quadratic: R[i] = R_base + (R_tip - R_base) × pos²\n'
|
|
f'R_base = {R_base/1000:.0f} kΩ, R_tip = {R_tip/1e6:.1f} MΩ')
|
|
ax.text(0.5, 2e6, formula, fontsize=10, ha='center',
|
|
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.7))
|
|
|
|
ax.set_xlabel('Normalized Position (0 = base, 1 = tip)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Resistance R[i] (Ω)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Resistance Distribution Initialization Strategies',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.grid(True, alpha=STYLE['grid_alpha'], which='both')
|
|
ax.legend(loc='upper left', fontsize=STYLE['legend_size'])
|
|
|
|
save_figure(fig, 'resistance-taper-initialization.png', 'advanced-modeling')
|
|
|
|
|
|
def generate_power_distribution_along_spark():
|
|
"""Image 38: Bar chart showing power dissipation per segment"""
|
|
fig, ax = plt.subplots(figsize=(12, 7))
|
|
|
|
# Segment numbers
|
|
segments = np.arange(1, 11)
|
|
|
|
# Realistic power distribution (higher at base, peaks at segment 2-3, decays to tip)
|
|
power = np.array([280, 380, 340, 280, 220, 160, 110, 70, 40, 20]) # kW
|
|
|
|
# Calculate cumulative
|
|
power_cumulative = np.cumsum(power)
|
|
total_power = power_cumulative[-1]
|
|
|
|
# Bar chart
|
|
bars = ax.bar(segments, power, color=STYLE['primary_color'], edgecolor='black', linewidth=1.5)
|
|
|
|
# Color gradient (hot at base, cool at tip)
|
|
colors = plt.cm.hot(np.linspace(0.8, 0.2, len(segments)))
|
|
for bar, color in zip(bars, colors):
|
|
bar.set_facecolor(color)
|
|
|
|
# Add percentage labels on bars
|
|
for i, (seg, p) in enumerate(zip(segments, power)):
|
|
percentage = p / total_power * 100
|
|
ax.text(seg, p + 20, f'{percentage:.1f}%', ha='center', fontsize=9, fontweight='bold')
|
|
|
|
# Cumulative line
|
|
ax2 = ax.twinx()
|
|
ax2.plot(segments, power_cumulative / total_power * 100, 'bo-', linewidth=2.5, markersize=8,
|
|
label='Cumulative (%)')
|
|
ax2.set_ylabel('Cumulative Power (%)', fontsize=STYLE['label_size'], color='blue')
|
|
ax2.tick_params(axis='y', labelcolor='blue')
|
|
ax2.set_ylim(0, 110)
|
|
ax2.axhline(y=100, color='blue', linestyle='--', alpha=0.5)
|
|
|
|
# Annotations
|
|
ax.annotate('Peak power\nin segments 2-3', xy=(2.5, 380), xytext=(4, 450),
|
|
fontsize=11, fontweight='bold',
|
|
arrowprops=dict(arrowstyle='->', color='red', lw=2),
|
|
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.8))
|
|
|
|
ax.annotate('Base segments:\n68% of total power', xy=(1, 280), xytext=(0.3, 350),
|
|
fontsize=10,
|
|
arrowprops=dict(arrowstyle='->', color='black', lw=1.5))
|
|
|
|
ax.annotate('Tip: Only 5%\nof total power', xy=(10, 20), xytext=(8.5, 100),
|
|
fontsize=10,
|
|
arrowprops=dict(arrowstyle='->', color='black', lw=1.5))
|
|
|
|
ax.set_xlabel('Segment Number (1 = base, 10 = tip)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Power Dissipated (kW)', fontsize=STYLE['label_size'])
|
|
ax.set_title(f'Power Distribution Along Spark (Total = {total_power:.0f} kW)',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.set_xticks(segments)
|
|
ax.grid(True, alpha=STYLE['grid_alpha'], axis='y')
|
|
|
|
# Total power box
|
|
textstr = f'Total Power: {total_power:.0f} kW\nBase (1-3): 68%\nMiddle (4-7): 27%\nTip (8-10): 5%'
|
|
props = dict(boxstyle='round', facecolor='lightblue', alpha=0.9)
|
|
ax.text(8.5, 400, textstr, fontsize=11, bbox=props, fontweight='bold')
|
|
|
|
save_figure(fig, 'power-distribution-along-spark.png', 'advanced-modeling')
|
|
|
|
|
|
def generate_current_attenuation_plot():
|
|
"""Image 39: Graph of current magnitude along spark"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
# Position along spark
|
|
position = np.linspace(0, 2.5, 100) # 0 to 2.5 meters
|
|
|
|
# Current attenuation (exponential-like decay)
|
|
# More current flows through capacitances to ground as we move along
|
|
I_normalized = np.exp(-0.6 * position)
|
|
|
|
# Add some realistic variation
|
|
I_normalized = I_normalized * (1 + 0.03 * np.sin(20 * position))
|
|
|
|
# Plot
|
|
ax.plot(position, I_normalized * 100, linewidth=3, color=STYLE['primary_color'])
|
|
ax.fill_between(position, 0, I_normalized * 100, alpha=0.3, color=STYLE['primary_color'])
|
|
|
|
# Mark segment boundaries (10 segments)
|
|
segment_positions = np.linspace(0, 2.5, 11)
|
|
for i, pos in enumerate(segment_positions):
|
|
I_val = np.exp(-0.6 * pos) * 100
|
|
ax.plot([pos, pos], [0, I_val], 'k--', linewidth=1, alpha=0.4)
|
|
if i < len(segment_positions) - 1:
|
|
ax.text(pos, -8, f'{i+1}', ha='center', fontsize=9, fontweight='bold')
|
|
|
|
# Mark key points
|
|
key_points = [
|
|
(0, 'Base: 100%'),
|
|
(0.83, 'Middle: 60%'),
|
|
(1.67, '3/4 point: 36%'),
|
|
(2.5, 'Tip: 22%'),
|
|
]
|
|
|
|
for pos, label in key_points:
|
|
I_val = np.exp(-0.6 * pos) * 100
|
|
ax.plot(pos, I_val, 'ro', markersize=10)
|
|
ax.annotate(label, xy=(pos, I_val), xytext=(pos, I_val + 15),
|
|
fontsize=10, ha='center', fontweight='bold',
|
|
arrowprops=dict(arrowstyle='->', color='red', lw=1.5))
|
|
|
|
ax.set_xlabel('Position Along Spark (meters)', fontsize=STYLE['label_size'])
|
|
ax.set_ylabel('Normalized Current |I| / |I_base| (%)', fontsize=STYLE['label_size'])
|
|
ax.set_title('Current Attenuation Along Distributed Spark Model\n(Current diverted to ground through C_sh)',
|
|
fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax.set_xlim(0, 2.5)
|
|
ax.set_ylim(0, 110)
|
|
ax.grid(True, alpha=STYLE['grid_alpha'])
|
|
|
|
# Segment labels
|
|
ax.text(1.25, -15, 'Segment Number', ha='center', fontsize=11, fontweight='bold')
|
|
|
|
# Physical explanation
|
|
textstr = ('Current decreases due to:\n'
|
|
'• Displacement current through C_sh\n'
|
|
'• Distributed capacitive shunting\n'
|
|
'• Not ohmic attenuation (R is small)')
|
|
props = dict(boxstyle='round', facecolor='lightyellow', alpha=0.9)
|
|
ax.text(1.8, 75, textstr, fontsize=10, bbox=props)
|
|
|
|
save_figure(fig, 'current-attenuation-plot.png', 'advanced-modeling')
|
|
|
|
|
|
# ============================================================================
|
|
# SHARED IMAGES
|
|
# ============================================================================
|
|
|
|
def generate_complex_number_review():
|
|
"""Image 45: Quick reference for complex number operations"""
|
|
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(14, 12))
|
|
|
|
# Example complex number
|
|
a = 3
|
|
b = 4
|
|
z = complex(a, b)
|
|
r = abs(z)
|
|
theta = np.angle(z)
|
|
|
|
# Quadrant 1: Rectangular form
|
|
ax1.arrow(0, 0, a, b, head_width=0.3, head_length=0.2, fc='black', ec='black', linewidth=2)
|
|
ax1.plot([0, a], [0, 0], 'b--', linewidth=2, label='Real part (a)')
|
|
ax1.plot([a, a], [0, b], 'r--', linewidth=2, label='Imaginary part (b)')
|
|
ax1.text(a/2, -0.5, f'a = {a}', fontsize=12, ha='center', color='blue', fontweight='bold')
|
|
ax1.text(a + 0.3, b/2, f'b = {b}', fontsize=12, ha='left', color='red', fontweight='bold')
|
|
ax1.text(a/2 + 0.3, b/2 + 0.5, f'z = a + jb\n= {a} + j{b}', fontsize=13, fontweight='bold',
|
|
bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.7))
|
|
ax1.set_xlim(-1, 6)
|
|
ax1.set_ylim(-1, 6)
|
|
ax1.set_xlabel('Real Axis', fontsize=11)
|
|
ax1.set_ylabel('Imaginary Axis', fontsize=11)
|
|
ax1.set_title('Rectangular Form: z = a + jb', fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax1.axhline(y=0, color='k', linewidth=0.8)
|
|
ax1.axvline(x=0, color='k', linewidth=0.8)
|
|
ax1.grid(True, alpha=0.3)
|
|
ax1.legend(loc='upper left')
|
|
ax1.set_aspect('equal')
|
|
|
|
# Quadrant 2: Polar form
|
|
ax2.arrow(0, 0, a, b, head_width=0.3, head_length=0.2, fc='black', ec='black', linewidth=2)
|
|
arc = Wedge((0, 0), 1.5, 0, np.degrees(theta), facecolor='lightgreen', alpha=0.5, edgecolor='black')
|
|
ax2.add_patch(arc)
|
|
ax2.plot([0, r], [0, 0], 'g--', linewidth=1, alpha=0.5)
|
|
ax2.text(r/2, -0.5, f'r = |z| = {r:.2f}', fontsize=11, ha='center', color='green', fontweight='bold')
|
|
ax2.text(0.8, 0.6, f'θ = {np.degrees(theta):.1f}°', fontsize=11, color='green', fontweight='bold')
|
|
ax2.text(a/2 + 0.3, b/2 + 0.8, f'z = r∠θ\n= {r:.2f}∠{np.degrees(theta):.1f}°', fontsize=13, fontweight='bold',
|
|
bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.7))
|
|
ax2.set_xlim(-1, 6)
|
|
ax2.set_ylim(-1, 6)
|
|
ax2.set_xlabel('Real Axis', fontsize=11)
|
|
ax2.set_ylabel('Imaginary Axis', fontsize=11)
|
|
ax2.set_title('Polar Form: z = r∠θ', fontsize=STYLE['title_size'], fontweight='bold')
|
|
ax2.axhline(y=0, color='k', linewidth=0.8)
|
|
ax2.axvline(x=0, color='k', linewidth=0.8)
|
|
ax2.grid(True, alpha=0.3)
|
|
ax2.set_aspect('equal')
|
|
|
|
# Quadrant 3: Conversions
|
|
ax3.axis('off')
|
|
conversion_text = f"""
|
|
CONVERSIONS
|
|
|
|
Rectangular → Polar:
|
|
r = √(a² + b²) = {r:.2f}
|
|
θ = atan(b/a) = {np.degrees(theta):.1f}°
|
|
|
|
Polar → Rectangular:
|
|
a = r cos(θ) = {r:.2f} × cos({np.degrees(theta):.1f}°) = {a:.2f}
|
|
b = r sin(θ) = {r:.2f} × sin({np.degrees(theta):.1f}°) = {b:.2f}
|
|
|
|
Euler Form:
|
|
z = r e^(jθ) = {r:.2f} e^(j{theta:.3f})
|
|
|
|
Complex Conjugate:
|
|
z* = a - jb = {a} - j{b}
|
|
z* = r∠(-θ) = {r:.2f}∠{-np.degrees(theta):.1f}°
|
|
"""
|
|
ax3.text(0.1, 0.9, conversion_text, fontsize=11, fontfamily='monospace',
|
|
verticalalignment='top',
|
|
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.9))
|
|
|
|
# Quadrant 4: Operations
|
|
ax4.axis('off')
|
|
operations_text = """
|
|
OPERATIONS
|
|
|
|
Addition/Subtraction (use rectangular):
|
|
z₁ + z₂ = (a₁ + a₂) + j(b₁ + b₂)
|
|
|
|
Multiplication (use polar):
|
|
z₁ × z₂ = r₁r₂ ∠(θ₁ + θ₂)
|
|
|
|
Division (use polar):
|
|
z₁ / z₂ = (r₁/r₂) ∠(θ₁ - θ₂)
|
|
|
|
Magnitude:
|
|
|z| = r = √(a² + b²)
|
|
|
|
Power Calculation (AC circuits):
|
|
P = ½ Re{V × I*}
|
|
(Use conjugate of current!)
|
|
|
|
Important for Tesla Coils:
|
|
j = √(-1) (imaginary unit)
|
|
jωL → inductive reactance (+j)
|
|
-j/(ωC) → capacitive reactance (-j)
|
|
"""
|
|
ax4.text(0.1, 0.9, operations_text, fontsize=10, fontfamily='monospace',
|
|
verticalalignment='top',
|
|
bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.9))
|
|
|
|
fig.suptitle('Complex Number Quick Reference for AC Circuit Analysis',
|
|
fontsize=STYLE['title_size']+2, fontweight='bold', y=0.98)
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'complex-number-review.png', 'shared')
|
|
|
|
|
|
# ============================================================================
|
|
# ADDITIONAL IMAGES: Comparison Tables and Simple Diagrams
|
|
# ============================================================================
|
|
|
|
def generate_drsstc_operating_modes():
|
|
"""Image 14: Three timing diagrams showing different DRSSTC operating modes"""
|
|
fig, axes = plt.subplots(3, 1, figsize=(12, 9))
|
|
fig.suptitle('DRSSTC Operating Modes Comparison', fontsize=16, fontweight='bold')
|
|
|
|
time_ms = np.linspace(0, 25, 1000)
|
|
|
|
# Mode 1: Fixed frequency
|
|
ax = axes[0]
|
|
freq_fixed = 200 # kHz
|
|
drive_fixed = 0.8 * np.sin(2 * np.pi * freq_fixed * time_ms / 1000)
|
|
drive_fixed[time_ms > 20] = 0 # Pulse ends
|
|
|
|
ax.plot(time_ms, drive_fixed, 'b-', linewidth=2, label='Drive Signal (Fixed f)')
|
|
ax.axhline(y=0, color='k', linestyle='-', linewidth=0.5)
|
|
ax.set_ylabel('Amplitude', fontsize=11)
|
|
ax.set_title('(a) Fixed Frequency Mode', fontsize=12, fontweight='bold')
|
|
ax.text(22, 0.5, 'Pro: Simple\nCon: Detuning\nwith loading', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
|
|
ax.grid(True, alpha=0.3)
|
|
ax.set_xlim(0, 25)
|
|
ax.legend(loc='upper left', fontsize=9)
|
|
|
|
# Mode 2: PLL tracking
|
|
ax = axes[1]
|
|
# Frequency decreases as spark grows
|
|
freq_pll = 200 - 25 * (time_ms / 20) # 200 -> 175 kHz
|
|
freq_pll[time_ms > 20] = 200
|
|
phase_pll = np.cumsum(2 * np.pi * freq_pll / 1000 * np.mean(np.diff(time_ms)))
|
|
drive_pll = 0.8 * np.sin(phase_pll)
|
|
drive_pll[time_ms > 20] = 0
|
|
|
|
ax.plot(time_ms, drive_pll, 'g-', linewidth=2, label='Drive Signal (PLL Tracking)')
|
|
ax.axhline(y=0, color='k', linestyle='-', linewidth=0.5)
|
|
ax.set_ylabel('Amplitude', fontsize=11)
|
|
ax.set_title('(b) PLL Tracking Mode', fontsize=12, fontweight='bold')
|
|
ax.text(22, 0.5, 'Pro: Follows\nresonance\nCon: Complex', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.8))
|
|
ax.grid(True, alpha=0.3)
|
|
ax.set_xlim(0, 25)
|
|
ax.legend(loc='upper left', fontsize=9)
|
|
|
|
# Mode 3: Programmed sweep
|
|
ax = axes[2]
|
|
freq_sweep = 200 - 30 * (time_ms / 20)**0.7 # Predetermined curve
|
|
freq_sweep[time_ms > 20] = 200
|
|
phase_sweep = np.cumsum(2 * np.pi * freq_sweep / 1000 * np.mean(np.diff(time_ms)))
|
|
drive_sweep = 0.8 * np.sin(phase_sweep)
|
|
drive_sweep[time_ms > 20] = 0
|
|
|
|
ax.plot(time_ms, drive_sweep, 'r-', linewidth=2, label='Drive Signal (Programmed)')
|
|
ax.axhline(y=0, color='k', linestyle='-', linewidth=0.5)
|
|
ax.set_ylabel('Amplitude', fontsize=11)
|
|
ax.set_xlabel('Time (ms)', fontsize=11)
|
|
ax.set_title('(c) Programmed Sweep Mode', fontsize=12, fontweight='bold')
|
|
ax.text(22, 0.5, 'Pro: Optimal\ntrajectory\nCon: Tuning', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='lightcoral', alpha=0.8))
|
|
ax.grid(True, alpha=0.3)
|
|
ax.set_xlim(0, 25)
|
|
ax.legend(loc='upper left', fontsize=9)
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'drsstc-operating-modes.png', 'optimization')
|
|
|
|
|
|
def generate_loaded_pole_analysis():
|
|
"""Image 15: Frequency domain showing coupled resonances"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
freq_khz = np.linspace(150, 250, 500)
|
|
f0 = 200 # Unloaded resonance
|
|
|
|
# Unloaded: sharp twin peaks
|
|
Q_unloaded = 50
|
|
pole1_unloaded = 190
|
|
pole2_unloaded = 210
|
|
response_unloaded = (100 / (1 + Q_unloaded**2 * ((freq_khz - pole1_unloaded)/pole1_unloaded)**2) +
|
|
100 / (1 + Q_unloaded**2 * ((freq_khz - pole2_unloaded)/pole2_unloaded)**2))
|
|
|
|
# Loaded: broader, shifted peaks
|
|
Q_loaded = 15
|
|
pole1_loaded = 175
|
|
pole2_loaded = 215
|
|
response_loaded = (80 / (1 + Q_loaded**2 * ((freq_khz - pole1_loaded)/pole1_loaded)**2) +
|
|
80 / (1 + Q_loaded**2 * ((freq_khz - pole2_loaded)/pole2_loaded)**2))
|
|
|
|
ax.plot(freq_khz, response_unloaded, 'b-', linewidth=2.5, label='Unloaded (No Spark)', alpha=0.7)
|
|
ax.plot(freq_khz, response_loaded, 'r-', linewidth=2.5, label='Loaded (With Spark)', alpha=0.7)
|
|
|
|
# Mark operating points
|
|
ax.axvline(x=f0, color='blue', linestyle='--', linewidth=1.5, alpha=0.5,
|
|
label=f'Fixed f0 = {f0} kHz (Wrong!)')
|
|
ax.axvline(x=pole1_loaded, color='red', linestyle='--', linewidth=1.5, alpha=0.7,
|
|
label=f'Track Loaded Pole = {pole1_loaded} kHz (Right!)')
|
|
|
|
# Annotations
|
|
ax.annotate('Unloaded peaks:\nSharp, symmetric', xy=(pole1_unloaded, 90),
|
|
xytext=(160, 150), fontsize=10,
|
|
bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.8),
|
|
arrowprops=dict(arrowstyle='->', color='blue', lw=1.5))
|
|
|
|
ax.annotate('Loaded peaks:\nBroader, shifted', xy=(pole1_loaded, 70),
|
|
xytext=(160, 90), fontsize=10,
|
|
bbox=dict(boxstyle='round', facecolor='lightcoral', alpha=0.8),
|
|
arrowprops=dict(arrowstyle='->', color='red', lw=1.5))
|
|
|
|
ax.set_xlabel('Frequency (kHz)', fontsize=12)
|
|
ax.set_ylabel('|V_topload| (kV)', fontsize=12)
|
|
ax.set_title('Loaded Pole Analysis: Frequency Tracking is Critical', fontsize=14, fontweight='bold')
|
|
ax.legend(loc='upper right', fontsize=10)
|
|
ax.grid(True, alpha=0.3)
|
|
ax.set_xlim(150, 250)
|
|
ax.set_ylim(0, 200)
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'loaded-pole-analysis.png', 'optimization')
|
|
|
|
|
|
def generate_epsilon_by_mode_comparison():
|
|
"""Image 19: Bar chart comparing epsilon values by operating mode"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
modes = ['QCW\n(Continuous)', 'Hybrid\nDRSSTC', 'Hard-Pulsed\nBurst']
|
|
epsilon_mean = [10, 30, 60]
|
|
epsilon_err_low = [5, 10, 30]
|
|
epsilon_err_high = [5, 10, 40]
|
|
colors = ['green', 'orange', 'red']
|
|
|
|
x_pos = np.arange(len(modes))
|
|
bars = ax.bar(x_pos, epsilon_mean, color=colors, alpha=0.7, edgecolor='black', linewidth=1.5)
|
|
|
|
# Error bars
|
|
ax.errorbar(x_pos, epsilon_mean,
|
|
yerr=[epsilon_err_low, epsilon_err_high],
|
|
fmt='none', color='black', capsize=10, capthick=2, linewidth=2)
|
|
|
|
# Add value labels
|
|
for i, (mean, color) in enumerate(zip(epsilon_mean, colors)):
|
|
ax.text(i, mean + epsilon_err_high[i] + 5, f'{mean} J/m',
|
|
ha='center', fontsize=11, fontweight='bold')
|
|
|
|
# Annotations
|
|
ax.text(0, 2, 'Efficient\nLeader-dominated', ha='center', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.8))
|
|
ax.text(1, 2, 'Moderate\nMixed regime', ha='center', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
|
|
ax.text(2, 2, 'Inefficient\nStreamer-dominated', ha='center', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='lightcoral', alpha=0.8))
|
|
|
|
ax.set_ylabel('Energy Cost per Meter, epsilon (J/m)', fontsize=12)
|
|
ax.set_xlabel('Operating Mode', fontsize=12)
|
|
ax.set_title('Energy Cost Comparison: QCW vs Burst Mode', fontsize=14, fontweight='bold')
|
|
ax.set_xticks(x_pos)
|
|
ax.set_xticklabels(modes, fontsize=11)
|
|
ax.set_yscale('log')
|
|
ax.set_ylim(1, 150)
|
|
ax.grid(True, alpha=0.3, axis='y')
|
|
|
|
# Add theoretical minimum line
|
|
ax.axhline(y=0.5, color='gray', linestyle=':', linewidth=2, alpha=0.7,
|
|
label='Theoretical minimum (~0.5 J/m)')
|
|
ax.legend(loc='upper left', fontsize=10)
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'epsilon-by-mode-comparison.png', 'spark-physics')
|
|
|
|
|
|
def generate_qcw_vs_burst_timeline():
|
|
"""Image 27: Side-by-side timing diagrams comparing QCW and burst operation"""
|
|
fig, axes = plt.subplots(2, 1, figsize=(14, 8))
|
|
fig.suptitle('QCW vs Burst Mode: Timing Comparison', fontsize=16, fontweight='bold')
|
|
|
|
# QCW mode (top)
|
|
ax = axes[0]
|
|
time_qcw = np.linspace(0, 25, 1000)
|
|
power_qcw = 50 * (time_qcw / 20)**1.5 # Gradual ramp
|
|
power_qcw[time_qcw > 20] = 0
|
|
length_qcw = 0.8 * (time_qcw / 20)**0.7 * 2.5 # Sub-linear growth
|
|
length_qcw[time_qcw > 20] = length_qcw[time_qcw <= 20][-1]
|
|
temp_qcw = 8000 + 12000 * (time_qcw / 20) # Temperature stays high
|
|
temp_qcw[time_qcw > 20] = 20000
|
|
|
|
ax2 = ax.twinx()
|
|
ax3 = ax.twinx()
|
|
ax3.spines['right'].set_position(('outward', 60))
|
|
|
|
p1, = ax.plot(time_qcw, power_qcw, 'r-', linewidth=2.5, label='Power (kW)')
|
|
p2, = ax2.plot(time_qcw, length_qcw, 'b-', linewidth=2.5, label='Spark Length (m)')
|
|
p3, = ax3.plot(time_qcw, temp_qcw, 'orange', linewidth=2.5, label='Channel Temp (K)')
|
|
|
|
ax.set_xlabel('Time (ms)', fontsize=11)
|
|
ax.set_ylabel('Power (kW)', fontsize=11, color='r')
|
|
ax2.set_ylabel('Spark Length (m)', fontsize=11, color='b')
|
|
ax3.set_ylabel('Temperature (K)', fontsize=11, color='orange')
|
|
ax.tick_params(axis='y', labelcolor='r')
|
|
ax2.tick_params(axis='y', labelcolor='b')
|
|
ax3.tick_params(axis='y', labelcolor='orange')
|
|
ax.set_title('(a) QCW Mode: 10-20 ms Ramp', fontsize=12, fontweight='bold')
|
|
ax.grid(True, alpha=0.3)
|
|
ax.set_xlim(0, 25)
|
|
ax.set_ylim(0, 100)
|
|
ax2.set_ylim(0, 3)
|
|
ax3.set_ylim(5000, 25000)
|
|
|
|
lines = [p1, p2, p3]
|
|
labels = [l.get_label() for l in lines]
|
|
ax.legend(lines, labels, loc='upper left', fontsize=9)
|
|
|
|
ax.text(22, 70, 'Channel stays hot\nthroughout pulse', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.8))
|
|
|
|
# Burst mode (bottom)
|
|
ax = axes[1]
|
|
time_burst = np.linspace(0, 1, 1000)
|
|
power_burst = np.zeros_like(time_burst)
|
|
power_burst[(time_burst > 0.1) & (time_burst < 0.6)] = 200 # Short high pulse
|
|
length_burst = np.zeros_like(time_burst)
|
|
length_burst[time_burst > 0.1] = 1.2 * np.minimum((time_burst[time_burst > 0.1] - 0.1) / 0.5, 1)**0.5
|
|
temp_burst = 8000 + 12000 * np.exp(-time_burst / 0.15) # Rapid cooling
|
|
|
|
ax2 = ax.twinx()
|
|
ax3 = ax.twinx()
|
|
ax3.spines['right'].set_position(('outward', 60))
|
|
|
|
p1, = ax.plot(time_burst, power_burst, 'r-', linewidth=2.5, label='Power (kW)')
|
|
p2, = ax2.plot(time_burst, length_burst, 'b-', linewidth=2.5, label='Spark Length (m)')
|
|
p3, = ax3.plot(time_burst, temp_burst, 'orange', linewidth=2.5, label='Channel Temp (K)')
|
|
|
|
ax.set_xlabel('Time (ms)', fontsize=11)
|
|
ax.set_ylabel('Power (kW)', fontsize=11, color='r')
|
|
ax2.set_ylabel('Spark Length (m)', fontsize=11, color='b')
|
|
ax3.set_ylabel('Temperature (K)', fontsize=11, color='orange')
|
|
ax.tick_params(axis='y', labelcolor='r')
|
|
ax2.tick_params(axis='y', labelcolor='b')
|
|
ax3.tick_params(axis='y', labelcolor='orange')
|
|
ax.set_title('(b) Burst Mode: 100-500 µs Pulse', fontsize=12, fontweight='bold')
|
|
ax.grid(True, alpha=0.3)
|
|
ax.set_xlim(0, 1)
|
|
ax.set_ylim(0, 250)
|
|
ax2.set_ylim(0, 1.5)
|
|
ax3.set_ylim(5000, 25000)
|
|
|
|
lines = [p1, p2, p3]
|
|
labels = [l.get_label() for l in lines]
|
|
ax.legend(lines, labels, loc='upper left', fontsize=9)
|
|
|
|
ax.text(0.75, 180, 'Channel cools\nbetween pulses', fontsize=9,
|
|
bbox=dict(boxstyle='round', facecolor='lightcoral', alpha=0.8))
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'qcw-vs-burst-timeline.png', 'spark-physics')
|
|
|
|
|
|
def generate_position_dependent_bounds():
|
|
"""Image 41: Graph showing R_min and R_max vs position"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
position = np.linspace(0, 1, 100) # 0 = base, 1 = tip
|
|
|
|
# R_min increases linearly with position
|
|
R_min = 1e3 + (10e3 - 1e3) * position # 1 kOhm -> 10 kOhm
|
|
|
|
# R_max increases quadratically
|
|
R_max = 100e3 + (100e6 - 100e3) * position**2 # 100 kOhm -> 100 MOhm
|
|
|
|
# Typical optimized distribution (quadratic taper)
|
|
R_opt = 5e3 + (500e3 - 5e3) * position**2
|
|
|
|
ax.fill_between(position, R_min, R_max, alpha=0.3, color='lightblue', label='Feasible Region')
|
|
ax.plot(position, R_min, 'b--', linewidth=2, label='R_min (Hot Leader Limit)')
|
|
ax.plot(position, R_max, 'r--', linewidth=2, label='R_max (Cold Streamer Limit)')
|
|
ax.plot(position, R_opt, 'g-', linewidth=3, label='Typical Optimized R', alpha=0.8)
|
|
|
|
# Annotations
|
|
ax.annotate('Base: Hot leader\nLow resistance', xy=(0.05, R_min[5]),
|
|
xytext=(0.15, 2e5), fontsize=10,
|
|
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8),
|
|
arrowprops=dict(arrowstyle='->', color='blue', lw=1.5))
|
|
|
|
ax.annotate('Tip: Cold streamer\nHigh resistance', xy=(0.95, R_max[95]),
|
|
xytext=(0.6, 5e7), fontsize=10,
|
|
bbox=dict(boxstyle='round', facecolor='lightcoral', alpha=0.8),
|
|
arrowprops=dict(arrowstyle='->', color='red', lw=1.5))
|
|
|
|
ax.set_xlabel('Position Along Spark (0 = Base, 1 = Tip)', fontsize=12)
|
|
ax.set_ylabel('Resistance (Ohm)', fontsize=12)
|
|
ax.set_title('Position-Dependent Resistance Bounds', fontsize=14, fontweight='bold')
|
|
ax.set_yscale('log')
|
|
ax.set_ylim(500, 2e8)
|
|
ax.legend(loc='upper left', fontsize=10)
|
|
ax.grid(True, alpha=0.3, which='both')
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'position-dependent-bounds.png', 'advanced-modeling')
|
|
|
|
|
|
def generate_validation_total_resistance():
|
|
"""Image 43: Chart showing expected R_total ranges"""
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
|
|
conditions = ['Very Low Freq\n(<100 kHz)', 'Standard QCW\n(200 kHz, Leader)',
|
|
'Standard Burst\n(200 kHz, Streamer)', 'High Freq\n(400+ kHz)']
|
|
R_mean = [5e3, 20e3, 150e3, 250e3] # Mean values
|
|
R_low = [1e3, 5e3, 50e3, 100e3] # Lower bounds
|
|
R_high = [10e3, 50e3, 300e3, 500e3] # Upper bounds
|
|
|
|
x_pos = np.arange(len(conditions))
|
|
colors = ['green', 'lightgreen', 'orange', 'red']
|
|
|
|
bars = ax.bar(x_pos, R_mean, color=colors, alpha=0.7, edgecolor='black', linewidth=1.5)
|
|
|
|
# Error bars showing range
|
|
ax.errorbar(x_pos, R_mean,
|
|
yerr=[np.array(R_mean) - np.array(R_low), np.array(R_high) - np.array(R_mean)],
|
|
fmt='none', color='black', capsize=10, capthick=2, linewidth=2)
|
|
|
|
# Value labels
|
|
for i, (mean, low, high) in enumerate(zip(R_mean, R_low, R_high)):
|
|
ax.text(i, high * 1.3, f'{mean/1e3:.0f} kOhm\n({low/1e3:.0f}-{high/1e3:.0f})',
|
|
ha='center', fontsize=9, fontweight='bold')
|
|
|
|
ax.set_ylabel('Total Spark Resistance, R_total (Ohm)', fontsize=12)
|
|
ax.set_xlabel('Operating Condition', fontsize=12)
|
|
ax.set_title('Expected Resistance Ranges for Validation', fontsize=14, fontweight='bold')
|
|
ax.set_xticks(x_pos)
|
|
ax.set_xticklabels(conditions, fontsize=10)
|
|
ax.set_yscale('log')
|
|
ax.set_ylim(500, 1e6)
|
|
ax.grid(True, alpha=0.3, axis='y')
|
|
|
|
# Add note
|
|
ax.text(0.5, 0.02, 'Note: R proportional to 1/f, R proportional to L (length)',
|
|
transform=ax.transAxes, fontsize=10, ha='center',
|
|
bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'validation-total-resistance.png', 'advanced-modeling')
|
|
|
|
|
|
def generate_lumped_vs_distributed_comparison():
|
|
"""Image 40: Table comparing lumped vs distributed models"""
|
|
fig, ax = plt.subplots(figsize=(12, 8))
|
|
ax.axis('off')
|
|
|
|
# Create comparison table
|
|
table_data = [
|
|
['Feature', 'Lumped Model', 'Distributed Model'],
|
|
['Circuit Elements', 'Single R, C_mut, C_sh', 'n segments (10-20)\nn(n+1)/2 capacitors'],
|
|
['Simulation Time', '~0.1 seconds', '~100-200 seconds'],
|
|
['Accuracy for\nShort Sparks (<1m)', 'Excellent', 'Excellent (overkill)'],
|
|
['Accuracy for\nLong Sparks (>2m)', 'Good approximation', 'High precision'],
|
|
['Spatial Detail', 'None (single point)', 'Voltage, current,\npower at each segment'],
|
|
['Extraction Effort', 'Simple (2x2 matrix)', 'Complex (11x11 matrix)\nPartial capacitance\ntransformation'],
|
|
['Best Use Cases', '- Impedance matching\n- R_opt studies\n- Quick iteration\n- Teaching', '- Research\n- Spatial distribution\n- Long sparks\n- Validation'],
|
|
['Computational Cost', 'Very Low', 'High'],
|
|
['Implementation\nDifficulty', 'Easy', 'Moderate to Hard'],
|
|
]
|
|
|
|
# Color coding
|
|
colors = []
|
|
for i, row in enumerate(table_data):
|
|
if i == 0: # Header
|
|
colors.append(['lightgray', 'lightgray', 'lightgray'])
|
|
else:
|
|
colors.append(['white', 'lightgreen', 'lightyellow'])
|
|
|
|
table = ax.table(cellText=table_data, cellLoc='left', loc='center',
|
|
cellColours=colors, colWidths=[0.25, 0.375, 0.375])
|
|
|
|
table.auto_set_font_size(False)
|
|
table.set_fontsize(10)
|
|
table.scale(1, 3)
|
|
|
|
# Style header row
|
|
for i in range(3):
|
|
cell = table[(0, i)]
|
|
cell.set_text_props(weight='bold', fontsize=11)
|
|
cell.set_facecolor('lightblue')
|
|
|
|
# Bold first column
|
|
for i in range(1, len(table_data)):
|
|
cell = table[(i, 0)]
|
|
cell.set_text_props(weight='bold')
|
|
|
|
ax.set_title('Lumped vs Distributed Model Comparison', fontsize=16, fontweight='bold', pad=20)
|
|
|
|
plt.tight_layout()
|
|
save_figure(fig, 'lumped-vs-distributed-comparison.png', 'advanced-modeling')
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN EXECUTION
|
|
# ============================================================================
|
|
|
|
def generate_all_fundamentals():
|
|
"""Generate all Part 1 (Fundamentals) images"""
|
|
print("\n" + "="*60)
|
|
print("GENERATING PART 1: FUNDAMENTALS IMAGES")
|
|
print("="*60)
|
|
|
|
generate_complex_plane_admittance() # Image 3
|
|
generate_phase_angle_visualization() # Image 4
|
|
generate_phase_constraint_graph() # Image 5
|
|
generate_admittance_vector_addition() # Image 7
|
|
|
|
print(f"\n[OK] Part 1 complete: 4 images generated")
|
|
|
|
|
|
def generate_all_optimization():
|
|
"""Generate all Part 2 (Optimization) images"""
|
|
print("\n" + "="*60)
|
|
print("GENERATING PART 2: OPTIMIZATION IMAGES")
|
|
print("="*60)
|
|
|
|
generate_power_vs_resistance_curves() # Image 9
|
|
generate_frequency_shift_with_loading() # Image 13
|
|
generate_drsstc_operating_modes() # Image 14
|
|
generate_loaded_pole_analysis() # Image 15
|
|
|
|
print(f"\n[OK] Part 2 complete: 4 images generated")
|
|
|
|
|
|
def generate_all_spark_physics():
|
|
"""Generate all Part 3 (Spark Physics) images"""
|
|
print("\n" + "="*60)
|
|
print("GENERATING PART 3: SPARK PHYSICS IMAGES")
|
|
print("="*60)
|
|
|
|
generate_energy_budget_breakdown() # Image 18
|
|
generate_epsilon_by_mode_comparison() # Image 19
|
|
generate_thermal_diffusion_vs_diameter() # Image 20
|
|
generate_voltage_division_vs_length_plot() # Image 24
|
|
generate_length_vs_energy_scaling() # Image 26
|
|
generate_qcw_vs_burst_timeline() # Image 27
|
|
|
|
print(f"\n[OK] Part 3 complete: 6 images generated")
|
|
|
|
|
|
def generate_all_advanced_modeling():
|
|
"""Generate all Part 4 (Advanced Modeling) images"""
|
|
print("\n" + "="*60)
|
|
print("GENERATING PART 4: ADVANCED MODELING IMAGES")
|
|
print("="*60)
|
|
|
|
generate_capacitance_matrix_heatmap() # Image 34
|
|
generate_resistance_taper_initialization() # Image 36
|
|
generate_power_distribution_along_spark() # Image 38
|
|
generate_current_attenuation_plot() # Image 39
|
|
generate_lumped_vs_distributed_comparison() # Image 40
|
|
generate_position_dependent_bounds() # Image 41
|
|
generate_validation_total_resistance() # Image 43
|
|
|
|
print(f"\n[OK] Part 4 complete: 7 images generated")
|
|
|
|
|
|
def generate_all_shared():
|
|
"""Generate shared images"""
|
|
print("\n" + "="*60)
|
|
print("GENERATING SHARED IMAGES")
|
|
print("="*60)
|
|
|
|
generate_complex_number_review() # Image 45
|
|
|
|
print(f"\n[OK] Shared images complete: 1 image generated")
|
|
|
|
|
|
def main():
|
|
"""Main entry point"""
|
|
parser = argparse.ArgumentParser(description='Generate Tesla Coil course images')
|
|
parser.add_argument('--part', type=int, choices=[1, 2, 3, 4], help='Generate images for specific part only')
|
|
parser.add_argument('--shared', action='store_true', help='Generate shared images')
|
|
args = parser.parse_args()
|
|
|
|
print("\n" + "="*60)
|
|
print("TESLA COIL SPARK COURSE - IMAGE GENERATION")
|
|
print("="*60)
|
|
print(f"Output directories:")
|
|
for name, path in ASSETS_DIRS.items():
|
|
print(f" {name}: {path}")
|
|
print("="*60)
|
|
|
|
if args.part:
|
|
if args.part == 1:
|
|
generate_all_fundamentals()
|
|
elif args.part == 2:
|
|
generate_all_optimization()
|
|
elif args.part == 3:
|
|
generate_all_spark_physics()
|
|
elif args.part == 4:
|
|
generate_all_advanced_modeling()
|
|
elif args.shared:
|
|
generate_all_shared()
|
|
else:
|
|
# Generate all
|
|
generate_all_fundamentals()
|
|
generate_all_optimization()
|
|
generate_all_spark_physics()
|
|
generate_all_advanced_modeling()
|
|
generate_all_shared()
|
|
|
|
print("\n" + "="*60)
|
|
print("GENERATION COMPLETE!")
|
|
print("="*60)
|
|
print(f"\nTotal images generated: 22")
|
|
print(f" - Fundamentals: 4 images")
|
|
print(f" - Optimization: 4 images")
|
|
print(f" - Spark Physics: 6 images")
|
|
print(f" - Advanced Modeling: 7 images")
|
|
print(f" - Shared: 1 image")
|
|
print("\nNote: This script generated matplotlib/numpy-based images.")
|
|
print("Circuit diagrams (7), FEMM screenshots (5), and photos (3) require")
|
|
print("manual creation with professional tools.")
|
|
print("\nSee CIRCUIT-SPECIFICATIONS.md for circuit creation specs.")
|
|
print("See IMAGE-REQUIREMENTS.md for complete list.")
|
|
print("="*60 + "\n")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|