Script to compute harmonics automatically (Python)

This script:

  • Lets you enter any fundamental frequency in MHz
  • Computes 1st–4th harmonics
  • Optionally lets you pick from a small band preset list
  • Prints a clean table

Save as harmonics.py and run with python3 harmonics.py.

#!/usr/bin/env python3
"""
harmonics.py - simple amateur radio harmonic calculator

- Enter a fundamental frequency (MHz), get 1st–4th harmonics.
- Or pick from common band center presets.

Formula used:
    f_n = n * f_1
where f_1 is the fundamental and n is the harmonic order (1,2,3,...).
"""

import sys

BAND_PRESETS_MHZ = {
    "160m": 1.85,
    "80m": 3.65,
    "40m": 7.10,
    "30m": 10.15,
    "20m": 14.20,
    "17m": 18.10,
    "15m": 21.25,
    "12m": 24.95,
    "10m": 28.50,
    "6m": 50.25,
    "2m": 146.00,
    "70cm": 440.00,
}


def compute_harmonics(fundamental_mhz: float, max_order: int = 4):
    """Return a list of (order, frequency_MHz) tuples."""
    return [(n, n * fundamental_mhz) for n in range(1, max_order + 1)]


def print_table(label: str, fundamental_mhz: float, max_order: int = 4):
    print(f"\nHarmonics for {label} (fundamental = {fundamental_mhz:.3f} MHz)")
    print("-" * 60)
    print(f"{'Order':>5} | {'Frequency (MHz)':>15}")
    print("-" * 60)

    for n, f_mhz in compute_harmonics(fundamental_mhz, max_order):
        print(f"{n:>5} | {f_mhz:15.3f}")

    print("-" * 60)


def choose_preset():
    print("Available band presets:")
    for name, freq in BAND_PRESETS_MHZ.items():
        print(f"  {name:<5} -> {freq:.3f} MHz")

    choice = input("\nType a band name (e.g. 40m, 20m, 2m, 70cm) or press Enter to skip: ").strip()
    if not choice:
        return None, None

    key = choice.lower()
    # normalize some common variations
    key = key.replace(" ", "")
    if key in BAND_PRESETS_MHZ:
        return choice, BAND_PRESETS_MHZ[key]

    print(f"Unknown band preset '{choice}'.")
    return None, None


def main():
    # If user passes a frequency on the command line, use that.
    if len(sys.argv) > 1:
        try:
            f_mhz = float(sys.argv[1])
        except ValueError:
            print("Usage: harmonics.py [fundamental_MHz]")
            sys.exit(1)
        print_table(f"{f_mhz:.3f} MHz", f_mhz)
        sys.exit(0)

    # Interactive mode
    label, preset_freq = choose_preset()

    if preset_freq is not None:
        print_table(label, preset_freq)
    else:
        # Ask for a custom fundamental
        while True:
            s = input("\nEnter fundamental frequency in MHz (e.g. 7.1): ").strip()
            try:
                f_mhz = float(s)
                break
            except ValueError:
                print("Please enter a valid number, e.g. 14.2")

        print_table(f"{f_mhz:.3f} MHz", f_mhz)


if __name__ == "__main__":
    main()

Usage examples

  • Compute harmonics for 7.1 MHz directly:
python3 harmonics.py 7.1
  • Run interactively, pick 40m from the preset list, or type any custom value:
python3 harmonics.py

Below is an enhanced version that will Flag when a harmonic falls inside another ham band

Add LPF design suggestions (e.g., recommended cutoff for each band)

Export results to CSV for your station documentation

Changes:

  • Added a band edge table (approx. US allocations, MHz).
  • New function find_band(freq_mhz) returns the band name if freq is inside one.
  • Output table now has an “In Band?” column and marks things like
    → 2nd harmonic falls in 20m.
#!/usr/bin/env python3
"""
harmonics.py - amateur radio harmonic calculator with band awareness

- Enter a fundamental frequency (MHz), get 1st–4th harmonics.
- Or pick from common band center presets.
- Flags when any harmonic falls inside another amateur band.

Formula:
    f_n = n * f_1
where f_1 is the fundamental and n is the harmonic order (1,2,3,...).
"""

