mirror of
https://github.com/js8call/js8call.git
synced 2025-12-22 09:17:08 +00:00
501 lines
17 KiB
C++
501 lines
17 KiB
C++
#include "Geodesic.hpp"
|
|
#include <concepts>
|
|
#include <QCache>
|
|
#include <QMutex>
|
|
#include <QMutexLocker>
|
|
#include "Maidenhead.hpp"
|
|
|
|
/******************************************************************************/
|
|
// Constants
|
|
/******************************************************************************/
|
|
|
|
namespace
|
|
{
|
|
// Epsilon values for Lat / Long comparisons; if we find after conversion
|
|
// to coordinates that a pair of grids are either identical or antipodes,
|
|
// within these limits, we'll return early rather than computing results.
|
|
|
|
constexpr auto LL_EPSILON_IDENTICAL = 0.02f;
|
|
constexpr auto LL_EPSILON_ANTIPODES = 1.e-6f;
|
|
|
|
// Table of compass directions, with arrows; available as an accessor on
|
|
// the Azimuth class. These are translatable, but translation is not our
|
|
// concern; we simply guarantee to the presentation layer that these are
|
|
// what we provide, deferring translation to somewhere above us.
|
|
|
|
constexpr QStringView COMPASS[] =
|
|
{
|
|
u"\u2191N",
|
|
u"\u2197NE",
|
|
u"\u2192E",
|
|
u"\u2198SE",
|
|
u"\u2193S",
|
|
u"\u2199SW",
|
|
u"\u2190W",
|
|
u"\u2196NW"
|
|
};
|
|
}
|
|
|
|
/******************************************************************************/
|
|
// Input Normalization
|
|
/******************************************************************************/
|
|
|
|
namespace
|
|
{
|
|
// Structure used to perform lookups; represents normalized, i.e.,
|
|
// validated, trimmed fore and aft, converted to upper case, grid
|
|
// identifiers, and an indication if either are only sufficiently
|
|
// long to contain square data, rather than subsquare or extended.
|
|
|
|
struct Data
|
|
{
|
|
QString origin;
|
|
QString remote;
|
|
bool square;
|
|
};
|
|
|
|
// Given a pair of strings that have passed validity checking, create
|
|
// and return normalized data.
|
|
|
|
auto
|
|
normalize(QStringView const origin,
|
|
QStringView const remote)
|
|
{
|
|
auto const normalizedOrigin = origin.trimmed().toString().toUpper();
|
|
auto const normalizedRemote = remote.trimmed().toString().toUpper();
|
|
|
|
return Data{normalizedOrigin,
|
|
normalizedRemote,
|
|
normalizedOrigin.length() < 6 ||
|
|
normalizedRemote.length() < 6};
|
|
}
|
|
}
|
|
|
|
/******************************************************************************/
|
|
// Grid Square to Coordinates
|
|
/******************************************************************************/
|
|
|
|
namespace
|
|
{
|
|
// Grid to coordinate transformation, with results exactly matching those
|
|
// of the Fortran subroutine grid2deg() within the domain of grid2deg(),
|
|
// which is only up to subsquare. Input is a 4, 6, or 8 character grid
|
|
// square, validated and normalized by the functions above.
|
|
|
|
template <qsizetype Offset>
|
|
auto
|
|
gridData(QStringView const grid)
|
|
{
|
|
static_assert(Offset == 0 || Offset == 1);
|
|
return std::array
|
|
{
|
|
grid[Offset ].unicode() - u'A',
|
|
grid[Offset + 2].unicode() - u'0',
|
|
(grid.size() > 4 ? grid[Offset + 4].unicode() : u'M') - u'A',
|
|
(grid.size() > 6 ? grid[Offset + 6].unicode() : u'4') - u'0'
|
|
};
|
|
}
|
|
|
|
inline auto
|
|
gridLat(QStringView const grid)
|
|
{
|
|
// 1: A-R, 10° each, field, one of 18 zones of latitude
|
|
// 3: 0-9, 1° each, 100 squares within field
|
|
// 5: A-X, 2.5' each, 576 subsquares within square
|
|
// 7: 0-9, 15" each, 100 extended squares within subsquare
|
|
|
|
auto const data = gridData<1>(grid);
|
|
|
|
return (-90 + 10 * data[0])
|
|
+ data[1]
|
|
+ (( 2.5f * (data[2] + 0.5f)) / 60.0f)
|
|
+ (( 15 * (data[3] + 0.5f)) / 3600.0f);
|
|
}
|
|
|
|
inline auto
|
|
gridLon(QStringView const grid)
|
|
{
|
|
// 0: A-R, 20° each, field, one of 18 zones of longitude
|
|
// 2: 0-9, 2° each, 100 squares within field
|
|
// 4: A-X, 5' each, 576 subsquares within square
|
|
// 6: 0-9, 30" each, 100 extended squares within subsquare
|
|
|
|
auto const data = gridData<0>(grid);
|
|
|
|
return (180 - 20 * data[0])
|
|
- (2 * data[1])
|
|
- (( 5 * (data[2] + 0.5f)) / 60.0f)
|
|
- (( 30 * (data[3] + 0.5f)) / 3600.0f);
|
|
}
|
|
|
|
class Coords
|
|
{
|
|
private:
|
|
|
|
// Data members
|
|
|
|
float m_lat;
|
|
float m_lon;
|
|
|
|
public:
|
|
|
|
// Inline Accessors
|
|
|
|
auto lat() const { return m_lat; }
|
|
auto lon() const { return m_lon; }
|
|
|
|
// Constructor
|
|
|
|
Coords(QStringView const grid)
|
|
: m_lat(gridLat(grid))
|
|
, m_lon(gridLon(grid))
|
|
{}
|
|
|
|
// Determine if these coordinates are identical to those provided,
|
|
// within the defined epsilon limit.
|
|
|
|
bool
|
|
isIdenticalTo(Coords const other) const
|
|
{
|
|
auto const latValue = std::abs(lat() - other.lat());
|
|
auto const lonValue = std::abs(lon() - other.lon());
|
|
|
|
return ((latValue < LL_EPSILON_IDENTICAL) &&
|
|
(lonValue < LL_EPSILON_IDENTICAL));
|
|
}
|
|
|
|
// Determine if these coordinates are antipodes of those provided,
|
|
// within the defined epsilon limit.
|
|
|
|
bool
|
|
isAntipodesOf(Coords const other) const
|
|
{
|
|
// We subtract the longitudes and add 720 degrees to ensure
|
|
// a positive result; modulo 360 of that results in a value
|
|
// in the range [-180, 180].
|
|
|
|
auto const range = std::fmod(lon() - other.lon() + 720.0f, 360.0f);
|
|
auto const latValue = std::abs (lat() + other.lat());
|
|
auto const lonValue = std::abs (range - 180.0f);
|
|
|
|
return ((latValue < LL_EPSILON_ANTIPODES) &&
|
|
(lonValue < LL_EPSILON_ANTIPODES));
|
|
}
|
|
};
|
|
}
|
|
|
|
/******************************************************************************/
|
|
// Coordinates to Azimuth / Distance
|
|
/******************************************************************************/
|
|
|
|
namespace
|
|
{
|
|
// Collapsed and simplified versions of two of JHT's original Fortran
|
|
// subroutines, azdist() and geodist(). Given normalized data, return
|
|
// the azimuth in degrees and the distance in kilometers.
|
|
//
|
|
// While in a perfect world, we could use the Haversine distance, a
|
|
// perfect world is a sphere, and ours is an ellipsoid, flattened at
|
|
// the poles. Thus, there is...math.
|
|
//
|
|
// Boy howdy, there is math.
|
|
//
|
|
// Note that as with the original routines, West longitude is positive.
|
|
|
|
auto
|
|
azdist(Data const & data)
|
|
{
|
|
// If they've given us the same grids, reward them appropriately.
|
|
|
|
if (data.origin == data.remote) return std::make_pair(0.0f, 0.0f);
|
|
|
|
// Convert the grids to coordinates.
|
|
|
|
auto const origin = Coords{data.origin};
|
|
auto const remote = Coords{data.remote};
|
|
|
|
// Grids that looked different prior to conversion to coordinates
|
|
// can nevertheless be practically on top of one another; we can't
|
|
// go there, because we're already there.
|
|
//
|
|
// Grids that are antipodes of one another aren't worth calculating;
|
|
// you can't get farther away without leaving the planet, moving in
|
|
// any direction will take you there, and it's the same distance no
|
|
// matter what direction you go.
|
|
|
|
if (origin.isIdenticalTo(remote)) return std::make_pair(0.0f, 0.0f);
|
|
if (origin.isAntipodesOf(remote)) return std::make_pair(0.0f, 204000.0f);
|
|
|
|
// Sanity checks complete; let's do some math. JHT took this algorithm
|
|
// from:
|
|
//
|
|
// Thomas, P.D., 1970,
|
|
// Spheroidal Geodesics, Reference Systems, & Local Geometry,
|
|
// U.S. Naval Oceanographic Office SP-138, 165 pp.
|
|
//
|
|
// "A discussion of the geodesic on the oblate spheroid (reference
|
|
// ellipsoid) is given with formulae of geodetic accuracy (second
|
|
// order in the flattening, distance and azimuths) for the non-
|
|
// iterative direct and inverse solutions over the hemispheroid,
|
|
// requiring no root extraction and no tabular data except 3-place
|
|
// tables of the natural trigonometric functions."
|
|
//
|
|
// This is a C++ translation of the original Fortran-63 algorithm, pages
|
|
// 162 and 163 of the publication, description on page 161.
|
|
//
|
|
// Note that the original routine uses single-precision real values, so
|
|
// we've done the same here. The degrees to radians and Tau constants
|
|
// used by JHT's version are slightly different than those used by the
|
|
// Thomas algorithm; we've used the JHT versions.
|
|
//
|
|
// Constants and derived constants:
|
|
//
|
|
// AL: Semi-major axis of the Earth, Clarke 1866 ellipsoid, in km.
|
|
// BL: Semi-minor axis of the Earth, Clarke 1866 ellipsoid, in km.
|
|
// D2R: Degrees to radians conversion factor.
|
|
// TAU: Tau constant, the ratio of the circumference to the radius
|
|
// of a circle, i.e, 2Pi;
|
|
|
|
constexpr auto AL = 6378206.4f;
|
|
constexpr auto BL = 6356583.8f;
|
|
constexpr auto D2R = 0.01745329251994f;
|
|
constexpr auto TAU = 6.28318530718f;
|
|
constexpr auto BOA = BL / AL;
|
|
constexpr auto F = 1.0f - BOA;
|
|
constexpr auto FF64 = F * F / 64.0f;
|
|
|
|
// Convert degrees of latitude and longitude to radians and compute the
|
|
// delta longitude in radians.
|
|
|
|
auto const P1R = origin.lat() * D2R;
|
|
auto const P2R = remote.lat() * D2R;
|
|
auto const L1R = origin.lon() * D2R;
|
|
auto const L2R = remote.lon() * D2R;
|
|
auto const DLR = L2R - L1R;
|
|
|
|
// And away we go; see page 162 of the publication.
|
|
|
|
auto const T1R = std::atan(BOA * std::tan(P1R));
|
|
auto const T2R = std::atan(BOA * std::tan(P2R));
|
|
auto const TM = (T1R + T2R) / 2.0f;
|
|
auto const DTM = (T2R - T1R) / 2.0f;
|
|
auto const STM = std::sin(TM);
|
|
auto const CTM = std::cos(TM);
|
|
auto const SDTM = std::sin(DTM);
|
|
auto const CDTM = std::cos(DTM);
|
|
auto const KL = STM * CDTM;
|
|
auto const KK = SDTM * CTM;
|
|
auto const SDLMR = std::sin(DLR / 2.0f);
|
|
auto const L = SDTM * SDTM + SDLMR * SDLMR * (CDTM * CDTM - STM * STM);
|
|
auto const CD = 1.0f - 2.0f * L;
|
|
auto const DL = std::acos(CD);
|
|
auto const SD = std::sin(DL);
|
|
auto const T = DL / SD;
|
|
auto const U = 2.0f * KL * KL / (1.0f - L);
|
|
auto const V = 2.0f * KK * KK / L;
|
|
auto const D = 4.0f * T * T;
|
|
auto const X = U + V;
|
|
auto const E = -2.0f * CD;
|
|
auto const Y = U - V;
|
|
auto const A = -D * E;
|
|
auto const dist = AL * SD * (T - (F / 4.0f) * (T * X - Y) + FF64 * (X * (A + (T - (A + E) / 2.0f) * X) + Y * (-2.0f * D + E * Y) + D * X * Y)) / 1000.0f;
|
|
auto const TDLPM = std::tan((DLR + (-((E * (4.0f - X) + 2.0f * Y) * ((F / 2.0f) * T + FF64 * (32.0f * T + (A - 20.0f * T) * X - 2.0f * (D + 2.0f) * Y)) / 4.0f) * std::tan(DLR))) / 2.0f);
|
|
auto const HAPBR = std::atan2(SDTM, (CTM * TDLPM));
|
|
auto const HAMBR = std::atan2(CDTM, (STM * TDLPM));
|
|
|
|
// This should be the net effect of the somewhat gnarly goto loops
|
|
// in the original Fortran. Even as a former 370 assembler guy, ew,
|
|
// just...ew.
|
|
|
|
auto A1M2 = TAU + HAMBR - HAPBR;
|
|
while (A1M2 < 0.0f || A1M2 >= TAU)
|
|
{
|
|
if (A1M2 < 0.0f) A1M2 += TAU;
|
|
else if (A1M2 >= TAU) A1M2 -= TAU;
|
|
}
|
|
|
|
return std::make_pair(360.0f - (A1M2 / D2R), dist);
|
|
}
|
|
}
|
|
|
|
/******************************************************************************/
|
|
// Local Utilities
|
|
/******************************************************************************/
|
|
|
|
namespace
|
|
{
|
|
// Displayable units. No need to translate these; the SI units are
|
|
// universal, and the standard units are only used in English.
|
|
|
|
constexpr QStringView UNITS_KM = u"km";
|
|
constexpr QStringView UNITS_MI = u"mi";
|
|
|
|
// In the spirit of the Fortran NINT() function, round and convert the
|
|
// provided floating-point value to an integer, for display purposes.
|
|
|
|
template <std::floating_point T>
|
|
auto
|
|
nint(T const value)
|
|
{
|
|
return static_cast<int>(std::round(value));
|
|
}
|
|
}
|
|
|
|
/******************************************************************************/
|
|
// Public Implementation
|
|
/******************************************************************************/
|
|
|
|
namespace Geodesic
|
|
{
|
|
// Return as a compass direction, i.e., directional arrow and cardinal
|
|
// direction, or an empty string view if invalid.
|
|
|
|
QStringView
|
|
Azimuth::compass() const
|
|
{
|
|
return isValid() ? COMPASS[static_cast<int>((m_value + 22.5f) / 45.0f) % 8]
|
|
: QStringView{};
|
|
}
|
|
|
|
// Return azimuth as a numeric string, to the nearest whole degree.
|
|
// If the caller requests units, we'll append a degree symbol.
|
|
|
|
QString
|
|
Azimuth::toString(bool const units) const
|
|
{
|
|
if (!isValid()) return QString{};
|
|
|
|
return units ? QString("%1°").arg(nint(m_value))
|
|
: QString::number (nint(m_value));
|
|
}
|
|
|
|
// Return distance as a numeric string, to the nearest whole kilometer
|
|
// or mile. If we're close and either of the grids that gave rise to us
|
|
// to us was only of square magnitude, rather than subsquare or extended,
|
|
// prepend a '<' to indicate that we're close, but we're not sure just
|
|
// how close, and the actual distance is somewhere within the value.
|
|
//
|
|
// If the caller requests units, we'll append them.
|
|
|
|
QString
|
|
Distance::toString(bool const miles,
|
|
bool const units) const
|
|
{
|
|
if (!isValid()) return QString{};
|
|
|
|
auto value = isClose() ? CLOSE : m_value;
|
|
if (miles) value /= 1.609344f;
|
|
|
|
if (units && isClose()) return QString("<%1 %2").arg(nint(value)).arg(miles ? UNITS_MI : UNITS_KM);
|
|
else if (units) return QString("%1 %2" ).arg(nint(value)).arg(miles ? UNITS_MI : UNITS_KM);
|
|
else if (isClose()) return QString("<%1" ).arg(nint(value));
|
|
else return QString::number (nint(value));
|
|
}
|
|
|
|
// The azdist() function is frankly something you don't want to run more
|
|
// than you have to. Additionally, while our contract defines the ability
|
|
// to compute a vector between any two valid grid identifiers, the fact is
|
|
// that the origin is going to be, practically speaking, always the local
|
|
// station.
|
|
//
|
|
// Vectors get looked up a lot, so caching them is of benefit. We use a
|
|
// two-level cache, the first level being a cache of origins, which will
|
|
// be reasonably persistent, the second level being an ephemeral cache of
|
|
// remotes.
|
|
//
|
|
// We're taking the default cache cost values of 100 here, so, rough math
|
|
// for the worst-case storage requirement here is:
|
|
//
|
|
// - Keys are strings, and they're all short, so we'll see a
|
|
// short string optimization, about 24 bytes per key.
|
|
//
|
|
// - Vectors are the size of 2 floats, so 8 bytes per Vector.
|
|
//
|
|
// - Key and associated Vector is therefore about 32 bytes.
|
|
//
|
|
// - 100 elements per cache = 100 * 32 = 3.2K
|
|
//
|
|
// - 100 caches of caches = ~3.2K * 100 = 320K. However, that's
|
|
// worst case in the extreme; likely we'll never see more than
|
|
// one origin, so likely only 3.2K total.
|
|
//
|
|
// - Some overhead on top of that, but relatively speaking, not
|
|
// much. We're therefore spending on the order of 4K of memory
|
|
// here, along with some light integer math, in order to avoid
|
|
// spending a lot of time repeating identical floating point
|
|
// calculations.
|
|
//
|
|
// Note that the vector returned to the caller is theirs; it's always a
|
|
// copy of a cached version, or a new one that we create. They should be
|
|
// only 8 bytes in size (2 floats); so this should be very efficient; in
|
|
// theory, these return in a single 64-bit register.
|
|
//
|
|
// This function is reentrant, but practically speaking, it'd be unusual
|
|
// for this to be called from anything other than the GUI thread.
|
|
|
|
Vector
|
|
vector(QStringView const origin,
|
|
QStringView const remote)
|
|
{
|
|
using Cache = QCache<QString, Vector>;
|
|
|
|
static QMutex mutex;
|
|
static QCache<QString, Cache> caches;
|
|
|
|
QMutexLocker lock(&mutex);
|
|
|
|
// Caller is expected to hand us a lot of garbage; it's literally the
|
|
// common case. Prior to getting too far into the weeds here, a quick
|
|
// sanity check that what we've been handed could be expected to work.
|
|
// If not, return a vector with invalid azimuth and invalid distance.
|
|
|
|
if (!Maidenhead::valid(origin.trimmed()) ||
|
|
!Maidenhead::valid(remote.trimmed()))
|
|
{
|
|
return Vector();
|
|
}
|
|
|
|
// Input data validated; we have a winner here; at this point we are
|
|
// going to return a valid vector; get the data by which to create it.
|
|
|
|
auto const data = normalize(origin, remote);
|
|
|
|
// Perform first-level cache lookup; we should practically always hit
|
|
// on this, other than the first time we're invoked.
|
|
|
|
if (auto cache = caches.object(data.origin))
|
|
{
|
|
// We've hit on the first level cache; if we hit on the second, then
|
|
// return a copy of the cached vector to the caller, and we're outta
|
|
// here. If we miss, create a vector, store a copy of it in the cache,
|
|
// and return the original to the caller.
|
|
|
|
if (auto const value = cache->object(data.remote))
|
|
{
|
|
return *value;
|
|
}
|
|
else
|
|
{
|
|
auto const vector = Vector(azdist(data), data.square);
|
|
cache->insert(data.remote, new Vector(vector));
|
|
return vector;
|
|
}
|
|
}
|
|
|
|
// We missed on the first-level cache; first time here for this origin.
|
|
// Create a new second-level cache and a vector, storing a copy of the
|
|
// vector in the cache, and then cache the cache. Return the original
|
|
// vector to the caller.
|
|
|
|
auto cache = new Cache();
|
|
auto const vector = Vector(azdist(data), data.square);
|
|
|
|
cache->insert(data.remote, new Vector(vector));
|
|
caches.insert(data.origin, cache);
|
|
|
|
return vector;
|
|
}
|
|
}
|
|
|
|
/******************************************************************************/
|