resonant-dipoles

In [1]:
import datetime
datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S Z")
Out[1]:
'2026-09-04 15:16:21 Z'

Regarding resonant dipoles at different heights

For an explanation what this is, it is highly recommended you read the pertaining blog post first.

Choices

When authoring this, I've made some more or less arbitrary choices.

This is a Jupyter notebook. Grab the sources and change those choices to whatever suits your preferences, and run the notebook again. Instructions for that are found towards the end of this document.

40 m band

Discussions here are mostly for the 40 m band, unless noted otherwise. Design frequency is 7100 kHz.

10 m high

Unless noted otherwise, the dipoles are at a height above ground of 10 m.

Average ground

Most antennas are simulated over "average ground".

Wire diameter

The simulation is typically done with wire of 1 mm² diameter.

In [2]:
from math import log, pi, sqrt

c = 3e8 # speed of light in m/s

f_base = 7.1e6 # our frequency of interest in Hz
height_base = 10 # the height used throughout for the antenna, or at least the feedpoint.
wire_diameter_base = 1e-3 # meter

ω_base = 2 * pi * f_base
λ_base = c / f_base

normal_segments_count = 9

Greetings from an age of the dinosaurs: NEC input.

This is going to be rather technical. You can skip this if you are not interested in the programming side of things.

As a side-remark: The NEC software traces its origin to the 1970s, when punch (paper) cards were commonplace. The original input format for NEC2 software was defined in terms of such punch cards.

Old printed documentation of the day has been OCR'ed, manually polished, and is now available at www.nec2.org. In particular, the NEC-2 manual manual (converted from paper September 1996) is a good reference.

Modern NEC2 implementations typically do not document input format. The implementers apparently presuppose knowledge of the traditional NEC2 documentation.

Each individual input card (today read: input line) typically has a two-character field that specifies what kind of input card it is, then some integer parameters followed by some floating point parameters. Information is collected from several input cards. The simulation run proper is then started by certain specific input cards.

In the 1970s, the input was column-oriented, with 80 columns per line. This is no longer the case for more modern NEC2 implementations. Today, space-separation of fields seems to do the job nicely.

Here is the code that provides such input:

In [3]:
from typing import Optional

# Table for usual dielectric constant and conductivity for different grounds.
# We only use average and city.
GROUNDS = {
    "ideal": (None, None),
    "salt water": (81, 5),
    "excellent": (20, 3e-2),
    "average": (13, 5e-3),
    "industry": (5, 1e-3),
    "city": (3, 1e-3)
}

def generate_input(
    l: float,
    f: float,
    height: float,
    wire_diameter: float,
    ground_diel: Optional[float],
    ground_cond: Optional[float],
    num_segs: int,
    rp_line: str = "RP 0 37 144 1003 0.0 0.0 2.5 2.5 0.0 0.0"
) -> str:
    """Generate the input nec2++ expects.
    
    l dipole length in m.
    f frequency in Hz
    height above ground in m
    wire_diameter again in m.
    ground_diel dielectric constant.
    ground_cond ground conductivity. Omit both ground_diel and ground_cond for free space.
    num_segs Number of segments used to simulate one dipole half.
    
    """
    λ = 3e8 / f

    # Some documentation says nec2++ does not like segments shorter than 0.02 λ.
    # But using shorter segment does not seem to really hurt.
    # So let's throw caution into the wind and use 17 segments as the standard.

    input = ("CM Simple dipole antennas for various parameter studies.\n"
        # end of comment:
        "CE Here is one of many examples:\n"

        # tag number, number of segments, x,y,z of endpoint, x,y,z of other endpoint, wire radius
        f"GW 1 {2*num_segs+1}  {-l/2:.3f} .0 {height:.2f}   {l/2:.3f} .0 {height:.2f}  {wire_diameter/2:.3e}\n"

        # end of geometry
        "GE\n"

        # Ground: 2 0 0 0 finite ground with no ground-screen, dielectric constant, conductivity in mhos / m
        + (
            f"GN 2 0 0 0 {ground_diel} {ground_cond}\n" \
            if ground_diel is not None or ground_cond is not None \
            else ""
          ) +

        # Copper wire
        "LD 5 1 0 0 58.1e6\n"

        # Excitation: 0 Voltage source, 1 tag number and segment number where the excitation happens,
        # the following 0 is for general sanity, the following one or two floats give the (real or complex) voltage.
        f"EX 0 1 {num_segs+1} 0 1.0\n"

        # Frequencies: 0: linear stepping, 1: number of frequencies stepped through, 0, 0,
        # then starting frequency in MHz and stepping increment.
        # The software that reads the simulation run's output assumes we have only one frequency.
        f"FR 0 1 0 0 {f*1e-6:.6f} 0.00\n"

        # Actually trigger the simulation:
        + rp_line + 
        "\nEN\n" )
    return input

