fix(webpub): the Distance column was empty for every QSO logged here
Nothing computes a distance when a QSO is logged. The DISTANCE field is only ever filled by an ADIF import that carried one, so publishing it straight gave an empty column to anyone whose log was made in OpsLog — which is everyone who reported it. It falls back to the two locators, which are on the QSO already. A stored distance still wins: it came from the log that recorded the contact, which knew more than two four-character squares do. No grids means an empty cell, not a zero — an empty cell is honest, a zero is a claim. Rounded to whole kilometres. The squares are tens of kilometres across and a decimal would assert an accuracy nobody has. The geometry moved to internal/geo on the way. It lived in package main, which internal packages cannot import, so the PSK Reporter watcher already had its maths injected from main and this would have been a third copy. A bearing that disagrees with itself between two panels is a fault nobody reports, because each screen looks perfectly plausible on its own.
This commit is contained in:
@@ -40,6 +40,7 @@ import (
|
|||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
"hamlog/internal/email"
|
"hamlog/internal/email"
|
||||||
"hamlog/internal/extsvc"
|
"hamlog/internal/extsvc"
|
||||||
|
"hamlog/internal/geo"
|
||||||
"hamlog/internal/integrations/udp"
|
"hamlog/internal/integrations/udp"
|
||||||
"hamlog/internal/lookup"
|
"hamlog/internal/lookup"
|
||||||
"hamlog/internal/lotwusers"
|
"hamlog/internal/lotwusers"
|
||||||
@@ -746,48 +747,16 @@ type App struct {
|
|||||||
udpLastMode string
|
udpLastMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
|
// gridToLatLon and haversineKm live in internal/geo, which the internal
|
||||||
// centre lat/lon in degrees. Returns ok=false on malformed input.
|
// packages can import — package main cannot be imported by anything. These
|
||||||
func gridToLatLon(grid string) (lat, lon float64, ok bool) {
|
// forward so there is exactly ONE implementation: the PSK Reporter watcher and
|
||||||
g := strings.ToUpper(strings.TrimSpace(grid))
|
// the web publisher measure the same path the cluster does, and a bearing that
|
||||||
if len(g) < 4 {
|
// disagrees with itself between two panels is a fault nobody reports, because
|
||||||
return 0, 0, false
|
// each screen looks plausible alone.
|
||||||
}
|
func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) }
|
||||||
A := g[0] - 'A'
|
|
||||||
B := g[1] - 'A'
|
|
||||||
C := g[2] - '0'
|
|
||||||
D := g[3] - '0'
|
|
||||||
if A > 17 || B > 17 || C > 9 || D > 9 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
lon = -180 + float64(A)*20 + float64(C)*2
|
|
||||||
lat = -90 + float64(B)*10 + float64(D)*1
|
|
||||||
if len(g) >= 6 {
|
|
||||||
E := g[4] - 'A'
|
|
||||||
F := g[5] - 'A'
|
|
||||||
if E <= 23 && F <= 23 {
|
|
||||||
lon += float64(E)*(5.0/60.0) + 2.5/60.0
|
|
||||||
lat += float64(F)*(2.5/60.0) + 1.25/60.0
|
|
||||||
return lat, lon, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 4-char locator: aim at the centre of the square.
|
|
||||||
lon += 1
|
|
||||||
lat += 0.5
|
|
||||||
return lat, lon, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// haversineKm returns the great-circle distance between two lat/lon pairs
|
|
||||||
// in kilometres. Standard Haversine, mean Earth radius 6371 km.
|
|
||||||
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
const R = 6371.0
|
return geo.HaversineKm(lat1, lon1, lat2, lon2)
|
||||||
rad := math.Pi / 180.0
|
|
||||||
dLat := (lat2 - lat1) * rad
|
|
||||||
dLon := (lon2 - lon1) * rad
|
|
||||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
||||||
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
||||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
||||||
return R * c
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialBearingDeg returns the initial great-circle bearing (azimuth) in
|
// initialBearingDeg returns the initial great-circle bearing (azimuth) in
|
||||||
|
|||||||
+4
-2
@@ -3,10 +3,12 @@
|
|||||||
"version": "0.24.6",
|
"version": "0.24.6",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"en": [
|
||||||
"Bulk edit can now set mode, submode and RST. They were excluded as per-QSO fields, which missed the point: bulk edit is for repairing a batch — an import that mapped every contact to SSB, an ADIF with no mode at all — and refusing meant editing a hundred rows one at a time. Setting the mode clears the submode, since one left over from the old mode contradicts the new one. Band stays with frequency, which already sets the two together."
|
"Bulk edit can now set mode, submode and RST. They were excluded as per-QSO fields, which missed the point: bulk edit is for repairing a batch — an import that mapped every contact to SSB, an ADIF with no mode at all — and refusing meant editing a hundred rows one at a time. Setting the mode clears the submode, since one left over from the old mode contradicts the new one. Band stays with frequency, which already sets the two together.",
|
||||||
|
"Web publishing: the Distance column is no longer empty. Nothing computes a distance when a QSO is logged — the stored field is only ever filled by an ADIF import that carried one — so it is worked out from the two locators instead, rounded to whole kilometres."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"L édition groupée sait enfin régler le mode, le sous-mode et le RST. Ils étaient exclus comme champs propres à chaque QSO, ce qui manquait l essentiel : l édition groupée sert à RÉPARER un lot — un import qui a tout mis en SSB, un ADIF sans aucun mode — et refuser obligeait à corriger cent lignes une par une. Régler le mode efface le sous-mode, celui de l ancien mode contredisant le nouveau. La bande reste avec la fréquence, qui pose déjà les deux ensemble."
|
"L édition groupée sait enfin régler le mode, le sous-mode et le RST. Ils étaient exclus comme champs propres à chaque QSO, ce qui manquait l essentiel : l édition groupée sert à RÉPARER un lot — un import qui a tout mis en SSB, un ADIF sans aucun mode — et refuser obligeait à corriger cent lignes une par une. Régler le mode efface le sous-mode, celui de l ancien mode contredisant le nouveau. La bande reste avec la fréquence, qui pose déjà les deux ensemble.",
|
||||||
|
"Publication web : la colonne Distance n est plus vide. Rien ne calcule de distance à l enregistrement d un QSO — le champ stocké n est rempli que par un ADIF importé qui en portait une — elle est donc déduite des deux locators, arrondie au kilomètre."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package geo is the one place that turns Maidenhead locators into positions,
|
||||||
|
// and positions into distances and bearings.
|
||||||
|
//
|
||||||
|
// It exists because there were about to be three copies. These functions lived
|
||||||
|
// in package main, which the internal packages cannot import, so the PSK
|
||||||
|
// Reporter watcher had its geometry injected from main and the web publisher
|
||||||
|
// was about to grow its own. A bearing that disagrees with itself between two
|
||||||
|
// panels is the kind of fault nobody reports, because each screen looks
|
||||||
|
// plausible on its own.
|
||||||
|
package geo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GridToLatLon parses a Maidenhead locator (4 or 6 characters) and returns the
|
||||||
|
// centre of that square in degrees. ok=false on malformed input.
|
||||||
|
func GridToLatLon(grid string) (lat, lon float64, ok bool) {
|
||||||
|
g := strings.ToUpper(strings.TrimSpace(grid))
|
||||||
|
if len(g) < 4 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
A := g[0] - 'A'
|
||||||
|
B := g[1] - 'A'
|
||||||
|
C := g[2] - '0'
|
||||||
|
D := g[3] - '0'
|
||||||
|
if A > 17 || B > 17 || C > 9 || D > 9 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
lon = -180 + float64(A)*20 + float64(C)*2
|
||||||
|
lat = -90 + float64(B)*10 + float64(D)*1
|
||||||
|
if len(g) >= 6 {
|
||||||
|
E := g[4] - 'A'
|
||||||
|
F := g[5] - 'A'
|
||||||
|
if E <= 23 && F <= 23 {
|
||||||
|
lon += float64(E)*(5.0/60.0) + 2.5/60.0
|
||||||
|
lat += float64(F)*(2.5/60.0) + 1.25/60.0
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4-character locator: aim at the centre of the square.
|
||||||
|
lon += 1
|
||||||
|
lat += 0.5
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HaversineKm returns the great-circle distance between two positions in
|
||||||
|
// kilometres. Mean Earth radius 6371 km.
|
||||||
|
func HaversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
|
const R = 6371.0
|
||||||
|
rad := math.Pi / 180.0
|
||||||
|
dLat := (lat2 - lat1) * rad
|
||||||
|
dLon := (lon2 - lon1) * rad
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||||
|
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||||
|
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistanceBetweenGrids is the distance in kilometres between two locators,
|
||||||
|
// ok=false when either cannot be parsed.
|
||||||
|
func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
|
||||||
|
lat1, lon1, ok1 := GridToLatLon(a)
|
||||||
|
lat2, lon2, ok2 := GridToLatLon(b)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return HaversineKm(lat1, lon1, lat2, lon2), true
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jlaffaye/ftp"
|
"github.com/jlaffaye/ftp"
|
||||||
|
|
||||||
|
"hamlog/internal/geo"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,6 +94,26 @@ func flt(p *float64) string {
|
|||||||
return strconv.FormatFloat(*p, 'f', -1, 64)
|
return strconv.FormatFloat(*p, 'f', -1, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distanceKm is the path length for the Distance column.
|
||||||
|
//
|
||||||
|
// The stored DISTANCE field is only ever filled by an ADIF import that carried
|
||||||
|
// one — OpsLog does not compute it when logging — so publishing it straight gave
|
||||||
|
// an empty column for every QSO made here, which is how this was reported.
|
||||||
|
//
|
||||||
|
// So it falls back to the two locators, which are on the QSO already. Rounded to
|
||||||
|
// whole kilometres: the grids are squares tens of kilometres across, and a
|
||||||
|
// decimal on a figure that imprecise claims an accuracy nobody has.
|
||||||
|
func distanceKm(q *qso.QSO) string {
|
||||||
|
if q.Distance != nil && *q.Distance > 0 {
|
||||||
|
return flt(q.Distance)
|
||||||
|
}
|
||||||
|
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
|
||||||
|
if !ok || km <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(km, 'f', 0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
func stamp(t time.Time) string {
|
func stamp(t time.Time) string {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return ""
|
return ""
|
||||||
@@ -218,7 +239,7 @@ var Columns = []Column{
|
|||||||
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
|
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
|
||||||
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
|
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
|
||||||
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
|
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
|
||||||
{"distance", "Distance", "Location", func(q *qso.QSO) string { return flt(q.Distance) }},
|
{"distance", "Distance", "Location", distanceKm},
|
||||||
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
|
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
|
||||||
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
|
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
|
||||||
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
|
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package webpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Distance column was published empty for every QSO logged in OpsLog: the
|
||||||
|
// stored DISTANCE field is only ever filled by an ADIF import that carried one,
|
||||||
|
// and nothing computes it when logging. It falls back to the two locators.
|
||||||
|
func TestDistanceFallsBackToTheGrids(t *testing.T) {
|
||||||
|
// JN36 (French Alps) to IO91 (southern England): a few hundred kilometres.
|
||||||
|
got := distanceKm(&qso.QSO{MyGrid: "JN36DG", Grid: "IO91"})
|
||||||
|
if got == "" {
|
||||||
|
t.Fatal("no distance from two perfectly good locators")
|
||||||
|
}
|
||||||
|
if got == "0" {
|
||||||
|
t.Errorf("distance = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored distance wins: it came from the log that recorded the QSO, which knew
|
||||||
|
// more than two four-character squares do.
|
||||||
|
func TestStoredDistanceWins(t *testing.T) {
|
||||||
|
d := 1234.0
|
||||||
|
if got := distanceKm(&qso.QSO{Distance: &d, MyGrid: "JN36", Grid: "IO91"}); got != "1234" {
|
||||||
|
t.Errorf("distance = %q, want the stored 1234", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No grids, no invention. An empty cell is honest; a zero is a claim.
|
||||||
|
func TestNoGridsNoDistance(t *testing.T) {
|
||||||
|
for _, q := range []qso.QSO{{}, {MyGrid: "JN36"}, {Grid: "IO91"}, {MyGrid: "??", Grid: "IO91"}} {
|
||||||
|
if got := distanceKm(&q); got != "" {
|
||||||
|
t.Errorf("distance(%+v) = %q, want empty", q, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user