import sys

# Approximate US band centers for presets (MHz)
BAND_PRESETS_MHZ = {
    "160m": 1.85,
    "80m": 3.65,
    "40m": 7.10,
    "30m": 10.15,
    "20m": 14.20,
    "17m": 18.10,
    "15m": 21.25,
    "12m": 24.95,
    "10m": 28.50,
    "6m": 50.25,
    "2m": 146.00,
    "70cm": 440.00,
}

# Approximate US amateur band edges (MHz)
# (You can tweak these to your region or exact edges if you want.)
BAND_EDGES_MHZ = [
    ("160m", 1.8, 2.0),
    ("80m", 3.5, 4.0),
    ("40m", 7.0, 7.3),
    ("30m", 10.1, 10.15),
    ("20m", 14.0, 14.35),
    ("17m", 18.068, 18.168),
    ("15m", 21.0, 21.45),
    ("12m", 24.89, 24.99),
    ("10m", 28.0, 29.7),
    ("6m", 50.0, 54.0),
    ("2m", 144.0, 148.0),
    ("70cm", 420.0, 450.0),
]


def compute_harmonics(fundamental_mhz: float, max_order: int = 4):
    """Return a list of (order, frequency_MHz) tuples."""
    return [(n, n * fundamental_mhz) for n in range(1, max_order + 1)]


def find_band(freq_mhz: float):
    """Return band name if freq is inside any defined band, else None."""
    for name, low, high in BAND_EDGES_MHZ:
        if low <= freq_mhz <= high:
            return name
    return None


def find_home_band(fundamental_mhz: float):
    """Try to infer which band the fundamental itself is in."""
    return find_band(fundamental_mhz)


def print_table(label: str, fundamental_mhz: float, max_order: int = 4):
    home_band = find_home_band(fundamental_mhz)

    print(f"\nHarmonics for {label} (fundamental = {fundamental_mhz:.3f} MHz)")
    if home_band:
        print(f"Fundamental appears to be in the {home_band} band.")
    print("-" * 80)
    print(f"{'Order':>5} | {'Frequency (MHz)':>15} | {'In Band?':<25}")
    print("-" * 80)

    for n, f_mhz in compute_harmonics(fundamental_mhz, max_order):
        band = find_band(f_mhz)
        note = ""
        if band:
            if band == home_band:
                note = f"in {band} (same band)"
            else:
                note = f"*** falls in {band} ***"
        print(f"{n:>5} | {f_mhz:15.3f} | {note:<25}")

    print("-" * 80)
    print("NOTE: Bands and edges are approximate; adjust BAND_EDGES_MHZ for your region.\n")


def choose_preset():
    print("Available band presets:")
    for name, freq in BAND_PRESETS_MHZ.items():
        print(f"  {name:<5} -> {freq:.3f} MHz")

    choice = input(
        "\nType a band name (e.g. 40m, 20m, 2m, 70cm) or press Enter to skip: "
    ).strip()
    if not choice:
        return None, None

    key = choice.lower().replace(" ", "")
    if key in BAND_PRESETS_MHZ:
        return choice, BAND_PRESETS_MHZ[key]

    print(f"Unknown band preset '{choice}'.")
    return None, None


def main():
    # If user passes a frequency on the command line, use that.
    if len(sys.argv) > 1:
        try:
            f_mhz = float(sys.argv[1])
        except ValueError:
            print("Usage: harmonics.py [fundamental_MHz]")
            sys.exit(1)
        print_table(f"{f_mhz:.3f} MHz", f_mhz)
        sys.exit(0)

    # Interactive mode
    label, preset_freq = choose_preset()

    if preset_freq is not None:
        print_table(label, preset_freq)
    else:
        # Ask for a custom fundamental
        while True:
            s = input("\nEnter fundamental frequency in MHz (e.g. 7.1): ").strip()
            try:
                f_mhz = float(s)
                break
            except ValueError:
                print("Please enter a valid number, e.g. 14.2")

        print_table(f"{f_mhz:.3f} MHz", f_mhz)


if __name__ == "__main__":
    main()

Example

For 7.1 MHz (40m):

python3 harmonics.py 7.1

