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