From 872dca6772536a9aa59106da8e4eba4b4d72cbe6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Nov 2025 15:52:47 +0000 Subject: [PATCH 1/3] Add geometry validation and area calculation functions Co-authored-by: erik --- src/e84_geoai_common/geometry.py | 73 +++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/e84_geoai_common/geometry.py b/src/e84_geoai_common/geometry.py index c3aed75..c1642fc 100644 --- a/src/e84_geoai_common/geometry.py +++ b/src/e84_geoai_common/geometry.py @@ -24,6 +24,32 @@ from e84_geoai_common.tracing import timed_function +def validate_and_fix_geometry(geom: BaseGeometry) -> BaseGeometry: + """Validate and fix invalid geometries. + + Common issues in GeoSpatial data include self-intersections, duplicate vertices, + and topological errors. This function attempts to fix these automatically. + + Args: + geom: The geometry to validate and fix. + + Returns: + A valid geometry. If the input is already valid, returns it unchanged. + If invalid, attempts to fix using buffer(0) which resolves many common issues. + + Example: + >>> from shapely import Polygon + >>> invalid = Polygon([(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)]) # self-intersecting + >>> fixed = validate_and_fix_geometry(invalid) + >>> fixed.is_valid + True + """ + if geom.is_valid: + return geom + # buffer(0) is a common technique to fix invalid geometries + return geom.buffer(0) + + def geometry_from_wkt(wkt: str) -> BaseGeometry: """Create shapely geometry from Well-Known Text (WKT) string.""" return shapely.from_wkt(wkt) # type: ignore[reportUnknownVariableType] @@ -222,7 +248,7 @@ def remove_extraneous_geoms(geom: BaseGeometry, *, max_points: int) -> BaseGeome return combine_geometry(geoms_to_combine) -# FUTURE the performance of this could be spead up for very large geometries with many small +# FUTURE the performance of this could be sped up for very large geometries with many small # polygons by comparing the number of sub geometries to the number of total points. If a ratio # exceeds a certain percentage then it may make sense to remove geometries initially that # are less than a certain percent of the total area. Then simplify after that. That could help @@ -347,3 +373,48 @@ def add_buffer(g: BaseGeometry, distance_km: float) -> BaseGeometry: # This will fall apart at the poles but works for our current use cases. return g.buffer((lon_distance + lat_distance) / 2.0) + + +def approximate_area_km2(g: BaseGeometry) -> float: + """Calculate approximate area in square kilometers for a geometry in WGS84 coordinates. + + Uses a simple approximation based on the average latitude of the geometry. + More accurate than using square degrees directly, but less accurate than + proper geodesic calculations. Suitable for most GeoSpatial AI applications + where approximate area is sufficient. + + Args: + g: The input geometry in WGS84 (EPSG:4326) coordinates. + + Returns: + Approximate area in square kilometers. + + Note: + - Assumes input geometry is in WGS84 (longitude/latitude) coordinates. + - Accuracy decreases near the poles and for very large geometries. + - For high-precision area calculations, consider reprojecting to an + appropriate projected coordinate system. + + Example: + >>> from shapely.geometry import box + >>> # A rough 1-degree by 1-degree box near the equator + >>> square = box(0, 0, 1, 1) + >>> area = approximate_area_km2(square) + >>> print(f"Area: {area:.2f} km²") + Area: 12364.46 km² + """ + # Get area in square degrees + area_deg2 = g.area + + # Convert to approximate km² using average latitude + avg_lat = g.centroid.y + avg_lat_rad = degrees_to_radians(avg_lat) + + # One degree of latitude is approximately 111.32 km everywhere + km_per_deg_lat = 111.32 + + # One degree of longitude varies with latitude: 111.32 * cos(lat) + km_per_deg_lon = 111.32 * math.cos(avg_lat_rad) + + # Convert square degrees to square kilometers + return area_deg2 * km_per_deg_lat * km_per_deg_lon From 6cb3b2124370162248195570e85d9a405df960f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Nov 2025 15:58:26 +0000 Subject: [PATCH 2/3] Add new LLM models to converse module Co-authored-by: erik --- src/e84_geoai_common/llm/models/__init__.py | 24 +++++++++++++++ .../llm/models/converse/__init__.py | 24 +++++++++++++++ .../llm/models/converse/converse.py | 30 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/e84_geoai_common/llm/models/__init__.py b/src/e84_geoai_common/llm/models/__init__.py index 75bb977..b4aafe1 100644 --- a/src/e84_geoai_common/llm/models/__init__.py +++ b/src/e84_geoai_common/llm/models/__init__.py @@ -15,6 +15,10 @@ ClaudeInvokeLLMRequest, ) from e84_geoai_common.llm.models.converse import ( + COHERE_COMMAND_LIGHT_TEXT, + COHERE_COMMAND_R, + COHERE_COMMAND_R_PLUS, + COHERE_COMMAND_TEXT, CONVERSE_BEDROCK_MODEL_IDS, LLAMA_3_1_8_B_INSTRUCT, LLAMA_3_1_70_B_INSTRUCT, @@ -23,6 +27,14 @@ LLAMA_3_2_11_B_VISION_INSTRUCT, LLAMA_3_2_90_B_VISION_INSTRUCT, LLAMA_3_3_70_B_INSTRUCT, + MISTRAL_7B_INSTRUCT, + MISTRAL_8X7B_INSTRUCT, + MISTRAL_LARGE, + MISTRAL_LARGE_2407, + MISTRAL_SMALL, + TITAN_TEXT_EXPRESS, + TITAN_TEXT_LITE, + TITAN_TEXT_PREMIER, BedrockConverseLLM, ConverseInvokeLLMRequest, ) @@ -48,6 +60,10 @@ "CLAUDE_4_SONNET", "CLAUDE_BEDROCK_MODEL_IDS", "CLAUDE_INSTANT", + "COHERE_COMMAND_LIGHT_TEXT", + "COHERE_COMMAND_R", + "COHERE_COMMAND_R_PLUS", + "COHERE_COMMAND_TEXT", "CONVERSE_BEDROCK_MODEL_IDS", "LLAMA_3_1_8_B_INSTRUCT", "LLAMA_3_1_70_B_INSTRUCT", @@ -56,12 +72,20 @@ "LLAMA_3_2_11_B_VISION_INSTRUCT", "LLAMA_3_2_90_B_VISION_INSTRUCT", "LLAMA_3_3_70_B_INSTRUCT", + "MISTRAL_7B_INSTRUCT", + "MISTRAL_8X7B_INSTRUCT", + "MISTRAL_LARGE", + "MISTRAL_LARGE_2407", + "MISTRAL_SMALL", "NOVA_BEDROCK_MODEL_IDS", "NOVA_CANVAS", "NOVA_LITE", "NOVA_MICRO", "NOVA_PRO", "NOVA_REEL", + "TITAN_TEXT_EXPRESS", + "TITAN_TEXT_LITE", + "TITAN_TEXT_PREMIER", "BedrockClaudeLLM", "BedrockConverseLLM", "BedrockNovaLLM", diff --git a/src/e84_geoai_common/llm/models/converse/__init__.py b/src/e84_geoai_common/llm/models/converse/__init__.py index 539c6b2..201efe9 100644 --- a/src/e84_geoai_common/llm/models/converse/__init__.py +++ b/src/e84_geoai_common/llm/models/converse/__init__.py @@ -1,6 +1,10 @@ """Wrappers for Bedrock Converse API.""" from e84_geoai_common.llm.models.converse.converse import ( + COHERE_COMMAND_LIGHT_TEXT, + COHERE_COMMAND_R, + COHERE_COMMAND_R_PLUS, + COHERE_COMMAND_TEXT, CONVERSE_BEDROCK_MODEL_IDS, LLAMA_3_1_8_B_INSTRUCT, LLAMA_3_1_70_B_INSTRUCT, @@ -9,6 +13,14 @@ LLAMA_3_2_11_B_VISION_INSTRUCT, LLAMA_3_2_90_B_VISION_INSTRUCT, LLAMA_3_3_70_B_INSTRUCT, + MISTRAL_7B_INSTRUCT, + MISTRAL_8X7B_INSTRUCT, + MISTRAL_LARGE, + MISTRAL_LARGE_2407, + MISTRAL_SMALL, + TITAN_TEXT_EXPRESS, + TITAN_TEXT_LITE, + TITAN_TEXT_PREMIER, BedrockConverseLLM, ConverseInferenceConfig, ConverseInvokeLLMRequest, @@ -61,6 +73,10 @@ ) __all__ = [ + "COHERE_COMMAND_LIGHT_TEXT", + "COHERE_COMMAND_R", + "COHERE_COMMAND_R_PLUS", + "COHERE_COMMAND_TEXT", "CONVERSE_BEDROCK_MODEL_IDS", "LLAMA_3_1_8_B_INSTRUCT", "LLAMA_3_1_70_B_INSTRUCT", @@ -69,6 +85,14 @@ "LLAMA_3_2_11_B_VISION_INSTRUCT", "LLAMA_3_2_90_B_VISION_INSTRUCT", "LLAMA_3_3_70_B_INSTRUCT", + "MISTRAL_7B_INSTRUCT", + "MISTRAL_8X7B_INSTRUCT", + "MISTRAL_LARGE", + "MISTRAL_LARGE_2407", + "MISTRAL_SMALL", + "TITAN_TEXT_EXPRESS", + "TITAN_TEXT_LITE", + "TITAN_TEXT_PREMIER", "BedrockConverseLLM", "ConverseAdditionalModelRequestFields", "ConverseAnyToolChoice", diff --git a/src/e84_geoai_common/llm/models/converse/converse.py b/src/e84_geoai_common/llm/models/converse/converse.py index bcae7c0..ccfd898 100644 --- a/src/e84_geoai_common/llm/models/converse/converse.py +++ b/src/e84_geoai_common/llm/models/converse/converse.py @@ -84,6 +84,24 @@ LLAMA_3_2_90_B_VISION_INSTRUCT = "us.meta.llama3-2-90b-instruct-v1:0" LLAMA_3_3_70_B_INSTRUCT = "us.meta.llama3-3-70b-instruct-v1:0" +# Amazon Titan Models +TITAN_TEXT_EXPRESS = "amazon.titan-text-express-v1" +TITAN_TEXT_LITE = "amazon.titan-text-lite-v1" +TITAN_TEXT_PREMIER = "amazon.titan-text-premier-v1:0" + +# Mistral AI Models +MISTRAL_7B_INSTRUCT = "mistral.mistral-7b-instruct-v0:2" +MISTRAL_8X7B_INSTRUCT = "mistral.mixtral-8x7b-instruct-v0:1" +MISTRAL_LARGE = "mistral.mistral-large-2402-v1:0" +MISTRAL_LARGE_2407 = "mistral.mistral-large-2407-v1:0" +MISTRAL_SMALL = "mistral.mistral-small-2402-v1:0" + +# Cohere Models +COHERE_COMMAND_TEXT = "cohere.command-text-v14" +COHERE_COMMAND_LIGHT_TEXT = "cohere.command-light-text-v14" +COHERE_COMMAND_R = "cohere.command-r-v1:0" +COHERE_COMMAND_R_PLUS = "cohere.command-r-plus-v1:0" + # DEPRECATED: Use the constants above instead. CONVERSE_BEDROCK_MODEL_IDS = { "Claude 3 Haiku": CLAUDE_3_HAIKU, @@ -104,6 +122,18 @@ "Llama 3.2 3B Instruct": LLAMA_3_2_3_B_INSTRUCT, "Llama 3.2 90B Vision Instruct": LLAMA_3_2_90_B_VISION_INSTRUCT, "Llama 3.3 70B Instruct": LLAMA_3_3_70_B_INSTRUCT, + "Titan Text Express": TITAN_TEXT_EXPRESS, + "Titan Text Lite": TITAN_TEXT_LITE, + "Titan Text Premier": TITAN_TEXT_PREMIER, + "Mistral 7B Instruct": MISTRAL_7B_INSTRUCT, + "Mistral 8x7B Instruct": MISTRAL_8X7B_INSTRUCT, + "Mistral Large": MISTRAL_LARGE, + "Mistral Large 2407": MISTRAL_LARGE_2407, + "Mistral Small": MISTRAL_SMALL, + "Cohere Command Text": COHERE_COMMAND_TEXT, + "Cohere Command Light Text": COHERE_COMMAND_LIGHT_TEXT, + "Cohere Command R": COHERE_COMMAND_R, + "Cohere Command R+": COHERE_COMMAND_R_PLUS, } From a56c3c68da8979b351f44902e9883108aa5da321 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Nov 2025 16:03:28 +0000 Subject: [PATCH 3/3] Add geometry validation and area calculation Co-authored-by: erik --- tests/test_geometry.py | 144 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 21231b0..c80f099 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,4 +1,4 @@ -from math import cos, pi, sin +from math import cos, pi, radians, sin import pytest from shapely import ( @@ -10,14 +10,17 @@ Point, count_coordinates, ) +from shapely.geometry import box from shapely.geometry.base import BaseGeometry from shapely.geometry.polygon import Polygon from shapely.validation import explain_validity from e84_geoai_common.geometry import ( + approximate_area_km2, geometry_to_polygon, remove_extraneous_geoms, simplify_geometry, + validate_and_fix_geometry, ) @@ -302,3 +305,142 @@ def test_geometry_to_polygon(): # MultiLinestring ce_multiline = MultiLineString([c_bottom_line, e_line]) assert geometry_to_polygon(ce_multiline).equals(Polygon([(13, 1), (15, 1), (19, 10), (13, 1)])) + + +def test_validate_and_fix_geometry_valid(): + """Test that valid geometries are returned unchanged.""" + # Valid point + point = Point(0, 0) + assert validate_and_fix_geometry(point) == point + assert validate_and_fix_geometry(point).is_valid + + # Valid polygon + polygon = Polygon([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]) + assert validate_and_fix_geometry(polygon) == polygon + assert validate_and_fix_geometry(polygon).is_valid + + # Valid line + line = LineString([(0, 0), (1, 1)]) + assert validate_and_fix_geometry(line) == line + assert validate_and_fix_geometry(line).is_valid + + +def test_validate_and_fix_geometry_invalid(): + """Test that invalid geometries are fixed using buffer(0).""" + # Self-intersecting polygon (bowtie shape) + # Creates a polygon that crosses itself + invalid_polygon = Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)]) + + # Verify it's invalid + assert not invalid_polygon.is_valid + + # Fix it + fixed = validate_and_fix_geometry(invalid_polygon) + + # Fixed geometry should be valid + assert fixed.is_valid + + # Fixed geometry should have non-zero area (buffer(0) resolves self-intersection) + assert fixed.area > 0 + + +def test_validate_and_fix_geometry_duplicate_points(): + """Test fixing polygons with duplicate consecutive points.""" + # Polygon with duplicate points + polygon_with_dupes = Polygon([(0, 0), (1, 0), (1, 0), (1, 1), (0, 1), (0, 0)]) + + # Note: shapely may already handle this, but if it creates an invalid + # geometry, our function should fix it + if not polygon_with_dupes.is_valid: + fixed = validate_and_fix_geometry(polygon_with_dupes) + assert fixed.is_valid + + +def test_approximate_area_km2_equator(): + """Test area calculation near the equator (latitude 0).""" + # 1 degree by 1 degree box at the equator + square = box(0, 0, 1, 1) + + area = approximate_area_km2(square) + + # At the equator: + # 1 degree latitude ≈ 111.32 km + # 1 degree longitude ≈ 111.32 km (cos(0) = 1) + # Expected area ≈ 111.32 * 111.32 ≈ 12,392 km² + expected_area = 111.32 * 111.32 + + # Allow 1% tolerance for floating point + assert abs(area - expected_area) / expected_area < 0.01 + + +def test_approximate_area_km2_mid_latitude(): + """Test area calculation at 45 degrees latitude.""" + # 1 degree by 1 degree box at 45° latitude + square = box(0, 45, 1, 46) + + area = approximate_area_km2(square) + + # At 45° latitude: + # 1 degree latitude ≈ 111.32 km + # 1 degree longitude ≈ 111.32 * cos(45°) ≈ 78.71 km + # Centroid is at 45.5°, so cos(45.5°) + avg_lat = 45.5 + expected_area = 111.32 * (111.32 * cos(radians(avg_lat))) + + # Allow 1% tolerance + assert abs(area - expected_area) / expected_area < 0.01 + + +def test_approximate_area_km2_small_area(): + """Test area calculation for a small polygon.""" + # 0.1 degree by 0.1 degree box at the equator + small_square = box(0, 0, 0.1, 0.1) + + area = approximate_area_km2(small_square) + + # Expected area ≈ (111.32 * 0.1) * (111.32 * 0.1) ≈ 123.92 km² + expected_area = (111.32 * 0.1) * (111.32 * 0.1) + + # Allow 1% tolerance + assert abs(area - expected_area) / expected_area < 0.01 + + +def test_approximate_area_km2_polygon(): + """Test area calculation for an irregular polygon.""" + # Triangle at the equator + triangle = Polygon([(0, 0), (1, 0), (0.5, 1), (0, 0)]) + + area = approximate_area_km2(triangle) + + # Area should be positive and reasonable + assert area > 0 + # Triangle area is half the bounding box + # Bounding box is ~1 degree x 1 degree at equator ≈ 12,392 km² + # So triangle should be around 6,196 km² + assert 5000 < area < 8000 + + +def test_approximate_area_km2_northern_hemisphere(): + """Test that area calculation works in northern latitudes.""" + # 1 degree by 1 degree box at 60° latitude + square = box(0, 60, 1, 61) + + area = approximate_area_km2(square) + + # At 60° latitude, longitude distance is roughly half of equator + # cos(60.5°) ≈ 0.495 + # Expected area smaller than at equator + assert area > 0 + assert area < 12392 # Less than equator area + + +def test_approximate_area_km2_southern_hemisphere(): + """Test that area calculation works in southern latitudes.""" + # 1 degree by 1 degree box at -30° latitude + square = box(0, -30, 1, -29) + + area = approximate_area_km2(square) + + # Should be symmetric with northern hemisphere + assert area > 0 + assert area < 12392 # Less than equator but more than 60° latitude