Skip to content

Instantly share code, notes, and snippets.

@scivision
Last active August 28, 2026 23:58
Show Gist options
  • Select an option

  • Save scivision/63b4e394bcd6b8a586fa0510bdabc3a5 to your computer and use it in GitHub Desktop.

Select an option

Save scivision/63b4e394bcd6b8a586fa0510bdabc3a5 to your computer and use it in GitHub Desktop.
Analyze Brandmeister DMR devices data, converting to Parquet first.

Analyze DMR ham radio hotspot statistics

With the continued growth of DMR as a voice and data mode for amateur radio, DMR hotspots are a popular way to access talkgroups without the need to tie up a community repeater. The DMR hotspot links a DMR mobile or portable radio to most other DMR radios, repeaters, hotspots over the internet. Think of a DMR hotspot like a networked repeater you controls, but without rebroadcasting your signal locally - just to and from other DMR systems over the internet. Hotspots might be used at a specific location, or might be used with celllar mobile network connectivity to be used on the go.

DMR hotspots typically have low transmit power (10 mW - 1000 mW) and are typically used with small rubber duck antennas. However, hotspots can be connected to high outdoor antennas. Also the radio used with the hotspot might be on an outdoor antenna and/or use a substantial transmit power. This means DMR hotspot users must be careful to select a proper frequency to avoid interference or illegal operation.

This code was a quick first pass to understand the extent of the problem, so that educational and public communications efforts know what to address first. Note that the claimed "TX" frequency published to the DMR server (like BrandMeister) may not be true - it's possible to not publish the actual frequency used by the hotspot. However, by default the actual center frequency is published to the DMR hotspot server.

Usage

It appears that the BrandMeister API doesn't allow to download active hotspots. Instead, we manually use a web browser to Devices, click Show ALL that takes several seconds to download about 10 megabytes of information, and then Save As HTML, which can take tens of seconds. It might be possible to automate that via Selenium, but we didn't try that yet.

  1. Download the BrandMeister Devices page and save as HTML - say "bm.html"
  2. Convert the large HTML file to Parquet format for vastly faster processing:
    python html2parquet.py bm.html
  3. Analyze the Parquet file and generate a report and plot:
    python device-stats.py bm.parquet
#!/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()
#!/usr/bin/env python3
"""
Convert saved HTML table from
https://brandmeister.network/#/devices
Show ALL into a Parquet file for fast loading.
"""
from pathlib import Path
import pandas as pd
import argparse
parser = argparse.ArgumentParser(description="Convert saved HTML from BrandMeister to Parquet")
parser.add_argument("file", nargs="?", default="~/Downloads/DMR Devices _ BrandMeister.html", help="Path to the HTML file")
args = parser.parse_args()
html_file = Path(args.file).expanduser().resolve(strict=True)
parquet_file = html_file.with_suffix(".parquet")
print(f"Reading HTML from {html_file}...")
dfs = pd.read_html(html_file, flavor="bs4")
df = dfs[0]
print(f"Saving {len(df)} rows to {parquet_file}...")
df.to_parquet(parquet_file, index=False)
print("Conversion to Parquet complete.")
[tool.black]
line_length = 132
[tool.mypy]
files = ["."]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment