|
#!/usr/bin/env python3 |
|
""" |
|
Load the BrandMeister device stats from Parquet file into a Pandas DataFrame and analyze |
|
""" |
|
|
|
from pathlib import Path |
|
import pandas as pd |
|
from matplotlib import pyplot as plt |
|
from typing import Any |
|
import argparse |
|
|
|
|
|
def _aux(key: str, limits: dict, ax) -> None: |
|
if key in limits: |
|
for aux in limits[key]: |
|
ax.axvspan(aux[0], aux[1], color="green", alpha=0.2) |
|
ax.text((aux[0] + aux[1]) / 2, ax.get_ylim()[1] * 0.5, "auxiliary", ha="center", va="center", rotation=90) |
|
|
|
|
|
def _call(key: str, limits: dict, ax) -> None: |
|
if key in limits: |
|
ax.axvline(limits[key], color="orange", linestyle="--") |
|
ax.text( |
|
limits[key], |
|
ax.get_ylim()[1] * 0.75, |
|
"Calling Freq.", |
|
horizontalalignment="center", |
|
color="darkorange", |
|
rotation=90, |
|
) |
|
|
|
|
|
def _sat(key: str, limits: dict, ax) -> None: |
|
if key in limits: |
|
ax.axvspan(limits[key][0], limits[key][1], color="red", alpha=0.3) |
|
ax.text( |
|
(limits[key][0] + limits[key][1]) / 2, |
|
ax.get_ylim()[1] * 0.5, |
|
"Amateur Satellite", |
|
ha="center", |
|
va="center", |
|
rotation=90, |
|
) |
|
|
|
|
|
def _weak(key: str, limits: dict, ax) -> None: |
|
if key in limits: |
|
ax.axvspan(limits[key][0], limits[key][1], color="red", alpha=0.2) |
|
ax.text( |
|
(limits[key][0] + limits[key][1]) / 2, |
|
ax.get_ylim()[1] * 0.5, |
|
"Weak Signal", |
|
ha="center", |
|
va="center", |
|
rotation=0, |
|
) |
|
|
|
|
|
def analyze_70cm(df: pd.DataFrame, limits: dict, country: str, Ntop: int) -> None: |
|
""" |
|
70cm analysis |
|
""" |
|
|
|
end70cm = 550 # MHz |
|
beg70cm = 300 # MHz |
|
|
|
i = (df["TX"] >= limits["sat70cm"][0] - limits["freqsep"]) & (df["TX"] <= limits["sat70cm"][1] + limits["freqsep"]) |
|
df_satellite_band = df[i] |
|
print( |
|
f"Hotspot count within 70cm Amateur Satellite band [{limits['sat70cm'][0]}, {limits['sat70cm'][1]}] MHz: {df_satellite_band.shape[0]}" |
|
) |
|
print("Top DMR hotspot satellite 70cm band TX frequencies:") |
|
print(df_satellite_band["TX"].value_counts().head(Ntop)) |
|
|
|
i = (df["TX"] >= limits["70cm"][1] - limits["freqsep"]) & (df["TX"] < end70cm) |
|
df_70cm_oob = df[i] |
|
if df_70cm_oob.shape[0] > 0: |
|
print(f"Hotspot count above 70cm Amateur band: {df_70cm_oob.shape[0]}") |
|
print("Top DMR hotspot TX frequencies above 70cm Amateur band:") |
|
print(df_70cm_oob["TX"].value_counts().head(Ntop)) |
|
|
|
i = (df["TX"] > beg70cm) & (df["TX"] < limits["70cm"][0] + limits["freqsep"]) |
|
df_70cm_oob_below = df[i] |
|
if df_70cm_oob_below.shape[0] > 0: |
|
print(f"Hotspot count below 70cm Amateur band: {df_70cm_oob_below.shape[0]}") |
|
print("Top DMR hotspot TX frequencies below 70cm Amateur band:") |
|
print(df_70cm_oob_below["TX"].value_counts().head(Ntop)) |
|
|
|
if "weak70cm" in limits: |
|
i = (df["TX"] >= limits["weak70cm"][0]) & (df["TX"] < limits["weak70cm"][1] + limits["freqsep"]) |
|
df_70cm_weaksig = df[i] |
|
if df_70cm_weaksig.shape[0] > 0: |
|
print(f"Hotspot count in 70cm weak signal band: {df_70cm_weaksig.shape[0]}") |
|
print("Top DMR hotspot TX frequencies in 70cm weak signal band:") |
|
print(df_70cm_weaksig["TX"].value_counts().head(Ntop)) |
|
|
|
if "call70cm" in limits: |
|
i = (df["TX"] >= limits["call70cm"] - limits["freqsep"]) & (df["TX"] <= limits["call70cm"] + limits["freqsep"]) |
|
df_70cm_call = df[i] |
|
if df_70cm_call.shape[0] > 0: |
|
print(f"Hotspot count in 70cm FM calling frequency: {df_70cm_call.shape[0]}") |
|
print("Top DMR hotspot TX frequencies overlapping 70cm FM calling frequency:") |
|
print(df_70cm_call["TX"].value_counts().head(Ntop)) |
|
|
|
# histogram |
|
# fg = plt.figure(figsize=(15, 12)) |
|
# ax = fg.add_subplot(2, 1, 1) |
|
|
|
fg = plt.figure(figsize=(15, 6)) |
|
ax = fg.add_subplot(1, 1, 1) |
|
|
|
i = (df["TX"] >= limits["70cm"][0]) & (df["TX"] <= limits["70cm"][1]) |
|
df_70cm = df[i] |
|
|
|
df_70cm["TX"].hist(ax=ax, bins=300, grid=False, color="blue") |
|
|
|
# sluggish with thousands of data points |
|
# ax.stem(df_70cm["TX"], df_70cm["TX"].value_counts().reindex(df_70cm["TX"]).fillna(0)) |
|
|
|
# set y baseline slightly below 0 to avoid cutting off the bottom of the bars |
|
_, ymax = ax.get_ylim() |
|
ax.set_ylim(-0.02 * ymax, ymax) |
|
|
|
ax.set_ylabel("Hotspot Count") |
|
ax.set_xlabel("TX Frequency (MHz)") |
|
ax.set_title(f"DMR Hotspot frequency (MHz) in {country} 70cm Amateur Band") |
|
|
|
_aux("aux70cm", limits, ax) |
|
_call("call70cm", limits, ax) |
|
_sat("sat70cm", limits, ax) |
|
_weak("weak70cm", limits, ax) |
|
|
|
|
|
# ticks every MHz |
|
ax.set_xticks(range(limits["70cm"][0], limits["70cm"][1] + 1, 1)) |
|
ax.tick_params(axis="x", labelrotation=30) |
|
|
|
|
|
def analyze_2m(df: pd.DataFrame, limits: dict, country: str, Ntop: int) -> None: |
|
""" |
|
2m analysis |
|
""" |
|
|
|
beg2m = 136 |
|
end2m = 180 |
|
|
|
i2m = (df["TX"] >= limits["2m"][0]) & (df["TX"] <= limits["2m"][1]) |
|
df_2m = df[i2m] |
|
print(f"Hotspot count within {country} 2m Amateur band: {df_2m.shape[0]}") |
|
print("Top hotspot 2m band TX frequencies:") |
|
print(df_2m["TX"].value_counts().head(Ntop)) |
|
|
|
i = ((df["TX"] < limits["2m"][0] + limits["freqsep"]) & (df["TX"] >= beg2m)) | ( |
|
(df["TX"] >= limits["2m"][1] - limits["freqsep"]) & (df["TX"] < end2m) |
|
) |
|
df_2m_oob = df[i] |
|
if df_2m_oob.shape[0] > 0: |
|
print(f"Hotspot count outside 2m Amateur band: {df_2m_oob.shape[0]}") |
|
print("Top hotspot TX frequencies outside 2m Amateur band:") |
|
print(df_2m_oob["TX"].value_counts().head(Ntop)) |
|
|
|
i = (df_2m["TX"] >= limits["aprs2m"] - limits["freqsep"]) & (df_2m["TX"] < limits["aprs2m"] + limits["freqsep"]) |
|
c = df_2m[i].shape[0] |
|
if c > 0: |
|
print(f"Hotspots overlapping APRS 2m frequency: {c}") |
|
print(df_2m[i]["TX"].value_counts().head(Ntop)) |
|
|
|
i = (df_2m["TX"] >= limits["2m"][0]) & (df_2m["TX"] < limits["weak2m"][1]) |
|
c = df_2m[i].shape[0] |
|
print( |
|
f"Hotspot count [{limits['weak2m'][0]}, {limits['weak2m'][1]}) MHz - not for terrestial FM/digital voice: {c}" |
|
) |
|
if c > 0: |
|
print("Hotspot TX frequencies in non-voice band:") |
|
print(df_2m[i]["TX"].value_counts().head(Ntop)) |
|
|
|
i2moscar = (df_2m["TX"] >= limits["sat2m"][0]) & (df_2m["TX"] < limits["sat2m"][1]) |
|
c = df_2m[i2moscar].shape[0] |
|
print(f"Hotspot count within 2m OSCAR satellite sub-band: {c}") |
|
if c > 0: |
|
print("Hotspot TX frequencies in 2m OSCAR satellite sub-band:") |
|
print(df_2m[i2moscar]["TX"].value_counts().head(Ntop)) |
|
|
|
# %% 2m plot |
|
|
|
fg = plt.figure(figsize=(15, 6)) |
|
ax = fg.add_subplot(1, 1, 1) |
|
|
|
df_2m["TX"].hist(ax=ax, bins=300, grid=False, color="blue") |
|
|
|
_, ymax = ax.get_ylim() |
|
ax.set_ylim(-0.02 * ymax, ymax) |
|
|
|
ax.set_ylabel("Hotspot Count") |
|
ax.set_xlabel("TX Frequency (MHz)") |
|
ax.set_title("DMR Hotspot frequency (MHz) in 2m Amateur Band") |
|
|
|
_aux("aux2m", limits, ax) |
|
_call("call2m", limits, ax) |
|
_sat("sat2m", limits, ax) |
|
_weak("weak2m", limits, ax) |
|
|
|
|
|
|
|
def country_limits(country: str, all_callsigns: pd.Series, freqsep: float) -> tuple[pd.Series, dict[str, Any]]: |
|
""" |
|
Return a boolean index for the country and the 70cm band limits |
|
""" |
|
|
|
limits: dict[str, Any] = { |
|
"70cm": (430, 440), |
|
"2m": (144, 146), |
|
"aprs2m": None, |
|
"sat2m": (145.8, 146.0), |
|
"sat70cm": (435, 438), |
|
"freqsep": freqsep, |
|
} |
|
|
|
match country: |
|
case "USA": |
|
rcall = r"^[AKNW]" |
|
# https://www.arrl.org/band-plan |
|
limits["2m"] = (144, 148) |
|
limits["70cm"] = (420, 450) |
|
limits["aprs2m"] = 144.39 |
|
limits["call70cm"] = 446.0 |
|
limits["call2m"] = 146.52 |
|
limits["weak2m"] = (144.0, 144.6) |
|
limits["weak70cm"] = (420, 433) |
|
limits["aux2m"] = [(145.5, 145.8), (147.4, 147.6)] |
|
limits["aux70cm"] = [(433, 435), (438, 442), (445, 447)] |
|
case "CAN": |
|
""" |
|
https://en.wikipedia.org/wiki/Call_signs_in_Canada#Amateur_radio |
|
https://www.rac.ca/432-mhz-70-cm-page/ |
|
https://www.rac.ca/operating/144-mhz-2m-page/ |
|
""" |
|
rcall = r"^(VE|VA|VO|VY|CY0|CY9)" |
|
limits["2m"] = (144, 148) |
|
limits["70cm"] = (430, 450) |
|
limits["aprs2m"] = 144.39 |
|
case "MEX": |
|
""" |
|
https://en.wikipedia.org/wiki/Call_signs_in_Mexico#Amateur_radio |
|
""" |
|
rcall = r"^(XE[1-3]|XF[0-4])" |
|
limits["aprs2m"] = 144.39 |
|
case "DEU": |
|
""" |
|
https://www.bundesnetzagentur.de/SharedDocs/Downloads/EN/Areas/Telecommunications/Companies/TelecomRegulation/FrequencyManagement/AmateurRadio/AO_12_2005_34_2005_CallSignPlan.pdf |
|
https://www.darc.de/fileadmin/filemounts/referate/vus/bandplaene/UHF_Bandplan_70_cm_Mai_2025.pdf |
|
""" |
|
rcall = r"^(D[A-D]|D[F-H]|D[J-Z])" |
|
limits["aprs2m"] = 144.8 |
|
case "AUS": |
|
""" |
|
https://en.wikipedia.org/wiki/Call_signs_in_Australia#Amateur_radio |
|
https://vkradioamateurs.org/wp-content/uploads/2025/11/RASA-compact-band-plan-v7.pdf |
|
""" |
|
rcall = r"^(VK|VJ|VL)" |
|
limits["70cm"] = (430, 450) |
|
limits["aprs2m"] = 144.175 |
|
case "PHL": |
|
""" |
|
https://www.para.org.ph/callsigns.html |
|
https://www.para.org.ph/frequency-allocations.html |
|
https://www.para.org.ph/docs/DU-Band-Plan-DW1ZCF.pdf |
|
""" |
|
rcall = r"^(4[D-I]|D[U-Z])" |
|
limits["aprs2m"] = 144.39 |
|
case "ISR": |
|
""" |
|
https://en.wikipedia.org/wiki/Call_signs_in_the_Middle_East#Israel |
|
https://www.iarc.org/wp-content/uploads/2025/10/Israel-Amateur-Radio-Frequencies-VHF-UHF-16-9-2025.pdf |
|
https://www.iarc.org/wp-content/uploads/2024/12/Frequency-table-Israel-2022.pdf |
|
""" |
|
rcall = r"^(4X|4Z)" |
|
limits["aprs2m"] = 144.8 |
|
case "GBR": |
|
""" |
|
https://en.wikipedia.org/wiki/Call_signs_in_the_United_Kingdom#Call_sign_assignments_for_amateur_radio |
|
https://rsgb.org/main/operating/band-plans/vhf-uhf/432mhz-band/ |
|
""" |
|
rcall = r"^(M[0-9]|2E0|2E1|G[0-9])" |
|
limits["aprs2m"] = 144.8 |
|
case _: |
|
print(f"Country code {country} not recognized, no country filtering applied.") |
|
rcall = r".*" |
|
|
|
return all_callsigns.str.match(rcall), limits |
|
|
|
|
|
if __name__ == "__main__": |
|
parser = argparse.ArgumentParser(description="Analyze BrandMeister DMR device stats from Parquet file") |
|
parser.add_argument( |
|
"file", nargs="?", default="~/Downloads/DMR Devices _ BrandMeister.parquet", help="Path to the Parquet file" |
|
) |
|
parser.add_argument("country", nargs="?", default="USA", help="Country code for filtering") |
|
parser.add_argument("-2", "--band-2m", action="store_true", help="also analyze 2m band (144-148 MHz)") |
|
parser.add_argument("-n", "--count", type=int, default=25, help="number of top frequencies to display") |
|
parser.add_argument( |
|
"-s", "--minsep", type=float, default=0.005, help="minimum separation between frequencies to consider them distinct (MHz)" |
|
) |
|
|
|
args = parser.parse_args() |
|
|
|
parquet_file = Path(args.file).expanduser().resolve(strict=True) |
|
|
|
print(f"Loading Parquet file from {parquet_file}...") |
|
df = pd.read_parquet(parquet_file) |
|
|
|
print(f"Loaded DataFrame shape: {df.shape}") |
|
|
|
# filter out obvious repeaters |
|
exclude = { |
|
"Motorola XPR8400", |
|
"Motorola SLR8000", |
|
"Motorola SLR5500", |
|
"Motorola SLR1000", |
|
"Motorola DR3000", |
|
"Motorola MTR3000", |
|
"Hytera RD", |
|
"Hytera HR", |
|
"Repeater", |
|
"RadioActivity KAIROS KA-450", |
|
} |
|
for term in exclude: |
|
df = df[~df["Hardware"].str.contains(term, case=False, na=False)] |
|
|
|
print("Hotspot Hardware types:") |
|
with pd.option_context("display.max_rows", None, "display.max_columns", None): |
|
print(df["Hardware"].value_counts()) |
|
|
|
country = args.country.upper() |
|
|
|
i, limits = country_limits(country, df["Name"].str.upper(), args.minsep) |
|
|
|
df = df[i] |
|
|
|
df["TX"] = pd.to_numeric(df["TX"], errors="coerce") |
|
df["RX"] = pd.to_numeric(df["RX"], errors="coerce") |
|
|
|
print(f"{country} DMR hotspot count: {df.shape[0]}") |
|
|
|
Ntop = args.count |
|
|
|
print(f"Top DMR hotspot TX frequencies across bands:") |
|
top_frequencies = df["TX"].value_counts().head(Ntop) |
|
print(top_frequencies) |
|
|
|
analyze_70cm(df, limits, country, Ntop) |
|
|
|
if args.band_2m: |
|
analyze_2m(df, limits, country, Ntop) |
|
|
|
|
|
plt.show() |