# Produce a sample output:
print(generate_input(9, f_base, height_base, wire_diameter_base, *GROUNDS["average"], 17))
print()
print(generate_input(9, f_base, height_base, wire_diameter_base, None, None, normal_segments_count))
CM Simple dipole antennas for various parameter studies.
CE Here is one of many examples:
GW 1 35  -4.500 .0 10.00   4.500 .0 10.00  5.000e-04
GE
GN 2 0 0 0 13 0.005
LD 5 1 0 0 58.1e6
EX 0 1 18 0 1.0
FR 0 1 0 0 7.100000 0.00
RP 0 37 144 1003 0.0 0.0 2.5 2.5 0.0 0.0
EN


CM Simple dipole antennas for various parameter studies.
CE Here is one of many examples:
GW 1 19  -4.500 .0 10.00   4.500 .0 10.00  5.000e-04
GE
LD 5 1 0 0 58.1e6
EX 0 1 10 0 1.0
FR 0 1 0 0 7.100000 0.00
RP 0 37 144 1003 0.0 0.0 2.5 2.5 0.0 0.0
EN

A consistency check

There was another talk I gave a while ago. It started from basic facts about L and C, explained the j notation and ended with an explanation of the L antenna tuner, something we'll be using here, too. The talk was in Germain. Slides are available at https://dj3ei.famsik.de/2022-vortrag-hamradio/.

For that talk, my friend Wolfgang, DK2FQ simulated a dipole of 2 x 5 m made of 1.6 mm diameter copper wire, 10 m above average ground, at a frequency of 7.05 MHz. He used the MMANA simulation program, which calculated an impedance of 14.04-1001j.

As a sanity check, let us compare what our simulation yields:

In [4]:
from antenna_simulation_driver import run_nec2pp, Nec2ppOutput, RadiationPatternRay

trial_sim_result = run_nec2pp(generate_input(
    10, 7.05e6, height_base, 1.6e-3, *GROUNDS["average"], normal_segments_count
))
print(
    f"Center-fed 2 x 5 m dipole {height_base} m above average ground (diel. const. 13, conductivity 5e-3 S/m)\n"
    "from copper wire of diameter 1.6 mm, at 7050 kHz.\n"
)
print("Impedance in Ω according to MMANA simulated by DK2FQ: 14.04-1001j,\n"
      "here according to NEC2: ", trial_sim_result.input_and_impedance.impedance)
Center-fed 2 x 5 m dipole 10 m above average ground (diel. const. 13, conductivity 5e-3 S/m)
from copper wire of diameter 1.6 mm, at 7050 kHz.

Impedance in Ω according to MMANA simulated by DK2FQ: 14.04-1001j,
here according to NEC2:  (14.128-1028.9j)
In [5]:
# It is easy to stand tall on shoulders of giants.
# This pulls in some stuff we'll be using throughout:

import pandas as pd
import matplotlib.pyplot as plt
import scipy.optimize
In [6]:
# Finding free space resonant dipole.
from typing import Callable, Tuple

sim_count: int = 0