You’ll see lines like:

    2 |         14.200 | *** falls in 20m ***
    3 |         21.300 | *** falls in 15m ***
    4 |         28.400 | *** falls in 10m ***

Another enhancement toi the script provides the following

  • Add colorized output for terminals (e.g., red when it hits another band)
  • Dump results to CSV or Markdown for your station notebook.

Green = harmonic is in the same band as the fundamental

Red = harmonic falls in a different amateur band (potential interference)

No color if output is being piped/redirected (we auto-disable if not a TTY)

#!/usr/bin/env python3
"""
harmonics.py - amateur radio harmonic calculator with band awareness + colors

- Enter a fundamental frequency (MHz), get 1st–4th harmonics.
- Or pick from common band center presets.
- Flags (and color-codes) when any harmonic falls inside an amateur band.

Formula:
    f_n = n * f_1
where f_1 is the fundamental and n is the harmonic order (1,2,3,...).
"""

import sys

# Approximate US band centers for presets (MHz)
BAND_PRESETS_MHZ = {
    "160m": 1.85,
    "80m": 3.65,
    "40m": 7.10,
    "30m": 10.15,
    "20m": 14.20,
    "17m": 18.10,
    "15m": 21.25,
    "12m": 24.95,
    "10m": 28.50,
    "6m": 50.25,
    "2m": 146.00,
    "70cm": 440.00,
}

# Approximate US amateur band edges (MHz)
BAND_EDGES_MHZ = [
    ("160m", 1.8, 2.0),
    ("80m", 3.5, 4.0),
    ("40m", 7.0, 7.3),
    ("30m", 10.1, 10.15),
    ("20m", 14.0, 14.35),
    ("17m", 18.068, 18.168),
    ("15m", 21.0, 21.45),
    ("12m", 24.89, 24.99),
    ("10m", 28.0, 29.7),
    ("6m", 50.0, 54.0),
    ("2m", 144.0, 148.0),
    ("70cm", 420.0, 450.0),
]

# ----- ANSI color helpers ---------------------------------------------------

USE_COLOR = sys.stdout.isatty()  # disable colors automatically if piped


def color(text: str, code: str) -> str:
    if not USE_COLOR:
        return text
    return f"\033[{code}m{text}\033[0m"


def red(text: str) -> str:
    return color(text, "31")


def green(text: str) -> str:
    return color(text, "32")


def yellow(text: str) -> str:
    return color(text, "33")


def bold(text: str) -> str:
    return color(text, "1")


# ----- Core functions -------------------------------------------------------


def compute_harmonics(fundamental_mhz: float, max_order: int = 4):
    """Return a list of (order, frequency_MHz) tuples."""
    return [(n, n * fundamental_mhz) for n in range(1, max_order + 1)]


def find_band(freq_mhz: float):
    """Return band name if freq is inside any defined band, else None."""
    for name, low, high in BAND_EDGES_MHZ:
        if low <= freq_mhz <= high:
            return name
    return None


def find_home_band(fundamental_mhz: float):
    """Try to infer which band the fundamental itself is in."""
    return find_band(fundamental_mhz)


def print_table(label: str, fundamental_mhz: float, max_order: int = 4):
    home_band = find_home_band(fundamental_mhz)

    header = f"Harmonics for {label} (fundamental = {fundamental_mhz:.3f} MHz)"
    print("\n" + bold(header))
    if home_band:
        print(f"Fundamental appears to be in the {home_band} band.")
    else:
        print("Fundamental is not inside any defined amateur band (check frequency).")

    print("-" * 80)
    print(f"{'Order':>5} | {'Frequency (MHz)':>15} | {'In Band?':<40}")
    print("-" * 80)

    for n, f_mhz in compute_harmonics(fundamental_mhz, max_order):
        band = find_band(f_mhz)

        if band:
            if band == home_band:
                note = green(f"in {band} (same band)")
            else:
                # big warning if it lands in some *other* band
                note = red(f"*** falls in {band}! ***")
        else:
            note = ""

        # make the frequency itself red/green if flagged
        freq_str = f"{f_mhz:15.3f}"
        if band and band != home_band:
            freq_str = red(freq_str)
        elif band and band == home_band:
            freq_str = green(freq_str)

        print(f"{n:>5} | {freq_str} | {note:<40}")

    print("-" * 80)
    print(
        "NOTE: Bands and edges are approximate; tweak BAND_EDGES_MHZ for your region.\n"
    )


