diff --git a/app.go b/app.go index e5073d2..8dc044a 100644 --- a/app.go +++ b/app.go @@ -40,6 +40,7 @@ import ( "hamlog/internal/dxcc" "hamlog/internal/email" "hamlog/internal/extsvc" + "hamlog/internal/geo" "hamlog/internal/integrations/udp" "hamlog/internal/lookup" "hamlog/internal/lotwusers" @@ -746,48 +747,16 @@ type App struct { udpLastMode string } -// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the -// centre lat/lon in degrees. Returns 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-char locator: aim at the centre of the square. - lon += 1 - lat += 0.5 - return lat, lon, true -} +// gridToLatLon and haversineKm live in internal/geo, which the internal +// packages can import — package main cannot be imported by anything. These +// forward so there is exactly ONE implementation: the PSK Reporter watcher and +// the web publisher measure the same path the cluster does, and a bearing that +// disagrees with itself between two panels is a fault nobody reports, because +// each screen looks plausible alone. +func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) } -// 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 { - 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) - c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) - return R * c + return geo.HaversineKm(lat1, lon1, lat2, lon2) } // initialBearingDeg returns the initial great-circle bearing (azimuth) in diff --git a/changelog.json b/changelog.json index 6778569..9edc8fe 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,12 @@ "version": "0.24.6", "date": "", "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": [ - "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." ] }, { diff --git a/internal/geo/geo.go b/internal/geo/geo.go new file mode 100644 index 0000000..56c3dd5 --- /dev/null +++ b/internal/geo/geo.go @@ -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 +} diff --git a/internal/webpub/webpub.go b/internal/webpub/webpub.go index 77a47e3..9052f01 100644 --- a/internal/webpub/webpub.go +++ b/internal/webpub/webpub.go @@ -33,6 +33,7 @@ import ( "github.com/jlaffaye/ftp" + "hamlog/internal/geo" "hamlog/internal/qso" ) @@ -93,6 +94,26 @@ func flt(p *float64) string { 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 { if t.IsZero() { return "" @@ -218,7 +239,7 @@ var Columns = []Column{ {"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 }}, {"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) }}, {"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) }}, diff --git a/internal/webpub/webpub_distance_test.go b/internal/webpub/webpub_distance_test.go new file mode 100644 index 0000000..0d33c7a --- /dev/null +++ b/internal/webpub/webpub_distance_test.go @@ -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) + } + } +}