def find_resonant_dipole_parameter(
    parameter_to_ant_simulation_input: Callable[[float],str],
    parameter_min, parameter_max, xtol: float = 1e-4
) -> Tuple[float, int]:
    def null_me(parameter: float) -> float:
        global sim_count
        sim_count += 1
        return run_nec2pp(parameter_to_ant_simulation_input(parameter)).input_and_impedance.impedance.imag
    return (
        scipy.optimize.toms748(null_me, parameter_min, parameter_max, xtol = xtol, disp=True),
        sim_count
    )

freespace_length, sims = find_resonant_dipole_parameter(
    lambda l: generate_input(l, f_base, 0.0, wire_diameter_base, None, None, normal_segments_count),
    c/f_base/2*0.93, c/f_base/2
)

freespace_length, sim_count
Out[6]:
(np.float64(20.562973460021233), 9)
In [7]:
import math

# I gauge DX-performance of an antenna by the EIRP as follows:
# Assuming the reflecting ionosphere to be at a certain height.
ionosphere_height = 400e3
# I want to reach a faraway station at a certain distance.
distance_faraway_station = 2000e3
# I calculate the required altitude angle (above horizon, initially in radians):
def calculate_altitude_angle(ionosphere_height: float, distance_faraway_station: float) -> float:
    from math import pi, sqrt, cos, acos
    earth_radius = 6371e3
    # Consider this triangle:
    # line a: connects the transmitting antenna with the earth's center
    # line b: line the radiation ray (for simplicity, assumed to be straight)
    #         travels until it hits the ionosphere (for simplicity, assumed to be a point)
    # line c: line connecting that point in the ionosphere with the earth's center
    a = earth_radius
    c = earth_radius + ionosphere_height

    # Angle at earth center, in radians:
    beta = distance_faraway_station / 2 / earth_radius

    # Law of cosines:
    b = sqrt(a**2 + c**2 - 2*a*c*cos(beta))

    # Angle between a and b:
    gamma = acos((a**2 + b**2 - c**2)/(2*a*b))

    # Altitude (angle between ray and line to horizon)
    return gamma - pi/2

altitude_angle = calculate_altitude_angle(ionosphere_height, distance_faraway_station)
altitude_angle_degree = altitude_angle * 180 / math.pi
altitude_angle_degree    
Out[7]:
16.661261309298848
In [8]:
def loss_db(loss: float) -> float:
    return -10 * math.log(loss) / math.log(10)

loss_db(0.5)
Out[8]:
3.0102999566398116
In [9]:
from dataclasses import dataclass

@dataclass
class ResultsAtHeight:
    height: float
    resonant_length: float
    impedance: complex
    loss_dB: float
    gain_up: float
    gain_dx: float
    sims_needed: int

def calc_for_height(
    height: float,
    ground_diel: Optional[float],
    ground_cond: Optional[float],
    altitude_angle_degree_wanted: float,
) -> ResultsAtHeight:
    resonant_length, sims_for_resonant_length = find_resonant_dipole_parameter(
        lambda l: generate_input(
            l,
            f_base,
            height,
            wire_diameter_base,
            ground_diel,
            ground_cond,
            normal_segments_count
        ), freespace_length * 0.9, freespace_length * 1.1
    )
    
    antenna_at_height_result = run_nec2pp(
        generate_input(
            resonant_length,
            f_base,
            height,
            wire_diameter_base,
            ground_diel,
            ground_cond,
            normal_segments_count,
            rp_line="RP 0 91 361 1001 0.0 0.0 1 1 0.0 0.0"
        )
    )
    # with open("dipole.out","w") as dipf:
    #     dipf.write(antenna_at_height_result.raw_output)

    result = ResultsAtHeight(
        height = height,
        resonant_length = resonant_length,
        impedance = antenna_at_height_result.input_and_impedance.impedance,
        loss_dB = loss_db(antenna_at_height_result.average_gain.average_power_gain / 2),
        gain_up = antenna_at_height_result.radiation_pattern.closest_ray(90, 0).power_gain_total,
        gain_dx = antenna_at_height_result.radiation_pattern.closest_ray(90, 90 - altitude_angle_degree).power_gain_total,
        sims_needed = sims_for_resonant_length + 1
    )
    return result

