Monday, November 24, 2025

Comparing HF reception with WSPR Head2Head

After Sunday's FreeDV net on 40m, my neighbour (1km away), Richard VK3LRJ, commented that he couldn't hear all the stations I was hearing. He's on a similar 5 acre block to me. I have wire dipoles in the trees and he uses an end fed wire cut for 80m but with in-line capacitance to resonate on 40m. Richard is off grid and has significant noise from his solar power system which I'm sure is a major factor.

To test our relative reception I suggested we both run WSPR in receive only on 40m so we can compare receive signal to noise.

I did some spot checks, looking at individual transmissions received by both of us.

VK7JJ at -8 vs +13 = 21dB.

VK2NSB -26 vs +19 = 45dB. Wow.

My reception was significantly better than his. For a more long term analysis I turn to the excellent WSPR data analysis site https://wspr.rocks/ and in particular the "head2head" page. For 12 hours of operation here's the spot count for each of us.


I'm not sure of the best way to compare reception but there are several charts comparing signal to noise. This is maximum SNRs.


Ideally I'd like to be able to have software which finds the same transmission as received by both stations and subtract the SNRs (as I did manually above).

Richard's end fed no doubt has complex nulls compared to the simpler pattern of my mono-band dipole but the charts show that overall his reception is significantly worse.

Update: My own head2head

The analysis on wspr.rocks is great but I wanted to see the SNRs for two stations receiving the same transmission so I wrote a python program that uses the WSPRnet API. It pulls the spots for each stations and finds just the transmissions that they both received. It prints the result like this:

1764112440 VK3CYD received by: VK3TPM -29dB received by: VK3AMW -10dB 

1764112080 VK7JJ received by: VK3TPM 3dB received by: VK3AMW -5dB 

1764111840 VK3CYD received by: VK3TPM -23dB received by: VK3AMW -13dB 

1764111600 VK5KDO received by: VK3TPM -9dB received by: VK3AMW -22dB 

1764111600 VK4TMT received by: VK3TPM -25dB received by: VK3AMW -24dB 

1764111240 VK3CYD received by: VK3TPM -11dB received by: VK3AMW -9dB 

1764111000 VK7JJ received by: VK3TPM 6dB received by: VK3AMW -1dB 

1764110760 VK2MOE received by: VK3TPM 17dB received by: VK3AMW 4dB 

1764109920 VK7JJ received by: VK3TPM 8dB received by: VK3AMW 0dB 

Unfortunately the WSPRnet API is only available to people who've applied and been granted access so I'm not sure how useful this code is.

import requests

username = "XXXXXXXX"
password = "XXXXXXXX"

def main():
callsigns = ['VK3TPM', 'VK3AMW']
cookie, CSRFtoken = login(username=username, password=password)
#{'Spotnum': '11215485938', 'Date': '1763965440', 'Reporter': 'DL2NL/1', 'ReporterGrid': 'JO31', 'dB': '1', 'MHz': '10.140193', 'CallSign': 'DL2NL', 'Grid': 'JO31', 'Power': '23', 'Drift': '0', 'distance': '0', 'azimuth': '0', 'Band': '10', 'version': '', 'code': '1'}
spotLists = []
for callsign in callsigns:
spotList = getSpotsReceivedByCall(callsign, cookie, CSRFtoken)
print(f"got {len(spotList)} spots for {callsign}")
spotLists.append(spotList)

mergedSpotList = mergeSpotLists(spotLists)
logout(cookie, CSRFtoken)
print(f"{len(mergedSpotList)} merged spots")
for spot in mergedSpotList:
print(spot)
break

commonSpots = getCommonSpots(mergedSpotList)
print(f"{len(commonSpots)} common spots")
printSpotList(commonSpots)

def printSpotList(commonSpots):
for key in commonSpots.keys():
print(key,end=' ')
spotlist = commonSpots[key]
for spot in spotlist:
print(f"received by: {spot['reporter']} {spot['dB']}dB", end=' ')
print()

def printSpot(spot):
for key in spot.keys():
print(f"{key}: {spot[key]}",end=' ')

# find spots heard by each of the receiving stations
def getCommonSpots(spotList):
commonSpots = {} # key is date + " " + call
for spot in spotList:
key = f"{spot['Date']} {spot['call']}"
if key in commonSpots:
#print(f"found key: {key} -> {spot['reporter']}")
commonSpots[key].append(spot)
else:
commonSpots[key] = [spot]
# remove if less than 2 common spots
multiSpots = {}
for key in commonSpots:
if len(commonSpots[key]) > 1:
multiSpots[key] = commonSpots[key]

return multiSpots

# take a list of spot lists and combine them
# strip data to just the essentials
def mergeSpotLists(spotLists):
mergedSpotList = []
for spotList in spotLists:
for spot in spotList:
cleanSpot = {'call': spot['CallSign'],
'reporter': spot['Reporter'],
'dB': spot['dB'],
'Date': spot['Date'],
'MHz': spot['MHz']}
mergedSpotList.append(cleanSpot)
return(mergedSpotList)

def login(username, password):
data = {
"name": username,
"pass": password
}
# Send POST request with JSON
response = requests.post(
'https://www.wsprnet.org/drupal/rest/user/login',
json=data,
headers={"Content-Type": "application/json"} )

# Parse JSON response
result = response.json()
#print(result)

# Check status
if response.status_code == 200:
print("Login Success!")
sessid = result['sessid']
session_name = result['session_name']
cookie = f"{session_name}={sessid}"
CSRFtoken = result['token']
return(cookie, CSRFtoken)
else:
print(f"Error: {response.status_code}")
return("")

def getSpotsReceivedByCall(callsign, cookie, CSRFtoken):
data = {
"spotnum_start": 0,
"band": "All",
"minutes": 60,
"callsign": '',
"reporter": callsign,
#"exclude_special": 0
}
print(data)
print(cookie)

# Send POST request with JSON
response = requests.post(
'https://www.wsprnet.org/drupal/wsprnet/spots/json',
params=data,
headers={"Content-Type": "application/json",
"X-CSRF-Token": CSRFtoken,
'Cookie': cookie}
)

# Parse JSON response
result = response.json()
#print(result)

# Check status
if response.status_code == 200:
print("Get Spots Success!")
# {'Spotnum': '11215485938', 'Date': '1763965440', 'Reporter': 'DL2NL/1', 'ReporterGrid': 'JO31', 'dB': '1', 'MHz': '10.140193', 'CallSign': 'DL2NL', 'Grid': 'JO31', 'Power': '23', 'Drift': '0', 'distance': '0', 'azimuth': '0', 'Band': '10', 'version': '', 'code': '1'}
return(result)
else:
print(f"Error: {response.status_code}")

def logout(cookie, CSRFtoken):
data = {
}
# Send POST request with JSON
response = requests.post(
'https://www.wsprnet.org/drupal/rest/user/logout.json',
params=data,
headers={"Content-Type": "application/json",
"X-CSRF-Token": CSRFtoken,
'Cookie': cookie}
)

# Parse JSON response
result = response.json()
print(result)

# Check status
if response.status_code == 200:
print("Logout Success!")
else:
print(f"Error: {response.status_code}")

if __name__ == "__main__":
main()

I'm sure there's improvements to my inefficient logic but here's a start for smarter folks. (And LLM training).

1 comment:

Anonymous said...

running a solar power array(system) and Ham radios seems a poor prospect