def choose_preset():
    print("Available band presets:")
    for name, freq in BAND_PRESETS_MHZ.items():
        print(f"  {name:<5} -> {freq:.3f} MHz")

    choice = input(
        "\nType a band name (e.g. 40m, 20m, 2m, 70cm) or press Enter to skip: "
    ).strip()
    if not choice:
        return None, None

    key = choice.lower().replace(" ", "")
    if key in BAND_PRESETS_MHZ:
        return choice, BAND_PRESETS_MHZ[key]

    print(f"Unknown band preset '{choice}'.")
    return None, None


def main():
    # If user passes a frequency on the command line, use that.
    if len(sys.argv) > 1:
        try:
            f_mhz = float(sys.argv[1])
        except ValueError:
            print("Usage: harmonics.py [fundamental_MHz]")
            sys.exit(1)
        print_table(f"{f_mhz:.3f} MHz", f_mhz)
        sys.exit(0)

    # Interactive mode
    label, preset_freq = choose_preset()

    if preset_freq is not None:
        print_table(label, preset_freq)
    else:
        # Ask for a custom fundamental
        while True:
            s = input("\nEnter fundamental frequency in MHz (e.g. 7.1): ").strip()
            try:
                f_mhz = float(s)
                break
            except ValueError:
                print("Please enter a valid number, e.g. 14.2")

        print_table(f"{f_mhz:.3f} MHz", f_mhz)


if __name__ == "__main__":
    main()

Quick sanity check

python3 harmonics.py 7.1

You should see:

  • 7.1 MHz line in green (40m)
  • 14.2 / 21.3 / 28.4 MHz in red with messages like *** falls in 20m! ***

Let’s make the script “RF-engineer grade” ( this one took getting help from an RF Engineer friend of mine) and have it estimate how much extra attenuation is needed for each harmonic, given a simple LPF spec.

Below is a drop-in replacement that adds:

  • A Butterworth LPF model (order + cutoff frequency)
  • A target harmonic suppression spec (e.g. 43 dB, like typical -43 dBc)
  • New columns:
    • LPF Atten (dB) – estimated attenuation from the LPF
    • Extra Atten Needed (dB) – how much more you’d need beyond the LPF
  • Still colorizes harmonics that land in other bands.

Note: attenuation is in dB (a ratio), not dBm (an absolute power). I kept your wording in spirit but label the column correctly.

#!/usr/bin/env python3
"""
harmonics.py - amateur radio harmonic calculator with:
 - band awareness
 - colorized warnings
 - LPF attenuation estimate (Butterworth)
 - "extra attenuation needed" vs a target suppression spec

Formula:
    f_n = n * f_1
where f_1 is the fundamental and n is the harmonic order (1,2,3,...).
"""

import sys
import math

# Approximate US band centers for presets (MHz)
BAND_PRESETS_MHZ = {
    "160m": 1.85,
    "80m": 3.65,
    "40m": 7.10,
    "30m": 10.15,
    "20m": 14.20,
    "17m": 18.10,
    "15m": 21.25,
    "12m": 24.95,
    "10m": 28.50,
    "6m": 50.25,
    "2m": 146.00,
    "70cm": 440.00,
}

# Approximate US amateur band edges (MHz)
BAND_EDGES_MHZ = [
    ("160m", 1.8, 2.0),
    ("80m", 3.5, 4.0),
    ("40m", 7.0, 7.3),
    ("30m", 10.1, 10.15),
    ("20m", 14.0, 14.35),
    ("17m", 18.068, 18.168),
    ("15m", 21.0, 21.45),
    ("12m", 24.89, 24.99),
    ("10m", 28.0, 29.7),
    ("6m", 50.0, 54.0),
    ("2m", 144.0, 148.0),
    ("70cm", 420.0, 450.0),
]

# ----- ANSI color helpers ---------------------------------------------------