# calc_for_height(10, *GROUNDS["average"], 10)
In [10]:
# Prepare data:
import itertools
import multiprocessing

heights =  list(itertools.chain((0.1 * h for h in range(20,100)), (10 + 0.25 * h for h in range(0, 361))))

def calculate_height_avg_ground(h: float) -> ResultsAtHeight:
    return calc_for_height(h, *GROUNDS["average"], altitude_angle_degree)

def calculate_height_city_ground(h: float) -> ResultsAtHeight:
    return calc_for_height(h, *GROUNDS["city"], altitude_angle_degree)

start_ts = datetime.datetime.now(datetime.timezone.utc)

with multiprocessing.Pool() as pool:
    avg_data = pool.map(calculate_height_avg_ground, heights)
    city_data = pool.map(calculate_height_city_ground, heights)
    
end_ts = datetime.datetime.now(datetime.timezone.utc)
len(heights)
Out[10]:
441
In [11]:
import os
number_of_simulations = \
    sum((d.sims_needed for d in avg_data)) + sum((d.sims_needed for d in city_data))
seconds_avg = (end_ts-start_ts).total_seconds()
print(
    f"{number_of_simulations} simulations done in {seconds_avg:.1f} seconds,"
    f"so {number_of_simulations/seconds_avg:.1f} sims/s or "
    f"{seconds_avg*1e3/number_of_simulations:.1f} ms per sim "
    f"(on a machine claiming {os.process_cpu_count()} CPUs)."
)
614284 simulations done in 451.1 seconds,so 1361.8 sims/s or 0.7 ms per sim (on a machine claiming 8 CPUs).
In [12]:
df = pd.DataFrame(
    {
        "resonant length avg": [100 * r.resonant_length / freespace_length for r in avg_data],
        "resonant length city": [100 * r.resonant_length / freespace_length for r in city_data],
        "loss / dB avg": [r.loss_dB for r in avg_data],
        "loss / dB city": [r.loss_dB for r in city_data],
        "impedance avg": [r.impedance.real for r in avg_data],
        "impedance city": [r.impedance.real for r in city_data],
        "gain NVIS dB avg": [r.gain_up for r in avg_data],
        "gain NVIS dB city": [r.gain_up for r in city_data],
        "gain DX dB avg": [r.gain_dx for r in avg_data],
        "gain DX dB city": [r.gain_dx for r in city_data],
    },
    index=heights
)
selected_heights = [(i <= 100 and i%10 == 0) or i%20 == 0 for i in range(0, len(df))]
df.iloc[selected_heights].round(2)
Out[12]:
resonant length avg resonant length city loss / dB avg loss / dB city impedance avg impedance city gain NVIS dB avg gain NVIS dB city gain DX dB avg gain DX dB city
2.0 99.20 99.14 7.85 6.91 57.48 80.90 -0.01 0.31 -9.17 -7.08
3.0 99.21 99.35 5.71 5.59 54.02 73.88 2.36 1.78 -6.63 -5.42
4.0 99.10 99.39 4.13 4.61 53.44 71.59 4.01 2.82 -4.73 -4.11
5.0 98.91 99.37 3.11 3.85 56.20 71.36 5.01 3.57 -3.42 -3.04
6.0 98.78 99.35 2.43 3.28 60.69 72.58 5.61 4.06 -2.46 -2.15
7.0 98.73 99.36 1.97 2.86 66.06 74.60 5.92 4.35 -1.70 -1.40
8.0 98.75 99.41 1.66 2.55 71.68 76.95 6.03 4.46 -1.06 -0.74
9.0 98.86 99.49 1.45 2.32 77.08 79.20 5.99 4.44 -0.49 -0.14
10.0 99.03 99.60 1.30 2.15 81.85 81.14 5.83 4.28 0.05 0.42
12.5 99.64 99.95 1.10 1.93 89.44 83.50 4.88 3.35 1.35 1.72
15.0 100.26 100.26 1.07 1.89 89.33 81.90 2.98 1.60 2.70 2.97
20.0 100.69 100.36 1.27 2.09 73.96 72.28 -6.47 -1.86 5.43 5.19
25.0 99.96 99.87 1.42 2.14 64.26 68.61 3.13 2.96 7.18 6.44
30.0 99.52 99.73 1.25 1.94 72.94 75.05 6.27 4.69 7.28 6.61
35.0 99.93 100.04 1.10 1.80 81.44 78.74 4.41 2.71 7.08 6.59
40.0 100.36 100.21 1.15 1.88 76.87 74.77 -3.80 -1.86 7.19 6.59
45.0 100.12 100.02 1.28 1.99 69.34 71.12 0.95 1.79 7.03 6.12
50.0 99.74 99.83 1.24 1.93 71.57 73.52 6.03 4.66 5.77 4.79
55.0 99.86 99.96 1.13 1.82 78.04 76.94 5.34 3.55 3.67 2.78
60.0 100.19 100.14 1.13 1.84 77.41 75.60 -0.96 -1.02 1.05 0.10
65.0 100.16 100.06 1.23 1.94 71.80 72.47 -1.68 0.52 -3.14 -4.12
70.0 99.86 99.89 1.24 1.92 71.42 73.03 5.47 4.37 -12.91 -9.71
75.0 99.84 99.93 1.15 1.84 76.14 75.83 5.93 4.15 -8.48 -5.72
80.0 100.10 100.08 1.13 1.83 77.33 75.83 1.26 0.17 -1.48 -0.78
85.0 100.16 100.08 1.20 1.91 73.33 73.36 -4.84 -0.75 2.42 2.44
90.0 99.94 99.94 1.23 1.92 71.65 72.97 4.60 3.86 4.78 4.46
95.0 99.85 99.92 1.17 1.85 74.90 75.09 6.24 4.54 6.05 5.64
100.0 100.04 100.04 1.14 1.83 76.97 75.79 2.95 1.38 6.84 6.39
In [13]:
# Resonace length as a function of height,
# in percent of resonant length of the same wire in free space:

