Keep the colleague's microbu-esp32c5 tree in this repository

obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but
that tree was gitignored, so a clone of this repository could not build the
firmware it ships. It is now committed here as ordinary files in its own
folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP
bridge's signature verification (--trust) used on the bench. Nothing is
fetched from or pushed to the colleague's repository; this repository and
its remotes carry everything. The folder's own .gitignore keeps build output,
downloaded components and private key material out, as it did there; the
committed file set is identical to that repository's tracked files.

The ESP32-C5 is still flashed from obu-firmware/, which only takes
vanetza-idf from microbu-esp32c5/, so the two stay separate folders.
FLASHING.md says how to take a newer version of the colleague's tree (copy
it over the folder, rebuild, test, commit).
This commit is contained in:
Ashin Walpola
2026-09-23 17:46:40 +02:00
parent 2f60623e18
commit 0e9525162d
9881 changed files with 1582523 additions and 17 deletions
@@ -0,0 +1,6 @@
include(UseGTest)
configure_gtest_directory(LINK_LIBRARIES facilities)
add_gtest(CamFunctions cam_functions.cpp LINK_LIBRARIES asn1_its_r2)
add_gtest(PathHistory path_history.cpp)
add_gtest(PathPoint path_point.cpp)
@@ -0,0 +1,340 @@
#include <gtest/gtest.h>
#include <vanetza/facilities/path_history.hpp>
#include <vanetza/asn1/type_traits.hpp>
#include <vanetza/asn1/its/Heading.h>
#include <vanetza/asn1/its/PathHistory.h>
#include <vanetza/asn1/its/ReferencePosition.h>
#include <vanetza/asn1/its/r2/Heading.h>
#include <vanetza/asn1/its/r2/PathHistory.h>
#include <vanetza/asn1/its/r2/ReferencePosition.h>
#include <vanetza/facilities/cam_functions.hpp>
#include <boost/units/cmath.hpp>
#include <boost/units/io.hpp>
#include <cmath>
namespace vanetza
{
namespace asn1
{
template<> struct asn1_type_traits<::PathHistory>
{
static asn_TYPE_descriptor_t& descriptor() { return ::asn_DEF_PathHistory; }
};
template<> struct asn1_type_traits<::Vanetza_ITS2_PathHistory>
{
static asn_TYPE_descriptor_t& descriptor() { return ::asn_DEF_Vanetza_ITS2_PathHistory; }
};
} // namespace asn1
} // namespace vanetza
using namespace vanetza;
using namespace vanetza::facilities;
using namespace vanetza::units;
constexpr long latitude(double degree, double arc_minute)
{
return std::round(1e7 * (degree + arc_minute / 60.0));
}
constexpr long longitude(double degree, double arc_minute)
{
return std::round(1e7 * (degree + arc_minute / 60.0));
}
::testing::AssertionResult NearDistance(const Length& a, const Length& b, Length delta)
{
using namespace boost::units;
const auto diff = abs(a - b);
if (diff < delta) {
return ::testing::AssertionSuccess();
} else {
return ::testing::AssertionFailure() << "actual difference " << diff << " exceeds delta of " << delta;
}
}
using HeadingTypes = ::testing::Types<Heading, Vanetza_ITS2_Heading>;
template<typename T>
class CamFunctionsHeading : public ::testing::Test
{
};
TYPED_TEST_SUITE(CamFunctionsHeading, HeadingTypes);
TEST(CamFunctionsHeading, similar_heading)
{
Angle a = 3 * si::radian;
Angle b = 2 * si::radian;
Angle limit = 0.5 * si::radian;
EXPECT_FALSE(similar_heading(a, b, limit));
EXPECT_FALSE(similar_heading(b, a, limit));
limit = 1.0 * si::radian;
EXPECT_TRUE(similar_heading(a, b, limit));
EXPECT_TRUE(similar_heading(b, a, limit));
a = 6.1 * si::radian;
b = 0.2 * si::radian;
limit = 0.4 * si::radian;
EXPECT_TRUE(similar_heading(a, b, limit));
EXPECT_TRUE(similar_heading(b, a, limit));
limit = 0.3 * si::radian;
EXPECT_FALSE(similar_heading(a, b, limit));
EXPECT_FALSE(similar_heading(b, a, limit));
limit = -1.0 * si::radian;
EXPECT_FALSE(similar_heading(a, a, limit));
}
TYPED_TEST(CamFunctionsHeading, similar_heading_unavailable1)
{
using SomeHeading = TypeParam;
SomeHeading a;
a.headingValue = HeadingValue_unavailable;
Angle b = 2.0 * si::radian;
Angle limit = 10 * si::radian;
EXPECT_FALSE(is_available(a));
EXPECT_FALSE(similar_heading(a, b, limit));
a.headingValue = 2 * HeadingValue_wgs84East;
EXPECT_TRUE(is_available(a));
EXPECT_TRUE(similar_heading(a, b, limit));
b = 0.0 * si::radian;
limit = 3.14 * si::radian;
EXPECT_FALSE(similar_heading(a, b, limit));
limit = 3.15 * si::radian;
EXPECT_TRUE(similar_heading(a, b, limit));
}
TYPED_TEST(CamFunctionsHeading, similar_heading_unavailable2)
{
using SomeHeading = TypeParam;
SomeHeading a;
a.headingValue = HeadingValue_unavailable;
SomeHeading b;
b.headingValue = HeadingValue_unavailable;
Angle limit = 10 * si::radian;
EXPECT_FALSE(is_available(a));
EXPECT_FALSE(is_available(b));
EXPECT_FALSE(similar_heading(a, b, limit));
b.headingValue = 200;
EXPECT_TRUE(is_available(b));
EXPECT_FALSE(similar_heading(a, b, limit));
EXPECT_FALSE(similar_heading(b, a, limit));
a.headingValue = 300;
EXPECT_TRUE(is_available(a));
EXPECT_TRUE(similar_heading(a, b, limit));
EXPECT_TRUE(similar_heading(b, a, limit));
}
using ReferencePositionTypes = ::testing::Types<ReferencePosition_t, Vanetza_ITS2_ReferencePosition_t>;
template<typename T>
class CamFunctionsReferencePosition : public ::testing::Test
{
};
TYPED_TEST_SUITE(CamFunctionsReferencePosition, ReferencePositionTypes);
TYPED_TEST(CamFunctionsReferencePosition, distance_reference_positions)
{
using SomeReferencePosition = TypeParam;
SomeReferencePosition pos1;
pos1.latitude = -latitude(6, 21.23);
pos1.longitude = -longitude(33, 22.12);
SomeReferencePosition pos2;
pos2.latitude = -latitude(6, 22.48);
pos2.longitude = -longitude(33, 22.55);
EXPECT_TRUE(NearDistance(distance(pos1, pos2), 2440.0 * si::meter , 10.0 * si::meter));
SomeReferencePosition pos3;
pos3.latitude = latitude(37, 17.3);
pos3.longitude = -longitude(0, 13.14);
SomeReferencePosition pos4;
pos4.latitude = latitude(37, 17.19);
pos4.longitude = longitude(0, 9.45);
EXPECT_TRUE(NearDistance(distance(pos3, pos4), 33390.0 * si::meter , 100.0 * si::meter));
SomeReferencePosition pos5;
pos5.latitude = -latitude(0, 19.24);
pos5.longitude = longitude(83, 37.32);
SomeReferencePosition pos6;
pos6.latitude = latitude(0, 27.15);
pos6.longitude = longitude(83, 04.45);
EXPECT_TRUE(NearDistance(distance(pos5, pos6), 105010.0 * si::meter , 300.0 * si::meter));
SomeReferencePosition pos7;
pos7.latitude = latitude(48, 45.56);
pos7.longitude = longitude(11, 26.01);
SomeReferencePosition pos8;
pos8.latitude = latitude(48, 45.566);
pos8.longitude = longitude(11, 26.04);
EXPECT_TRUE(NearDistance(distance(pos7, pos8), 38.0 * si::meter , 0.5 * si::meter));
}
TYPED_TEST(CamFunctionsReferencePosition, distance_refpos_latlon)
{
using SomeReferencePosition = TypeParam;
SomeReferencePosition refpos;
refpos.latitude = -latitude(6, 21.23);
refpos.longitude = -longitude(33, 22.12);
GeoAngle lat = -(6 + (22.48 / 60.0)) * degree;
GeoAngle lon = -(33 + (22.55 / 60.0)) * degree;
EXPECT_TRUE(NearDistance(distance(refpos, lat, lon), 2440.0 * si::meter , 10.0 * si::meter));
}
TYPED_TEST(CamFunctionsReferencePosition, distance_unavailable)
{
using SomeReferencePosition = TypeParam;
SomeReferencePosition pos1 {};
SomeReferencePosition pos2 {};
EXPECT_TRUE(is_available(pos1));
EXPECT_TRUE(is_available(pos2));
EXPECT_FALSE(std::isnan(distance(pos1, pos2).value()));
pos1.latitude = Latitude_unavailable;
EXPECT_FALSE(is_available(pos1));
EXPECT_TRUE(std::isnan(distance(pos1, pos2).value()));
EXPECT_TRUE(std::isnan(distance(pos2, pos1).value()));
pos1.latitude = 0;
pos1.longitude = Longitude_unavailable;
EXPECT_FALSE(is_available(pos1));
EXPECT_TRUE(std::isnan(distance(pos1, pos2).value()));
EXPECT_TRUE(std::isnan(distance(pos2, pos1).value()));
}
TYPED_TEST(CamFunctionsReferencePosition, copy)
{
using SomeReferencePosition = TypeParam;
PositionFix src;
src.latitude = 1.23 * vanetza::units::degree;
src.longitude = 4.56 * vanetza::units::degree;
src.confidence.orientation = vanetza::units::TrueNorth::from_value(10.0);
src.confidence.semi_major = 20 * vanetza::units::si::meter;
src.confidence.semi_minor = 15 * vanetza::units::si::meter;
SomeReferencePosition dest;
copy(src, dest);
EXPECT_EQ(dest.latitude, 123 * 100000);
EXPECT_EQ(dest.longitude, 456 * 100000);
EXPECT_EQ(dest.positionConfidenceEllipse.semiMajorConfidence, 20 * 100);
EXPECT_EQ(dest.positionConfidenceEllipse.semiMinorConfidence, 15 * 100);
EXPECT_EQ(dest.positionConfidenceEllipse.semiMajorOrientation, 10 * 10);
EXPECT_EQ(dest.altitude.altitudeValue, AltitudeValue_unavailable);
EXPECT_EQ(dest.altitude.altitudeConfidence, AltitudeConfidence_unavailable);
}
using PathHistoryTypes = ::testing::Types<::PathHistory, Vanetza_ITS2_PathHistory>;
template<typename T>
class CamFunctionsPathHistory : public ::testing::Test
{
protected:
vanetza::facilities::PathHistory path_history;
void add_sample(double lat, double lon, const std::string& time)
{
vanetza::facilities::PathPoint path_point;
path_point.latitude = lat * degree;
path_point.longitude = lon * degree;
path_point.time = boost::posix_time::from_iso_string(time);
path_history.addSample(path_point);
}
};
TYPED_TEST_SUITE(CamFunctionsPathHistory, PathHistoryTypes);
TYPED_TEST(CamFunctionsPathHistory, copy_path_history)
{
using SomePathHistory = TypeParam;
this->add_sample(40.906, 29.155, "20241027T031000");
this->add_sample(40.907, 29.156, "20241027T031010");
this->add_sample(40.908, 29.157, "20241027T031020");
SomePathHistory dest_path_history = {}; // zero-initialize struct
copy(this->path_history, dest_path_history);
int size = dest_path_history.list.count;
EXPECT_EQ(size, 2);
// incremental encoding: each point relative to the previous
EXPECT_EQ(dest_path_history.list.array[0]->pathPosition.deltaLatitude,
dest_path_history.list.array[1]->pathPosition.deltaLatitude);
EXPECT_EQ(*dest_path_history.list.array[0]->pathDeltaTime, 1000); // 10 s
EXPECT_EQ(*dest_path_history.list.array[1]->pathDeltaTime, 1000); // 10 s
for (int i = 0; i < size; i++) {
auto current_path_point = dest_path_history.list.array[i];
ASSERT_NE(current_path_point->pathDeltaTime, nullptr);
// check ASN.1 constraints
EXPECT_GE(*current_path_point->pathDeltaTime, 1);
EXPECT_LE(*current_path_point->pathDeltaTime, 65535);
EXPECT_GE(current_path_point->pathPosition.deltaLatitude, -131071);
EXPECT_LE(current_path_point->pathPosition.deltaLatitude, 131072);
EXPECT_GE(current_path_point->pathPosition.deltaLongitude, -131071);
EXPECT_LE(current_path_point->pathPosition.deltaLongitude, 131072);
EXPECT_GE(current_path_point->pathPosition.deltaAltitude, -12700);
EXPECT_LE(current_path_point->pathPosition.deltaAltitude, 12800);
}
asn1::reset(dest_path_history);
}
TYPED_TEST(CamFunctionsPathHistory, copy_clamps_stationary_delta_time)
{
using SomePathHistory = TypeParam;
this->add_sample(40.906, 29.155, "20241027T031000");
this->add_sample(40.907, 29.156, "20241027T031010");
this->add_sample(40.908, 29.157, "20241027T031020");
// parked ~20 min at the last position: reference far past the max PathDeltaTime
this->add_sample(40.908, 29.157, "20241027T033000");
SomePathHistory dest = {}; // zero-initialize struct
copy(this->path_history, dest);
ASSERT_EQ(dest.list.count, 3); // whole trace kept alive
ASSERT_NE(dest.list.array[0]->pathDeltaTime, nullptr);
EXPECT_EQ(*dest.list.array[0]->pathDeltaTime, 65535); // only the first point is clamped
for (int i = 1; i < dest.list.count; i++) {
ASSERT_NE(dest.list.array[i]->pathDeltaTime, nullptr);
EXPECT_LT(*dest.list.array[i]->pathDeltaTime, 65535); // later points keep their short gaps
}
asn1::reset(dest);
}
TYPED_TEST(CamFunctionsPathHistory, copy_truncates_after_long_stop)
{
using SomePathHistory = TypeParam;
// moved, parked ~21 min, then resumed: the stop is a >655 s gap in the middle
this->add_sample(40.9000, 29.1500, "20241027T031000"); // pre-stop trail
this->add_sample(40.9005, 29.1505, "20241027T031010");
this->add_sample(40.9010, 29.1510, "20241027T031020");
this->add_sample(40.9015, 29.1515, "20241027T033100"); // resumed ~21 min later
this->add_sample(40.9020, 29.1520, "20241027T033110");
this->add_sample(40.9025, 29.1525, "20241027T033120");
SomePathHistory dest = {}; // zero-initialize struct
copy(this->path_history, dest);
// trail is cut at the discontinuity, not carried across it with a clamped mid gap
ASSERT_GT(dest.list.count, 0);
for (int i = 0; i < dest.list.count; i++) {
ASSERT_NE(dest.list.array[i]->pathDeltaTime, nullptr);
EXPECT_LT(*dest.list.array[i]->pathDeltaTime, 65535);
}
asn1::reset(dest);
}
@@ -0,0 +1,223 @@
#include <vanetza/facilities/path_history.hpp>
#include <gtest/gtest.h>
using vanetza::facilities::PathHistory;
using vanetza::facilities::PathPoint;
namespace units = vanetza::units;
#define EXPECT_PATHPOINT_EQ(a, b) \
EXPECT_DOUBLE_EQ(a.latitude.value(), b.latitude.value()); \
EXPECT_DOUBLE_EQ(a.longitude.value(), b.longitude.value()); \
EXPECT_DOUBLE_EQ(a.heading.value(), b.heading.value()); \
EXPECT_EQ(a.time, b.time)
const units::GeoAngle cOneMeterLatitude = 1.0 / 111320.0 * units::degrees;
TEST(PathHistory, reference_point) {
PathHistory ph;
const PathPoint default_pp;
EXPECT_PATHPOINT_EQ(default_pp, ph.getReferencePoint());
PathPoint first_pp;
first_pp.longitude = 34.4 * units::degrees;
first_pp.latitude = -10.3 * units::degrees;
first_pp.heading = units::Angle { 48.3 * units::degrees };
first_pp.time = boost::posix_time::time_from_string("2014-11-21 11:11:48");
ph.addSample(first_pp);
EXPECT_PATHPOINT_EQ(first_pp, ph.getReferencePoint());
PathPoint second_pp;
second_pp.longitude = 34.4 * units::degrees;
second_pp.latitude = -10.3 * units::degrees;
second_pp.heading = units::Angle { 48.3 * units::degrees };
second_pp.time = boost::posix_time::time_from_string("2014-11-21 11:11:48.1");
ph.addSample(second_pp);
EXPECT_PATHPOINT_EQ(second_pp, ph.getReferencePoint());
}
TEST(PathHistory, concise_points_of_equal_samples) {
PathHistory ph;
EXPECT_EQ(0, ph.getConcisePoints().size());
PathPoint first_pp;
first_pp.longitude = -3.45 * units::degrees;
first_pp.latitude = 32.98 * units::degrees;
ph.addSample(first_pp);
ASSERT_EQ(1, ph.getConcisePoints().size());
EXPECT_PATHPOINT_EQ(first_pp, ph.getConcisePoints().front());
for (unsigned i = 0; i < 10; ++i) {
ph.addSample(first_pp);
EXPECT_EQ(1, ph.getConcisePoints().size());
}
}
TEST(PathHistory, concise_points_chord_length_threshold) {
PathHistory ph;
PathPoint pp;
pp.longitude = 0.0 * units::degrees;
pp.latitude = 0.0 * units::degrees;
ph.addSample(pp);
pp.latitude += cOneMeterLatitude;
ph.addSample(pp);
pp.latitude += cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(1, ph.getConcisePoints().size());
pp.latitude += cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(1, ph.getConcisePoints().size());
pp.latitude += 20.0 * cOneMeterLatitude;
ph.addSample(pp);
ASSERT_EQ(2, ph.getConcisePoints().size());
EXPECT_DOUBLE_EQ(3.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().front().latitude.value());
pp.latitude += 10.0 * cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(3, ph.getConcisePoints().size());
EXPECT_DOUBLE_EQ(23.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().front().latitude.value());
pp.latitude += 10.0 * cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(3, ph.getConcisePoints().size());
pp.latitude += 3.0 * cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(4, ph.getConcisePoints().size());
EXPECT_DOUBLE_EQ(43.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().front().latitude.value());
pp.latitude += 23.0 * cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(5, ph.getConcisePoints().size());
EXPECT_DOUBLE_EQ(46.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().front().latitude.value());
}
TEST(PathHistory, concise_points_actual_error_threshold) {
PathHistory ph;
PathPoint pp;
ph.addSample(pp);
pp.heading += units::Angle(5.0 * units::degrees);
ph.addSample(pp);
pp.latitude += 5.0 * cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(1, ph.getConcisePoints().size());
pp.latitude += 10.0 * units::degrees;
pp.longitude += 10.0 * units::degrees;
ph.addSample(pp);
EXPECT_EQ(2, ph.getConcisePoints().size());
}
TEST(PathHistory, concise_points_truncation) {
PathHistory::Parameters params;
params.retention_distance = 200.0 * units::si::meter;
PathHistory ph(params);
PathPoint pp;
pp.latitude = 0.0 * units::degree;
pp.longitude = 0.0 * units::degree;
ph.addSample(pp);
pp.latitude += 25.0 * cOneMeterLatitude;
ph.addSample(pp);
pp.latitude += 25.0 * cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(2, ph.getConcisePoints().size());
pp.latitude += 25.0 * cOneMeterLatitude;
ph.addSample(pp);
EXPECT_EQ(3, ph.getConcisePoints().size());
pp.latitude += 205.0 * cOneMeterLatitude;
ph.addSample(pp);
ASSERT_EQ(4, ph.getConcisePoints().size());
EXPECT_DOUBLE_EQ(0.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().back().latitude.value());
EXPECT_DOUBLE_EQ(75.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().front().latitude.value());
ph.addSample(pp);
ASSERT_EQ(2, ph.getConcisePoints().size());
EXPECT_DOUBLE_EQ(75.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().back().latitude.value());
EXPECT_DOUBLE_EQ(280.0 * cOneMeterLatitude.value(),
ph.getConcisePoints().front().latitude.value());
}
TEST(PathHistory, clear_resets) {
PathHistory ph;
PathPoint pp;
pp.latitude = 0.0 * units::degrees;
pp.longitude = 0.0 * units::degrees;
for (unsigned i = 0; i < 5; ++i) {
pp.latitude += 25.0 * cOneMeterLatitude;
ph.addSample(pp);
}
ASSERT_GT(ph.getConcisePoints().size(), 1u);
ph.clear();
EXPECT_EQ(0u, ph.getConcisePoints().size());
EXPECT_PATHPOINT_EQ(PathPoint(), ph.getReferencePoint());
// usable again, no stale points carried across the reset
ph.addSample(pp);
EXPECT_EQ(1u, ph.getConcisePoints().size());
}
TEST(PathHistory, custom_retention_keeps_more) {
PathHistory::Parameters short_params;
short_params.retention_distance = 200.0 * units::si::meter;
PathHistory short_hist(short_params);
PathHistory::Parameters long_params;
long_params.retention_distance = 500.0 * units::si::meter;
PathHistory long_hist(long_params);
PathPoint pp;
pp.latitude = 0.0 * units::degrees;
pp.longitude = 0.0 * units::degrees;
short_hist.addSample(pp);
long_hist.addSample(pp);
for (unsigned i = 0; i < 20; ++i) {
pp.latitude += 25.0 * cOneMeterLatitude; // ~25 m steps, ~500 m total
short_hist.addSample(pp);
long_hist.addSample(pp);
}
EXPECT_GT(long_hist.getConcisePoints().size(), short_hist.getConcisePoints().size());
}
TEST(PathHistory, concise_points_retrieval_limits) {
PathHistory ph;
PathPoint pp;
pp.latitude = 0.0 * units::degrees;
pp.longitude = 0.0 * units::degrees;
ph.addSample(pp);
for (unsigned i = 0; i < 6; ++i) {
pp.latitude += 25.0 * cOneMeterLatitude; // beyond chord threshold: a concise point each
ph.addSample(pp);
}
const std::list<PathPoint>& full = ph.getConcisePoints();
ASSERT_GT(full.size(), 3u);
// concise points ~25 m apart, so at a 30 m threshold:
// "covering" includes the point crossing 30 m, "within" excludes it
const auto covering = ph.getConcisePointsMinLength(30.0 * units::si::meter);
const auto within = ph.getConcisePointsMaxLength(30.0 * units::si::meter);
EXPECT_EQ(3, std::distance(covering.begin(), covering.end()));
EXPECT_EQ(2, std::distance(within.begin(), within.end()));
EXPECT_DOUBLE_EQ(full.front().latitude.value(), covering.front().latitude.value());
EXPECT_DOUBLE_EQ(full.front().latitude.value(), within.front().latitude.value());
// optional point limit keeps the newest points
const auto capped = ph.getConcisePointsMinLength(10000.0 * units::si::meter, 2);
EXPECT_EQ(2, std::distance(capped.begin(), capped.end()));
EXPECT_DOUBLE_EQ(full.front().latitude.value(), capped.front().latitude.value());
}
@@ -0,0 +1,20 @@
#include <vanetza/facilities/path_point.hpp>
#include <gtest/gtest.h>
namespace units = vanetza::units;
using vanetza::facilities::PathPoint;
TEST(PathPoint, chord_length) {
PathPoint a;
a.longitude = 12.14094444 * units::degrees;
a.latitude = 49.53858333 * units::degrees;
PathPoint b;
b.longitude = 12.27191666 * units::degrees;
b.latitude = 49.72394444 * units::degrees;
EXPECT_EQ(chord_length(a, b), chord_length(b, a));
units::Length length = chord_length(a, b);
EXPECT_NEAR(22692.54, length / units::si::meters, 0.01);
}