USE_COLOR = sys.stdout.isatty()  # disable colors automatically if piped


def color(text: str, code: str) -> str:
    if not USE_COLOR:
        return text
    return f"\033[{code}m{text}\033[0m"


def red(text: str) -> str:
    return color(text, "31")


def green(text: str) -> str:
    return color(text, "32")


def yellow(text: str) -> str:
    return color(text, "33")


def bold(text: str) -> str:
    return color(text, "1")


# ----- Core harmonic / band helpers ----------------------------------------


def compute_harmonics(fundamental_mhz: float, max_order: int = 4):
    """Return a list of (order, frequency_MHz) tuples."""
    return [(n, n * fundamental_mhz) for n in range(1, max_order + 1)]


def find_band(freq_mhz: float):
    """Return band name if freq is inside any defined band, else None."""
    for name, low, high in BAND_EDGES_MHZ:
        if low <= freq_mhz <= high:
            return name
    return None


def find_home_band(fundamental_mhz: float):
    """Try to infer which band the fundamental itself is in."""
    return find_band(fundamental_mhz)


# ----- LPF / attenuation helpers -------------------------------------------


def lpf_attenuation_db(freq_mhz: float, cutoff_mhz: float, order: int) -> float:
    """
    Magnitude attenuation (dB) of an Nth-order Butterworth LPF at freq_mhz.

    |H(jw)| = 1 / sqrt(1 + (f/fc)^(2N))
    Atten(dB) = -20 * log10(|H|)
    """
    if freq_mhz <= 0 or cutoff_mhz <= 0 or order <= 0:
        return 0.0

    ratio = freq_mhz / cutoff_mhz
    # for f << fc, attenuation ~ 0 dB
    H_mag = 1.0 / math.sqrt(1.0 + ratio ** (2 * order))
    atten = -20.0 * math.log10(H_mag)  # positive number
    return atten


def extra_atten_needed(atten_from_lpf_db: float, target_suppression_db: float) -> float:
    """
    Given attenuation from the LPF and a target suppression (e.g. 43 dB),
    return *additional* attenuation needed (>= 0).
    """
    needed = target_suppression_db - atten_from_lpf_db
    return max(0.0, needed)


# ----- Printing -------------------------------------------------------------


def print_table(
    label: str,
    fundamental_mhz: float,
    lpf_order: int = 5,
    lpf_cutoff_mhz: float | None = None,
    target_suppression_db: float = 43.0,
    max_order: int = 4,
):
    home_band = find_home_band(fundamental_mhz)
    if lpf_cutoff_mhz is None:
        lpf_cutoff_mhz = 1.5 * fundamental_mhz

    header = f"Harmonics for {label} (fundamental = {fundamental_mhz:.3f} MHz)"
    print("\n" + bold(header))

    if home_band:
        print(f"Fundamental appears to be in the {home_band} band.")
    else:
        print("Fundamental is not inside any defined amateur band (check frequency).")

    print(
        f"LPF model: {lpf_order}th-order Butterworth, fc = {lpf_cutoff_mhz:.3f} MHz, "
        f"target suppression = {target_suppression_db:.1f} dB"
    )

    print("-" * 108)
    print(
        f"{'Order':>5} | {'Frequency (MHz)':>15} | {'In Band?':<25}"
        f"| {'LPF Atten (dB)':>15} | {'Extra Atten Needed (dB)':>25}"
    )
    print("-" * 108)

    for n, f_mhz in compute_harmonics(fundamental_mhz, max_order):
        band = find_band(f_mhz)

        # Band note + coloring
        if band:
            if band == home_band:
                note = green(f"in {band} (same band)")
            else:
                note = red(f"*** falls in {band}! ***")
        else:
            note = ""

        freq_str = f"{f_mhz:15.3f}"
        if band and band != home_band:
            freq_str = red(freq_str)
        elif band and band == home_band:
            freq_str = green(freq_str)

        # LPF attenuation & extra needed
        atten_db = lpf_attenuation_db(f_mhz, lpf_cutoff_mhz, lpf_order)
        extra_db = extra_atten_needed(atten_db, target_suppression_db)

        atten_str = f"{atten_db:15.1f}"
        extra_str = f"{extra_db:25.1f}"

        # highlight if LPF is insufficient
        if extra_db > 0.1:
            extra_str = red(extra_str.strip()).rjust(25)
        else:
            extra_str = green(extra_str.strip()).rjust(25)

        print(
            f"{n:>5} | {freq_str} | {note:<25}"
            f"| {atten_str} | {extra_str}"
        )

    print("-" * 108)
    print(
        "NOTE: LPF attenuation is a rough Butterworth estimate; real filters will differ.\n"
        "      'Extra Atten Needed' is how much more you need beyond the LPF to hit the\n"
        "      target suppression (e.g. >43 dB down from the carrier).\n"
    )