ax = df[["resonant length avg", "resonant length city"]].plot(
    figsize=(12,8),
    grid=True,
    subplots=False,
    lw=7,
    fontsize=21
)
ax.set_title("Resonant dipole length, in percent of resonant length in free space", fontsize=21)
ax.set_xlabel("Height above ground / m", fontsize=21)
ax.set_ylabel("%", fontsize=21)
pass
No description has been provided for this image
In [14]:
ax = df[["loss / dB avg", "loss / dB city"]].plot(
    figsize=(12,8),
    grid=True,
    subplots=False,
    lw=7,
    fontsize=21
)
ax.set_title("Overall dipole loss (wire + ground).", fontsize=21)
ax.set_xlabel("Height above ground / m", fontsize=21)
ax.set_ylabel("Loss / dB", fontsize=21)
df[["loss / dB avg", "loss / dB city"]].iloc[selected_heights].round(1)
Out[14]:
loss / dB avg loss / dB city
2.0 7.8 6.9
3.0 5.7 5.6
4.0 4.1 4.6
5.0 3.1 3.8
6.0 2.4 3.3
7.0 2.0 2.9
8.0 1.7 2.5
9.0 1.4 2.3
10.0 1.3 2.2
12.5 1.1 1.9
15.0 1.1 1.9
20.0 1.3 2.1
25.0 1.4 2.1
30.0 1.3 1.9
35.0 1.1 1.8
40.0 1.1 1.9
45.0 1.3 2.0
50.0 1.2 1.9
55.0 1.1 1.8
60.0 1.1 1.8
65.0 1.2 1.9
70.0 1.2 1.9
75.0 1.2 1.8
80.0 1.1 1.8
85.0 1.2 1.9
90.0 1.2 1.9
95.0 1.2 1.9
100.0 1.1 1.8
No description has been provided for this image
In [15]:
# Impedance plot.
# As for each height, we first adjust the dipole length for resonance,
# the imaginary path is always 0, so Z is real.

