From dbb9f35d9be4fafa5d829790f86794aa053c5187 Mon Sep 17 00:00:00 2001 From: Philip Sargent Date: Sat, 9 May 2026 20:44:22 +0100 Subject: [PATCH] 1626/1623 detection and tests --- core/1623-6_border.gpx | 147 ++++++++++++++++++++++++++ core/cleaned_border_output.gpx | 7 ++ core/position_tests.py | 80 ++++++++++++++ core/position_utils.py | 184 +++++++++++++++++++++++++++++++++ core/test_points.gpx | 2 + 5 files changed, 420 insertions(+) create mode 100644 core/1623-6_border.gpx create mode 100644 core/cleaned_border_output.gpx create mode 100644 core/position_tests.py create mode 100644 core/position_utils.py create mode 100644 core/test_points.gpx diff --git a/core/1623-6_border.gpx b/core/1623-6_border.gpx new file mode 100644 index 000000000..e64adddef --- /dev/null +++ b/core/1623-6_border.gpx @@ -0,0 +1,147 @@ + + + 1626/3-as-trk + just the 1623/6 border + + 1626/3-as-trk + 1 + + + + +1626-22 +1626-21 +1626-20 +1626-19 +1626-18 +1626-17 +1626-16 +1626-15 +1626-14 +1626-13 +1626-12 +1626-11 +1626-10 +1626-9 +1626-8 +1626-7 +1626-6 +1626-5 +1626-4 +1626-3 +1626-2 +1626-1 +1626-323 +1626-322 +1626-321 +1626-320 +1626-319 +1626-318 +1626-317 +1626-316 +1626-315 +1626-314 +1626-313 +1626-312 +1626-311 +1626-310 +1626-309 +1626-308 +1626-307 +1626-306 +1626-305 +1626-304 +1626-303 +1623-77 +1623-78 +1623-79 +1623-80 +1626-299 +1623-81 +1626-298 +1623-82 +1626-297 +1623-83 +1626-296 +1623-84 +1626-295 +1623-85 +1626-294 +1623-86 +1626-293 +1623-87 +1626-292 +1623-88 +1626-291 +1623-89 +1626-290 +1623-90 +1626-289 +1623-91 +1626-288 +1623-92 +1626-287 +1623-93 +1626-286 +1623-94 +1626-285 +1623-95 +1626-284 +1623-96 +1626-283 +1623-97 +1626-282 +1623-98 +1626-281 +1623-99 +1626-280 +1623-100 +1626-279 +1623-101 +1626-278 +1623-102 +1626-277 +1623-103 +1626-276 +1623-104 +1626-275 +1623-105 +1626-274 +1623-106 +1626-273 +1623-107 +1626-272 +1623-108 +1626-271 +1623-109 +1626-270 +1623-110 +1626-269 +1623-111 +1626-268 +1623-112 +1626-267 +1623-113 +1626-266 +1623-114 +1626-265 +1623-115 +1626-264 +1623-116 +1626-263 +1623-117 +1626-262 +1623-118 +1626-261 +1623-119 +1626-260 +1623-120 +1626-259 +1623-121 +1626-258 +1623-122 +1626-257 +1623-123 +1626-256 + + diff --git a/core/cleaned_border_output.gpx b/core/cleaned_border_output.gpx new file mode 100644 index 000000000..6af5be294 --- /dev/null +++ b/core/cleaned_border_output.gpx @@ -0,0 +1,7 @@ + + + + Cleaned Border 1626-1623 + + + \ No newline at end of file diff --git a/core/position_tests.py b/core/position_tests.py new file mode 100644 index 000000000..bdd8ca019 --- /dev/null +++ b/core/position_tests.py @@ -0,0 +1,80 @@ +import xml.etree.ElementTree as ET + +from position_utils import which_area # file-type import, not module type. + +def run_limit_tests(which_area_func): + # Boundary limits derived from GPX data + west_limit = 13.72476763 # 1626-22 [cite: 1] + east_limit = 13.86031535 # 1626-256 [cite: 3] + + print("Running Border Logic Tests...") + + # 1. Invalid point to the West + res, area = which_area_func(47.69, west_limit - 0.01) + print(f"Test West: Expected (False, 'None'), Got ({res}, '{area}')") + + # 2. Invalid point to the East + res, area = which_area_func(47.70, east_limit + 0.01) + print(f"Test East: Expected (False, 'None'), Got ({res}, '{area}')") + + +def generate_test_data(): + offset = 0.0005 + + # 6 base points extracted from the track to ensure we cover the whole E/W span + # Coordinates from source GPX + + base_coords = [ + (47.69048595, 13.72476763 + 2* offset), # Far West (1626-22) + (47.68532296, 13.73512238), # Monotonic section 1 (1626-13) + (47.67441976, 13.76998479), # Central dip (1626-308) + (47.68909077, 13.79091566), # Rising section (1626-294) + (47.70536617, 13.82402883), # Northern peak (1626-278) + (47.70002466, 13.86031535 - 2* offset) # Far East (1626-256) + ] + + test_points = [] + + for lat, lon in base_coords: + # Generate North point (Area 1626) + test_points.append({'lat': lat + offset, 'lon': lon + offset, 'area': '1626', 'desc': 'North_Test'}) + # Generate South point (Area 1623) + test_points.append({'lat': lat - offset, 'lon': lon - offset, 'area': '1623', 'desc': 'South_Test'}) + + return test_points + +def export_test_gpx(test_points, filename="test_points.gpx"): + gpx = ET.Element("gpx", version="1.1", creator="Python Script", + xmlns="http://www.topografix.com/GPX/1/1") + + for pt in test_points: + wpt = ET.SubElement(gpx, "wpt", lat=str(pt['lat']), lon=str(pt['lon'])) + name = ET.SubElement(wpt, "name") + name.text = f"{pt['area']}_{pt['desc']}" + desc = ET.SubElement(wpt, "desc") + desc.text = f"Expected Area: {pt['area']}" + + tree = ET.ElementTree(gpx) + tree.write(filename, encoding="utf-8", xml_declaration=True) + print(f"Successfully created {filename} with 12 test waypoints.") + +def run_12_point_test(which_area_func): + points = generate_test_data() + export_test_gpx(points) + + passed = 0 + + print(f"{'Lat':<12} | {'Lon':<12} | {'Expected':<10} | {'Result'}") + print("-" * 55) + + for pt in points: + valid, area = which_area_func(pt['lat'], pt['lon']) + status = "PASS" if (valid and area == pt['area']) else "FAIL" + if status == "PASS": passed += 1 + print(f"{pt['lat']:<12.6f} | {pt['lon']:<12.6f} | {pt['area']:<10} | {status}") + + print(f"\nSummary: {passed}/12 points passed.") + +# Uncomment to run: +run_limit_tests(which_area) +run_12_point_test(which_area) \ No newline at end of file diff --git a/core/position_utils.py b/core/position_utils.py new file mode 100644 index 000000000..c0168a630 --- /dev/null +++ b/core/position_utils.py @@ -0,0 +1,184 @@ +import bisect +from pathlib import Path +import xml.etree.ElementTree as ET + +def load_and_clean_gpx(filename): + if not Path(filename).exists: + print("No file") + else: + print(f"Loading {filename}") + # Parse GPX + tree = ET.parse(filename) + root = tree.getroot() + + # Extract points (handling namespaces) + ns = {'gpx': 'http://www.topografix.com/GPX/1/0'} + raw_points = [] + for pt in root.findall('.//gpx:trkpt', ns): + lat = float(pt.get('lat')) + lon = float(pt.get('lon')) + raw_points.append((lon, lat)) # Using (x, y) order for easier logic + + n_raw = len(raw_points) + if not raw_points: + return [] + + # 1. Remove exactly duplicate consecutive points + cleaned_points = [raw_points[0]] + for i in range(1, len(raw_points)): + if raw_points[i] != raw_points[i-1]: + cleaned_points.append(raw_points[i]) + + n_clean =len(cleaned_points) + print(f"read {n_raw} points and returned {n_clean} cleaned points") + return cleaned_points + + + +def save_cleaned_gpx(points, output_filename="cleaned_border.gpx"): + """ + Saves a list of (lon, lat) tuples as a GPX track. + """ + # Create the root element with necessary namespaces + root = ET.Element("gpx", + version="1.1", + creator="CaveAreaProcessor", + xmlns="http://www.topografix.com/GPX/1/1") + + # Create the track structure + trk = ET.SubElement(root, "trk") + name = ET.SubElement(trk, "name") + name.text = "Cleaned Border 1626-1623" + + trkseg = ET.SubElement(trk, "trkseg") + + # Add each point as a track point + for lon, lat in points: + # Note: GPX uses lat/lon attributes, we provide them as strings + ET.SubElement(trkseg, "trkpt", lat=str(lat), lon=str(lon)) + + # Write to file + tree = ET.ElementTree(root) + # Use indenting for readability if available (Python 3.9+) + if hasattr(ET, "indent"): + ET.indent(tree, space="\t", level=0) + + tree.write(output_filename, encoding="windows-1252", xml_declaration=True) + print(f"Cleaned track exported to {output_filename}") + + +def split_into_monotonic_segments(points): + """ + Splits the track into segments that are monotonic in Longitude (E/W). + This allows us to use binary search on each segment. + """ + if not points: return [] + + segments = [] + current_segment = [points[0]] + + for i in range(1, len(points)): + p_prev = points[i-1] + p_curr = points[i] + + # Check if we should start a new segment based on direction change in Lon + if len(current_segment) > 1: + prev_dir = current_segment[-1][0] - current_segment[-2][0] + curr_dir = p_curr[0] - p_prev[0] + + # If direction changed (and wasn't zero before), split + if (prev_dir > 0 and curr_dir < 0) or (prev_dir < 0 and curr_dir > 0): + segments.append(current_segment) + current_segment = [p_prev] + + current_segment.append(p_curr) + + segments.append(current_segment) + print(len(segments)) + return segments + + + + +PREPARED_SEGMENTS = [] +MIN_LON = 0 +MAX_LON = 0 + +def generate_boundary_segments(): + # Example Workflow: + points = load_and_clean_gpx('1623-6_border.gpx') + # save_cleaned_gpx(points, "cleaned_border_output.gpx") # done once, not needed + mono_segments = split_into_monotonic_segments(points) + + # Pre-calculate global bounds and segment metadata + ALL_LONS = [p[0] for p in points] + MIN_LON, MAX_LON = min(ALL_LONS), max(ALL_LONS) + + # Prepare segments for binary search + # We store each segment as (sorted_lons, corresponding_lats) + for seg in mono_segments: + lons = [p[0] for p in seg] + lats = [p[1] for p in seg] + + # Ensure lons are strictly increasing for bisect + is_increasing = lons[-1] > lons[0] + if not is_increasing: + lons.reverse() + lats.reverse() + + PREPARED_SEGMENTS.append({ + 'lons': lons, + 'lats': lats, + 'min_lon': min(lons), + 'max_lon': max(lons) + }) + return MIN_LON, MAX_LON + +def which_area(lat, lon): + global PREPARED_SEGMENTS, MIN_LON, MAX_LON + + if not PREPARED_SEGMENTS: + MIN_LON, MAX_LON = generate_boundary_segments() + + # Fast boundary check (East/West limits) + if lon < MIN_LON or lon > MAX_LON: + return False, "None" + + # Area 1626 is North, Area 1623 is South + # We find the boundary latitude at this longitude + boundary_lat = None + + for seg in PREPARED_SEGMENTS: + # Check if lon is within this monotonic segment + if seg['min_lon'] <= lon <= seg['max_lon']: + lons = seg['lons'] + lats = seg['lats'] + + # Binary search to find the segment indices O(log n) + idx = bisect.bisect_right(lons, lon) + + if idx == 0: + boundary_lat = lats[0] + elif idx == len(lons): + boundary_lat = lats[-1] + else: + # Simple Linear Interpolation (no square roots/Pythagoras) + # lat = y1 + (y2 - y1) * (x - x1) / (x2 - x1) + x1, x2 = lons[idx-1], lons[idx] + y1, y2 = lats[idx-1], lats[idx] + + # Basic ratio calculation + t = (lon - x1) / (x2 - x1) + boundary_lat = y1 + t * (y2 - y1) + + # Once we find the boundary at this lon, we compare + # Note: If the line doubles back, this logic uses the first segment found. + break + + if boundary_lat is None: + return False, "None" + + # Compare input lat to boundary lat + # North of boundary = 1626, South = 1623 + area = "1626" if lat >= boundary_lat else "1623" + return True, area \ No newline at end of file diff --git a/core/test_points.gpx b/core/test_points.gpx new file mode 100644 index 000000000..6f3df42b5 --- /dev/null +++ b/core/test_points.gpx @@ -0,0 +1,2 @@ + +1626_North_TestExpected Area: 16261623_South_TestExpected Area: 16231626_North_TestExpected Area: 16261623_South_TestExpected Area: 16231626_North_TestExpected Area: 16261623_South_TestExpected Area: 16231626_North_TestExpected Area: 16261623_South_TestExpected Area: 16231626_North_TestExpected Area: 16261623_South_TestExpected Area: 16231626_North_TestExpected Area: 16261623_South_TestExpected Area: 1623 \ No newline at end of file