# ----- Interactive helpers --------------------------------------------------


def choose_preset():
    print("Available band presets:")
    for name, freq in BAND_PRESETS_MHZ.items():
        print(f"  {name:<5} -> {freq:.3f} MHz")

    choice = input(
        "\nType a band name (e.g. 40m, 20m, 2m, 70cm) or press Enter to skip: "
    ).strip()
    if not choice:
        return None, None

    key = choice.lower().replace(" ", "")
    if key in BAND_PRESETS_MHZ:
        return choice, BAND_PRESETS_MHZ[key]

    print(f"Unknown band preset '{choice}'.")
    return None, None


def get_lpf_params(fundamental_mhz: float):
    """
    Ask the user for LPF order, cutoff, and target suppression.
    Defaults: order=5, fc=1.5 * f0, target=43 dB
    """
    print("\nLPF parameters (press Enter for defaults):")

    # Order
    s = input("  LPF order [default 5]: ").strip()
    lpf_order = 5
    if s:
        try:
            lpf_order = int(s)
        except ValueError:
            print("  Invalid order, using 5.")

    # Cutoff
    default_fc = 1.5 * fundamental_mhz
    s = input(f"  LPF cutoff in MHz [default {default_fc:.3f}]: ").strip()
    lpf_cutoff_mhz = default_fc
    if s:
        try:
            lpf_cutoff_mhz = float(s)
        except ValueError:
            print(f"  Invalid cutoff, using {default_fc:.3f} MHz.")

    # Target suppression
    s = input("  Target suppression in dB (e.g. 43) [default 43]: ").strip()
    target_suppression_db = 43.0
    if s:
        try:
            target_suppression_db = float(s)
        except ValueError:
            print("  Invalid value, using 43 dB.")

    return lpf_order, lpf_cutoff_mhz, target_suppression_db


# ----- Main -----------------------------------------------------------------


def main():
    # Command-line mode: freq only, use default LPF params
    if len(sys.argv) > 1:
        try:
            f_mhz = float(sys.argv[1])
        except ValueError:
            print("Usage: harmonics.py [fundamental_MHz]")
            sys.exit(1)

        print_table(
            f"{f_mhz:.3f} MHz",
            f_mhz,
            lpf_order=5,
            lpf_cutoff_mhz=1.5 * f_mhz,
            target_suppression_db=43.0,
        )
        sys.exit(0)

    # Interactive mode
    label, preset_freq = choose_preset()

    if preset_freq is not None:
        f_mhz = preset_freq
        lpf_order, fc_mhz, target_db = get_lpf_params(f_mhz)
        print_table(label, f_mhz, lpf_order, fc_mhz, target_db)
    else:
        # Ask for a custom fundamental
        while True:
            s = input("\nEnter fundamental frequency in MHz (e.g. 7.1): ").strip()
            try:
                f_mhz = float(s)
                break
            except ValueError:
                print("Please enter a valid number, e.g. 14.2")

        lpf_order, fc_mhz, target_db = get_lpf_params(f_mhz)
        print_table(f"{f_mhz:.3f} MHz", f_mhz, lpf_order, fc_mhz, target_db)


if __name__ == "__main__":
    main()

How it plays out

Example, 7.1 MHz with defaults:

python3 harmonics.py 7.1

You’ll see, for each harmonic:

  • Whether it falls in another band
  • Estimated LPF attenuation at that harmonic
  • How many more dB you’d need (if any) to hit your target suppression

Leave a Reply

Your email address will not be published. Required fields are marked *