ax = df[["impedance avg", "impedance city"]].plot(
    figsize=(12,8),
    grid=True,
    subplots=False,
    lw=7,
    fontsize=21
)
ax.set_title("Impedance of resonant half-wave dipole", fontsize=21)
ax.set_xlabel("Height above ground / m", fontsize=21)
ax.set_ylabel("Z / Ω", fontsize=21)
df[["impedance avg", "impedance city"]].iloc[selected_heights].round(1)
Out[15]:
impedance avg impedance city
2.0 57.5 80.9
3.0 54.0 73.9
4.0 53.4 71.6
5.0 56.2 71.4
6.0 60.7 72.6
7.0 66.1 74.6
8.0 71.7 76.9
9.0 77.1 79.2
10.0 81.8 81.1
12.5 89.4 83.5
15.0 89.3 81.9
20.0 74.0 72.3
25.0 64.3 68.6
30.0 72.9 75.1
35.0 81.4 78.7
40.0 76.9 74.8
45.0 69.3 71.1
50.0 71.6 73.5
55.0 78.0 76.9
60.0 77.4 75.6
65.0 71.8 72.5
70.0 71.4 73.0
75.0 76.1 75.8
80.0 77.3 75.8
85.0 73.3 73.4
90.0 71.6 73.0
95.0 74.9 75.1
100.0 77.0 75.8
No description has been provided for this image
In [16]:
# How does the gain change with resonant dipole height?
# We are interested in a 2000 km QSO, which needs a fairly low radiation angle above horizon:

ax = df[["gain DX dB avg", "gain DX dB city"]].plot(
    figsize=(12,8),
    grid=True,
    subplots=False,
    lw=7,
    fontsize=21
)
ax.set_title(f"Gain for a {distance_faraway_station*1e-3:.0f} km QSO "
    f"(radiation {altitude_angle_degree:.0f}° above horizon).", fontsize=21)
ax.set_xlabel("Height above ground / m", fontsize=21)
ax.set_ylabel("Gain dBi", fontsize=21)
df[["gain DX dB avg", "gain DX dB city"]].iloc[selected_heights].round(1)
Out[16]:
gain DX dB avg gain DX dB city
2.0 -9.2 -7.1
3.0 -6.6 -5.4
4.0 -4.7 -4.1
5.0 -3.4 -3.0
6.0 -2.5 -2.2
7.0 -1.7 -1.4
8.0 -1.1 -0.7
9.0 -0.5 -0.1
10.0 0.0 0.4
12.5 1.4 1.7
15.0 2.7 3.0
20.0 5.4 5.2
25.0 7.2 6.4
30.0 7.3 6.6
35.0 7.1 6.6
40.0 7.2 6.6
45.0 7.0 6.1
50.0 5.8 4.8
55.0 3.7 2.8
60.0 1.0 0.1
65.0 -3.1 -4.1
70.0 -12.9 -9.7
75.0 -8.5 -5.7
80.0 -1.5 -0.8
85.0 2.4 2.4
90.0 4.8 4.5
95.0 6.0 5.6
100.0 6.8 6.4
No description has been provided for this image
In [17]:
# How does the gain change with resonant dipole height?
# Let us try NVIS this time, so gain straight up:

ax = df[["gain NVIS dB avg", "gain NVIS dB city"]].plot(
    figsize=(12,8),
    grid=True,
    subplots=False,
    lw=7,
    fontsize=21
)
ax.set_title("Gain for NVIS QSOs (radiation straight up).", fontsize=21)
ax.set_xlabel("Height above ground / m", fontsize=21)
ax.set_ylabel("Gain dB EIRP", fontsize=21)
df[["gain NVIS dB avg", "gain NVIS dB city"]].iloc[selected_heights].round(1)
Out[17]:
gain NVIS dB avg gain NVIS dB city
2.0 -0.0 0.3
3.0 2.4 1.8
4.0 4.0 2.8
5.0 5.0 3.6
6.0 5.6 4.1
7.0 5.9 4.4
8.0 6.0 4.5
9.0 6.0 4.4
10.0 5.8 4.3
12.5 4.9 3.4
15.0 3.0 1.6
20.0 -6.5 -1.9
25.0 3.1 3.0
30.0 6.3 4.7
35.0 4.4 2.7
40.0 -3.8 -1.9
45.0 1.0 1.8
50.0 6.0 4.7
55.0 5.3 3.6
60.0 -1.0 -1.0
65.0 -1.7 0.5
70.0 5.5 4.4
75.0 5.9 4.2
80.0 1.3 0.2
85.0 -4.8 -0.8
90.0 4.6 3.9
95.0 6.2 4.5
100.0 3.0 1.4
No description has been provided for this image
In [18]:
datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S Z")
Out[18]:
'2026-09-04 15:23:55 Z'

Other material

This is a part of the showcase for my Python software antenna-simulation-driver. It is intended to provide an accessible and smooth entrance to that software.

For more similar material, see that showcase. It also contains an optimization example: An off-the-shelve optimization algorithm finds the ZS6BKW multiband antenna.

If you want to download and run this Jupyter notebook on your own computer, you'll need nec2++ installed (see the antenna-simulation-driver README), and the Jupyter "usual suspects":

numpy
notebook
pandas
matplotlib
scipy

In case you need to know (you probably don't), this ran on Python 3.13.5 and the versions of the pieces of software used and the dependencies pulled in by them were, in requirements.txt format:

antenna-simulation-driver==0.3.0
anyio==4.14.2
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
arrow==1.4.0
asttokens==3.0.2
async-lru==2.3.0
attrs==26.1.0
babel==2.18.0
beautifulsoup4==4.15.0
bleach==6.4.0
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
comm==0.2.3
contourpy==1.3.3
cycler==0.12.1
debugpy==1.8.21
decorator==5.3.1
defusedxml==0.7.1
executing==2.2.1
fastjsonschema==2.22.2
fonttools==4.63.0
fqdn==1.5.1
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.19
ipykernel==7.3.0
ipython==9.16.1
ipython-pygments-lexers==1.1.1
isoduration==20.11.0
jedi==0.20.0
jinja2==3.1.6
json5==0.15.0
jsonpointer==3.1.1
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
jupyter-builder==1.2.2
jupyter-client==8.9.1
jupyter-core==5.9.1
jupyter-events==0.12.1
jupyter-lsp==2.3.1
jupyter-server==2.20.0
jupyter-server-terminals==0.5.4
jupyterlab==4.6.3
jupyterlab-pygments==0.3.0
jupyterlab-server==2.28.0
kiwisolver==1.5.0
lark==1.3.1
markupsafe==3.0.3
matplotlib==3.11.1
matplotlib-inline==0.2.2
mistune==3.3.4
nbclient==0.11.0
nbconvert==7.17.1
nbformat==5.11.1
nest-asyncio2==1.7.2
notebook==7.6.2
notebook-shim==0.2.4
numpy==2.5.2
packaging==26.3
pandas==3.0.5
pandocfilters==1.5.1
parso==0.8.7
pexpect==4.9.0
pillow==12.3.0
platformdirs==4.11.3
prometheus-client==0.26.0
prompt-toolkit==3.0.53
psutil==7.2.2
ptyprocess==0.7.0
pure-eval==0.2.3
pycparser==3.0
pygments==2.21.0
pyparsing==3.3.2
python-dateutil==2.9.0.post0
python-json-logger==4.2.0
pyyaml==6.0.3
pyzmq==27.1.0
referencing==0.37.0
requests==2.34.2
rfc3339-validator==0.1.4
rfc3986-validator==0.1.1
rfc3987-syntax==1.1.0
rpds-py==2026.6.3
scipy==1.18.0
send2trash==2.1.0
setuptools==82.0.1
six==1.17.0
soupsieve==2.9.2
stack-data==0.6.3
terminado==0.18.1
tinycss2==1.5.1
tornado==6.5.8
traitlets==5.16.1
typing-extensions==4.16.0
tzdata==2026.3
uri-template==1.3.0
urllib3==2.7.0
wcwidth==0.8.2
webcolors==25.10.0
webencodings==0.6.1
websocket-client==1.9.0