geospatial-mcp-server 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .env
10
+ .venv/
11
+ venv/
12
+ *.log
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AiAgentKarl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: geospatial-mcp-server
3
+ Version: 0.1.0
4
+ Summary: Geospatial MCP Server — Geocoding, POI-Suche, Routing und Gebietsstatistiken via OpenStreetMap fuer AI Agents
5
+ Project-URL: Homepage, https://github.com/AiAgentKarl/geospatial-mcp-server
6
+ Project-URL: Repository, https://github.com/AiAgentKarl/geospatial-mcp-server
7
+ Project-URL: Issues, https://github.com/AiAgentKarl/geospatial-mcp-server/issues
8
+ Author: AiAgentKarl
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,geocoding,geospatial,mcp,nominatim,openstreetmap,osm,overpass
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: GIS
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27.0
23
+ Requires-Dist: mcp>=1.0.0
24
+ Requires-Dist: pydantic>=2.0.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # geospatial-mcp-server
28
+
29
+ <!-- mcp-name: geospatial-mcp-server -->
30
+
31
+ MCP Server for geospatial data — gives AI agents access to geocoding, reverse geocoding, POI search, routing info, and area statistics via OpenStreetMap.
32
+
33
+ **100% free, no API key required.** Uses OpenStreetMap Nominatim + Overpass API.
34
+
35
+ ## Tools
36
+
37
+ | Tool | Description |
38
+ |------|-------------|
39
+ | `geocode` | Convert address/place name to coordinates |
40
+ | `reverse_geocode` | Convert coordinates to address |
41
+ | `search_nearby` | Find POIs nearby (restaurants, hospitals, schools, etc.) |
42
+ | `get_route_info` | Distance and bearing between two points |
43
+ | `get_area_stats` | Population, area, type info for a place |
44
+ | `find_boundaries` | Administrative boundaries of a place |
45
+ | `search_pois` | Search points of interest by keyword in an area |
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install geospatial-mcp-server
51
+ ```
52
+
53
+ ## Usage with Claude Desktop
54
+
55
+ Add to your `claude_desktop_config.json`:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "geospatial": {
61
+ "command": "geospatial-server"
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ Or with uvx (no install needed):
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "geospatial": {
73
+ "command": "uvx",
74
+ "args": ["geospatial-mcp-server"]
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## Examples
81
+
82
+ - "Where is the Eiffel Tower?" → `geocode("Eiffel Tower, Paris")`
83
+ - "What's at 48.8566, 2.3522?" → `reverse_geocode(48.8566, 2.3522)`
84
+ - "Find restaurants near me" → `search_nearby(48.8566, 2.3522, "restaurant")`
85
+ - "How far is Berlin from Munich?" → `get_route_info("Berlin", "Munich")`
86
+ - "Tell me about Tokyo" → `get_area_stats("Tokyo")`
87
+
88
+ ## Data Sources
89
+
90
+ - [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) — Geocoding & reverse geocoding
91
+ - [Overpass API](https://overpass-api.de/) — POI search & spatial queries
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,69 @@
1
+ # geospatial-mcp-server
2
+
3
+ <!-- mcp-name: geospatial-mcp-server -->
4
+
5
+ MCP Server for geospatial data — gives AI agents access to geocoding, reverse geocoding, POI search, routing info, and area statistics via OpenStreetMap.
6
+
7
+ **100% free, no API key required.** Uses OpenStreetMap Nominatim + Overpass API.
8
+
9
+ ## Tools
10
+
11
+ | Tool | Description |
12
+ |------|-------------|
13
+ | `geocode` | Convert address/place name to coordinates |
14
+ | `reverse_geocode` | Convert coordinates to address |
15
+ | `search_nearby` | Find POIs nearby (restaurants, hospitals, schools, etc.) |
16
+ | `get_route_info` | Distance and bearing between two points |
17
+ | `get_area_stats` | Population, area, type info for a place |
18
+ | `find_boundaries` | Administrative boundaries of a place |
19
+ | `search_pois` | Search points of interest by keyword in an area |
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install geospatial-mcp-server
25
+ ```
26
+
27
+ ## Usage with Claude Desktop
28
+
29
+ Add to your `claude_desktop_config.json`:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "geospatial": {
35
+ "command": "geospatial-server"
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Or with uvx (no install needed):
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "geospatial": {
47
+ "command": "uvx",
48
+ "args": ["geospatial-mcp-server"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## Examples
55
+
56
+ - "Where is the Eiffel Tower?" → `geocode("Eiffel Tower, Paris")`
57
+ - "What's at 48.8566, 2.3522?" → `reverse_geocode(48.8566, 2.3522)`
58
+ - "Find restaurants near me" → `search_nearby(48.8566, 2.3522, "restaurant")`
59
+ - "How far is Berlin from Munich?" → `get_route_info("Berlin", "Munich")`
60
+ - "Tell me about Tokyo" → `get_area_stats("Tokyo")`
61
+
62
+ ## Data Sources
63
+
64
+ - [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) — Geocoding & reverse geocoding
65
+ - [Overpass API](https://overpass-api.de/) — POI search & spatial queries
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "geospatial-mcp-server"
7
+ version = "0.1.0"
8
+ description = "Geospatial MCP Server — Geocoding, POI-Suche, Routing und Gebietsstatistiken via OpenStreetMap fuer AI Agents"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "AiAgentKarl"}
14
+ ]
15
+ keywords = ["mcp", "geospatial", "openstreetmap", "geocoding", "osm", "nominatim", "overpass", "ai-agents"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering :: GIS",
26
+ ]
27
+ dependencies = [
28
+ "mcp>=1.0.0",
29
+ "httpx>=0.27.0",
30
+ "pydantic>=2.0.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/AiAgentKarl/geospatial-mcp-server"
35
+ Repository = "https://github.com/AiAgentKarl/geospatial-mcp-server"
36
+ Issues = "https://github.com/AiAgentKarl/geospatial-mcp-server/issues"
37
+
38
+ [project.scripts]
39
+ geospatial-server = "src.server:main"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src"]
@@ -0,0 +1 @@
1
+ # Geospatial MCP Server — Geodaten fuer AI Agents
@@ -0,0 +1 @@
1
+ # OSM API Clients
@@ -0,0 +1,182 @@
1
+ """
2
+ OpenStreetMap API Client — Nominatim + Overpass
3
+ Nutzt httpx fuer async HTTP-Anfragen.
4
+ """
5
+
6
+ import httpx
7
+ from typing import Any
8
+
9
+
10
+ # Nominatim verlangt einen User-Agent Header
11
+ USER_AGENT = "GeospatialMCPServer/0.1.0"
12
+ NOMINATIM_BASE = "https://nominatim.openstreetmap.org"
13
+ OVERPASS_URL = "https://overpass-api.de/api/interpreter"
14
+
15
+ # Timeout fuer alle Anfragen (Overpass kann langsam sein)
16
+ TIMEOUT = httpx.Timeout(30.0, connect=10.0)
17
+
18
+
19
+ def _get_headers() -> dict[str, str]:
20
+ """Standard-Headers fuer Nominatim (User-Agent ist Pflicht)."""
21
+ return {
22
+ "User-Agent": USER_AGENT,
23
+ "Accept": "application/json",
24
+ }
25
+
26
+
27
+ async def nominatim_search(query: str, limit: int = 5) -> list[dict[str, Any]]:
28
+ """
29
+ Geocoding: Adresse/Ortsname -> Koordinaten.
30
+ Nutzt Nominatim /search Endpoint.
31
+ """
32
+ params = {
33
+ "q": query,
34
+ "format": "json",
35
+ "limit": limit,
36
+ "addressdetails": 1,
37
+ "extratags": 1,
38
+ }
39
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
40
+ response = await client.get(
41
+ f"{NOMINATIM_BASE}/search",
42
+ params=params,
43
+ headers=_get_headers(),
44
+ )
45
+ response.raise_for_status()
46
+ return response.json()
47
+
48
+
49
+ async def nominatim_reverse(lat: float, lon: float) -> dict[str, Any]:
50
+ """
51
+ Reverse Geocoding: Koordinaten -> Adresse.
52
+ Nutzt Nominatim /reverse Endpoint.
53
+ """
54
+ params = {
55
+ "lat": lat,
56
+ "lon": lon,
57
+ "format": "json",
58
+ "addressdetails": 1,
59
+ "extratags": 1,
60
+ }
61
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
62
+ response = await client.get(
63
+ f"{NOMINATIM_BASE}/reverse",
64
+ params=params,
65
+ headers=_get_headers(),
66
+ )
67
+ response.raise_for_status()
68
+ return response.json()
69
+
70
+
71
+ async def nominatim_lookup(query: str) -> dict[str, Any] | None:
72
+ """
73
+ Einzelnen Ort suchen und Details zurueckgeben.
74
+ Gibt das erste Ergebnis zurueck oder None.
75
+ """
76
+ results = await nominatim_search(query, limit=1)
77
+ if results:
78
+ return results[0]
79
+ return None
80
+
81
+
82
+ async def overpass_query(query: str) -> dict[str, Any]:
83
+ """
84
+ Overpass API Abfrage ausfuehren.
85
+ Erwartet eine vollstaendige Overpass QL Query.
86
+ """
87
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
88
+ response = await client.post(
89
+ OVERPASS_URL,
90
+ data={"data": query},
91
+ headers={"User-Agent": USER_AGENT},
92
+ )
93
+ response.raise_for_status()
94
+ return response.json()
95
+
96
+
97
+ async def overpass_nearby(
98
+ lat: float,
99
+ lon: float,
100
+ category: str,
101
+ radius_m: int = 1000,
102
+ ) -> list[dict[str, Any]]:
103
+ """
104
+ POIs in der Naehe suchen via Overpass.
105
+ Unterstuetzte Kategorien werden auf OSM-Tags gemappt.
106
+ """
107
+ # Kategorie auf OSM-Tags mappen
108
+ tag_mapping = {
109
+ "restaurant": '"amenity"="restaurant"',
110
+ "cafe": '"amenity"="cafe"',
111
+ "bar": '"amenity"="bar"',
112
+ "fast_food": '"amenity"="fast_food"',
113
+ "hospital": '"amenity"="hospital"',
114
+ "pharmacy": '"amenity"="pharmacy"',
115
+ "doctor": '"amenity"="doctors"',
116
+ "school": '"amenity"="school"',
117
+ "university": '"amenity"="university"',
118
+ "kindergarten": '"amenity"="kindergarten"',
119
+ "bank": '"amenity"="bank"',
120
+ "atm": '"amenity"="atm"',
121
+ "fuel": '"amenity"="fuel"',
122
+ "parking": '"amenity"="parking"',
123
+ "supermarket": '"shop"="supermarket"',
124
+ "bakery": '"shop"="bakery"',
125
+ "hotel": '"tourism"="hotel"',
126
+ "museum": '"tourism"="museum"',
127
+ "park": '"leisure"="park"',
128
+ "playground": '"leisure"="playground"',
129
+ "police": '"amenity"="police"',
130
+ "fire_station": '"amenity"="fire_station"',
131
+ "post_office": '"amenity"="post_office"',
132
+ "library": '"amenity"="library"',
133
+ "cinema": '"amenity"="cinema"',
134
+ "theatre": '"amenity"="theatre"',
135
+ "gym": '"leisure"="fitness_centre"',
136
+ "swimming_pool": '"leisure"="swimming_pool"',
137
+ "bus_stop": '"highway"="bus_stop"',
138
+ "train_station": '"railway"="station"',
139
+ "subway": '"railway"="subway_entrance"',
140
+ "charging_station": '"amenity"="charging_station"',
141
+ }
142
+
143
+ # Wenn Kategorie bekannt, nutze spezifischen Tag
144
+ osm_filter = tag_mapping.get(category.lower())
145
+ if not osm_filter:
146
+ # Fallback: Suche nach Name oder beliebigem Amenity-Tag
147
+ osm_filter = f'"amenity"="{category}"'
148
+
149
+ query = f"""
150
+ [out:json][timeout:25];
151
+ (
152
+ node[{osm_filter}](around:{radius_m},{lat},{lon});
153
+ way[{osm_filter}](around:{radius_m},{lat},{lon});
154
+ );
155
+ out center body;
156
+ """
157
+
158
+ result = await overpass_query(query)
159
+ return result.get("elements", [])
160
+
161
+
162
+ async def overpass_search_pois(
163
+ keyword: str,
164
+ lat: float,
165
+ lon: float,
166
+ radius_m: int = 5000,
167
+ ) -> list[dict[str, Any]]:
168
+ """
169
+ POIs nach Freitext-Keyword suchen via Overpass.
170
+ Sucht in name-Tags innerhalb des Radius.
171
+ """
172
+ query = f"""
173
+ [out:json][timeout:25];
174
+ (
175
+ node["name"~"{keyword}",i](around:{radius_m},{lat},{lon});
176
+ way["name"~"{keyword}",i](around:{radius_m},{lat},{lon});
177
+ );
178
+ out center body;
179
+ """
180
+
181
+ result = await overpass_query(query)
182
+ return result.get("elements", [])
@@ -0,0 +1,149 @@
1
+ """
2
+ Geospatial MCP Server — Geodaten fuer AI Agents.
3
+ Geocoding, POI-Suche, Routing und Gebietsstatistiken via OpenStreetMap.
4
+ """
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from src.tools.geo import (
9
+ geocode,
10
+ reverse_geocode,
11
+ search_nearby,
12
+ get_route_info,
13
+ get_area_stats,
14
+ find_boundaries,
15
+ search_pois,
16
+ )
17
+
18
+ # FastMCP Server erstellen
19
+ mcp = FastMCP(
20
+ "geospatial-mcp-server",
21
+ instructions=(
22
+ "Geospatial data server for AI agents. "
23
+ "Provides geocoding, reverse geocoding, nearby POI search, "
24
+ "distance calculation, area statistics, boundary lookup, "
25
+ "and keyword-based POI search. "
26
+ "All data comes from OpenStreetMap (Nominatim + Overpass API). "
27
+ "No API key required."
28
+ ),
29
+ )
30
+
31
+
32
+ # --- Tools registrieren ---
33
+
34
+ @mcp.tool()
35
+ async def geocode_tool(query: str) -> dict:
36
+ """Convert an address or place name to geographic coordinates.
37
+
38
+ Args:
39
+ query: Address or place name (e.g. "Eiffel Tower, Paris", "1600 Pennsylvania Ave, Washington DC")
40
+
41
+ Returns:
42
+ Coordinates, address details and result type
43
+ """
44
+ return await geocode(query)
45
+
46
+
47
+ @mcp.tool()
48
+ async def reverse_geocode_tool(lat: float, lon: float) -> dict:
49
+ """Convert geographic coordinates to an address.
50
+
51
+ Args:
52
+ lat: Latitude (e.g. 48.8566)
53
+ lon: Longitude (e.g. 2.3522)
54
+
55
+ Returns:
56
+ Address and location details
57
+ """
58
+ return await reverse_geocode(lat, lon)
59
+
60
+
61
+ @mcp.tool()
62
+ async def search_nearby_tool(
63
+ lat: float,
64
+ lon: float,
65
+ category: str,
66
+ radius_m: int = 1000,
67
+ ) -> dict:
68
+ """Find points of interest nearby (restaurants, hospitals, schools, etc.).
69
+
70
+ Args:
71
+ lat: Center latitude
72
+ lon: Center longitude
73
+ category: POI category — one of: restaurant, cafe, bar, fast_food, hospital, pharmacy, doctor, school, university, kindergarten, bank, atm, fuel, parking, supermarket, bakery, hotel, museum, park, playground, police, fire_station, post_office, library, cinema, theatre, gym, swimming_pool, bus_stop, train_station, subway, charging_station
74
+ radius_m: Search radius in meters (default: 1000, max: 10000)
75
+
76
+ Returns:
77
+ List of nearby POIs with name, coordinates, distance
78
+ """
79
+ return await search_nearby(lat, lon, category, radius_m)
80
+
81
+
82
+ @mcp.tool()
83
+ async def get_route_info_tool(origin: str, destination: str) -> dict:
84
+ """Calculate distance and direction between two places (straight line).
85
+
86
+ Args:
87
+ origin: Starting place (e.g. "Berlin")
88
+ destination: Target place (e.g. "Munich")
89
+
90
+ Returns:
91
+ Distance in km/miles, bearing, direction, coordinates of both points
92
+ """
93
+ return await get_route_info(origin, destination)
94
+
95
+
96
+ @mcp.tool()
97
+ async def get_area_stats_tool(place_name: str) -> dict:
98
+ """Get statistics about a place (population, area, type).
99
+
100
+ Args:
101
+ place_name: Name of the place (e.g. "Berlin", "Tokyo", "New York")
102
+
103
+ Returns:
104
+ Population, area, administrative type, Wikipedia link and more
105
+ """
106
+ return await get_area_stats(place_name)
107
+
108
+
109
+ @mcp.tool()
110
+ async def find_boundaries_tool(place_name: str) -> dict:
111
+ """Find administrative boundaries of a place.
112
+
113
+ Args:
114
+ place_name: Name of the place (e.g. "Bavaria", "California", "Paris")
115
+
116
+ Returns:
117
+ Boundary info with bounding box and administrative hierarchy
118
+ """
119
+ return await find_boundaries(place_name)
120
+
121
+
122
+ @mcp.tool()
123
+ async def search_pois_tool(
124
+ query: str,
125
+ lat: float,
126
+ lon: float,
127
+ radius_m: int = 5000,
128
+ ) -> dict:
129
+ """Search points of interest by keyword in an area.
130
+
131
+ Args:
132
+ query: Search keyword (e.g. "Starbucks", "Museum", "Train Station")
133
+ lat: Center latitude
134
+ lon: Center longitude
135
+ radius_m: Search radius in meters (default: 5000, max: 25000)
136
+
137
+ Returns:
138
+ List of matching POIs with name, coordinates, distance
139
+ """
140
+ return await search_pois(query, lat, lon, radius_m)
141
+
142
+
143
+ def main():
144
+ """Server starten."""
145
+ mcp.run(transport="stdio")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -0,0 +1 @@
1
+ # Geospatial Tool-Definitionen
@@ -0,0 +1,430 @@
1
+ """
2
+ Geospatial MCP Tools — 7 Tools fuer Geodaten.
3
+ Nutzt OpenStreetMap Nominatim + Overpass API.
4
+ """
5
+
6
+ import math
7
+ from typing import Any
8
+
9
+ from src.clients.osm import (
10
+ nominatim_search,
11
+ nominatim_reverse,
12
+ nominatim_lookup,
13
+ overpass_nearby,
14
+ overpass_search_pois,
15
+ )
16
+
17
+
18
+ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
19
+ """
20
+ Haversine-Formel: Berechnet Entfernung zwischen zwei Koordinaten in km.
21
+ """
22
+ R = 6371.0 # Erdradius in km
23
+
24
+ lat1_r = math.radians(lat1)
25
+ lat2_r = math.radians(lat2)
26
+ dlat = math.radians(lat2 - lat1)
27
+ dlon = math.radians(lon2 - lon1)
28
+
29
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1_r) * math.cos(lat2_r) * math.sin(dlon / 2) ** 2
30
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
31
+
32
+ return R * c
33
+
34
+
35
+ def _bearing(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
36
+ """Berechnet die Peilung (Bearing) in Grad von Punkt 1 zu Punkt 2."""
37
+ lat1_r = math.radians(lat1)
38
+ lat2_r = math.radians(lat2)
39
+ dlon = math.radians(lon2 - lon1)
40
+
41
+ x = math.sin(dlon) * math.cos(lat2_r)
42
+ y = math.cos(lat1_r) * math.sin(lat2_r) - math.sin(lat1_r) * math.cos(lat2_r) * math.cos(dlon)
43
+
44
+ bearing_rad = math.atan2(x, y)
45
+ return (math.degrees(bearing_rad) + 360) % 360
46
+
47
+
48
+ def _bearing_to_direction(bearing: float) -> str:
49
+ """Wandelt Bearing in Himmelsrichtung um."""
50
+ directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
51
+ index = round(bearing / 45) % 8
52
+ return directions[index]
53
+
54
+
55
+ def _format_element(element: dict[str, Any]) -> dict[str, Any]:
56
+ """Formatiert ein Overpass-Element fuer die Ausgabe."""
57
+ tags = element.get("tags", {})
58
+ # Koordinaten: Bei Way-Elementen center nutzen
59
+ lat = element.get("lat") or element.get("center", {}).get("lat")
60
+ lon = element.get("lon") or element.get("center", {}).get("lon")
61
+
62
+ result = {
63
+ "name": tags.get("name", "Unbekannt"),
64
+ "type": element.get("type", "unknown"),
65
+ "lat": lat,
66
+ "lon": lon,
67
+ }
68
+
69
+ # Nuetzliche Tags hinzufuegen wenn vorhanden
70
+ useful_tags = [
71
+ "addr:street", "addr:housenumber", "addr:city", "addr:postcode",
72
+ "phone", "website", "opening_hours", "cuisine", "brand",
73
+ "wheelchair", "internet_access",
74
+ ]
75
+ for tag in useful_tags:
76
+ if tag in tags:
77
+ result[tag.replace(":", "_")] = tags[tag]
78
+
79
+ return result
80
+
81
+
82
+ async def geocode(query: str) -> dict[str, Any]:
83
+ """
84
+ Adresse oder Ortsname in Koordinaten umwandeln.
85
+
86
+ Args:
87
+ query: Adresse oder Ortsname (z.B. "Brandenburger Tor, Berlin")
88
+
89
+ Returns:
90
+ Koordinaten, Adressdetails und Typ des Ergebnisses
91
+ """
92
+ results = await nominatim_search(query, limit=5)
93
+
94
+ if not results:
95
+ return {"error": f"Keine Ergebnisse fuer '{query}' gefunden."}
96
+
97
+ formatted = []
98
+ for r in results:
99
+ entry = {
100
+ "name": r.get("display_name", ""),
101
+ "lat": float(r.get("lat", 0)),
102
+ "lon": float(r.get("lon", 0)),
103
+ "type": r.get("type", "unknown"),
104
+ "class": r.get("class", "unknown"),
105
+ "importance": round(float(r.get("importance", 0)), 4),
106
+ }
107
+ # Adressdetails hinzufuegen
108
+ address = r.get("address", {})
109
+ if address:
110
+ entry["address"] = {
111
+ k: v for k, v in address.items()
112
+ if k not in ("country_code",)
113
+ }
114
+ formatted.append(entry)
115
+
116
+ return {
117
+ "query": query,
118
+ "results_count": len(formatted),
119
+ "results": formatted,
120
+ }
121
+
122
+
123
+ async def reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
124
+ """
125
+ Koordinaten in Adresse umwandeln.
126
+
127
+ Args:
128
+ lat: Breitengrad (z.B. 48.8566)
129
+ lon: Laengengrad (z.B. 2.3522)
130
+
131
+ Returns:
132
+ Adresse und Details zum Standort
133
+ """
134
+ try:
135
+ result = await nominatim_reverse(lat, lon)
136
+ except Exception as e:
137
+ return {"error": f"Reverse Geocoding fehlgeschlagen: {str(e)}"}
138
+
139
+ if "error" in result:
140
+ return {"error": result["error"]}
141
+
142
+ address = result.get("address", {})
143
+ return {
144
+ "lat": lat,
145
+ "lon": lon,
146
+ "display_name": result.get("display_name", ""),
147
+ "type": result.get("type", "unknown"),
148
+ "class": result.get("class", "unknown"),
149
+ "address": {
150
+ k: v for k, v in address.items()
151
+ if k not in ("country_code",)
152
+ },
153
+ }
154
+
155
+
156
+ async def search_nearby(
157
+ lat: float,
158
+ lon: float,
159
+ category: str,
160
+ radius_m: int = 1000,
161
+ ) -> dict[str, Any]:
162
+ """
163
+ POIs in der Naehe suchen (Restaurants, Krankenhaeuser, Schulen, etc.).
164
+
165
+ Args:
166
+ lat: Breitengrad des Mittelpunkts
167
+ lon: Laengengrad des Mittelpunkts
168
+ category: Kategorie (restaurant, hospital, school, pharmacy, supermarket, hotel, park, bank, fuel, etc.)
169
+ radius_m: Suchradius in Metern (Standard: 1000, Max: 10000)
170
+
171
+ Returns:
172
+ Liste der gefundenen POIs mit Name, Koordinaten und Details
173
+ """
174
+ # Radius begrenzen
175
+ radius_m = min(max(radius_m, 100), 10000)
176
+
177
+ try:
178
+ elements = await overpass_nearby(lat, lon, category, radius_m)
179
+ except Exception as e:
180
+ return {"error": f"Overpass-Abfrage fehlgeschlagen: {str(e)}"}
181
+
182
+ # Ergebnisse formatieren und Entfernung berechnen
183
+ pois = []
184
+ for el in elements:
185
+ formatted = _format_element(el)
186
+ if formatted["lat"] and formatted["lon"]:
187
+ formatted["distance_m"] = round(
188
+ _haversine(lat, lon, formatted["lat"], formatted["lon"]) * 1000
189
+ )
190
+ pois.append(formatted)
191
+
192
+ # Nach Entfernung sortieren
193
+ pois.sort(key=lambda x: x.get("distance_m", 99999))
194
+
195
+ return {
196
+ "center": {"lat": lat, "lon": lon},
197
+ "category": category,
198
+ "radius_m": radius_m,
199
+ "results_count": len(pois),
200
+ "results": pois[:50], # Max 50 Ergebnisse
201
+ }
202
+
203
+
204
+ async def get_route_info(origin: str, destination: str) -> dict[str, Any]:
205
+ """
206
+ Entfernung und Richtung zwischen zwei Orten berechnen.
207
+
208
+ Args:
209
+ origin: Startort (z.B. "Berlin")
210
+ destination: Zielort (z.B. "Muenchen")
211
+
212
+ Returns:
213
+ Luftlinie-Entfernung in km, Richtung und Koordinaten beider Punkte
214
+ """
215
+ # Beide Orte geocoden
216
+ origin_result = await nominatim_lookup(origin)
217
+ if not origin_result:
218
+ return {"error": f"Startort '{origin}' nicht gefunden."}
219
+
220
+ dest_result = await nominatim_lookup(destination)
221
+ if not dest_result:
222
+ return {"error": f"Zielort '{destination}' nicht gefunden."}
223
+
224
+ lat1 = float(origin_result["lat"])
225
+ lon1 = float(origin_result["lon"])
226
+ lat2 = float(dest_result["lat"])
227
+ lon2 = float(dest_result["lon"])
228
+
229
+ distance_km = _haversine(lat1, lon1, lat2, lon2)
230
+ bearing_deg = _bearing(lat1, lon1, lat2, lon2)
231
+ direction = _bearing_to_direction(bearing_deg)
232
+
233
+ return {
234
+ "origin": {
235
+ "name": origin_result.get("display_name", origin),
236
+ "lat": lat1,
237
+ "lon": lon1,
238
+ },
239
+ "destination": {
240
+ "name": dest_result.get("display_name", destination),
241
+ "lat": lat2,
242
+ "lon": lon2,
243
+ },
244
+ "distance_km": round(distance_km, 2),
245
+ "distance_miles": round(distance_km * 0.621371, 2),
246
+ "bearing_degrees": round(bearing_deg, 1),
247
+ "direction": direction,
248
+ "note": "Luftlinie (Haversine). Tatsaechliche Strecke kann laenger sein.",
249
+ }
250
+
251
+
252
+ async def get_area_stats(place_name: str) -> dict[str, Any]:
253
+ """
254
+ Statistiken ueber einen Ort abrufen (Bevoelkerung, Flaeche, Typ).
255
+
256
+ Args:
257
+ place_name: Name des Ortes (z.B. "Berlin", "Tokyo", "New York")
258
+
259
+ Returns:
260
+ Bevoelkerung, Flaeche, Verwaltungstyp und weitere Infos
261
+ """
262
+ result = await nominatim_lookup(place_name)
263
+ if not result:
264
+ return {"error": f"Ort '{place_name}' nicht gefunden."}
265
+
266
+ address = result.get("address", {})
267
+ extratags = result.get("extratags", {})
268
+
269
+ stats: dict[str, Any] = {
270
+ "name": result.get("display_name", place_name),
271
+ "lat": float(result.get("lat", 0)),
272
+ "lon": float(result.get("lon", 0)),
273
+ "type": result.get("type", "unknown"),
274
+ "class": result.get("class", "unknown"),
275
+ "importance": round(float(result.get("importance", 0)), 4),
276
+ }
277
+
278
+ # Bounding Box wenn vorhanden
279
+ bbox = result.get("boundingbox")
280
+ if bbox and len(bbox) == 4:
281
+ stats["bounding_box"] = {
282
+ "south": float(bbox[0]),
283
+ "north": float(bbox[1]),
284
+ "west": float(bbox[2]),
285
+ "east": float(bbox[3]),
286
+ }
287
+ # Ungefaehre Flaeche aus Bounding Box berechnen
288
+ height_km = _haversine(float(bbox[0]), float(bbox[2]), float(bbox[1]), float(bbox[2]))
289
+ width_km = _haversine(float(bbox[0]), float(bbox[2]), float(bbox[0]), float(bbox[3]))
290
+ stats["approx_area_km2"] = round(height_km * width_km, 2)
291
+
292
+ # Extratags auswerten (Population, Wikipedia, etc.)
293
+ if extratags:
294
+ if "population" in extratags:
295
+ try:
296
+ stats["population"] = int(extratags["population"])
297
+ except (ValueError, TypeError):
298
+ stats["population"] = extratags["population"]
299
+ if "wikipedia" in extratags:
300
+ stats["wikipedia"] = extratags["wikipedia"]
301
+ if "wikidata" in extratags:
302
+ stats["wikidata"] = extratags["wikidata"]
303
+ if "website" in extratags:
304
+ stats["website"] = extratags["website"]
305
+ if "capital" in extratags:
306
+ stats["capital"] = extratags["capital"]
307
+ if "timezone" in extratags:
308
+ stats["timezone"] = extratags["timezone"]
309
+
310
+ # Adress-Hierarchie
311
+ if address:
312
+ stats["address"] = {
313
+ k: v for k, v in address.items()
314
+ if k not in ("country_code",)
315
+ }
316
+
317
+ return stats
318
+
319
+
320
+ async def find_boundaries(place_name: str) -> dict[str, Any]:
321
+ """
322
+ Administrative Grenzen eines Ortes finden.
323
+
324
+ Args:
325
+ place_name: Name des Ortes (z.B. "Bayern", "California", "Paris")
326
+
327
+ Returns:
328
+ Verwaltungsgrenzen mit Bounding Box und Hierarchie
329
+ """
330
+ # Nominatim-Suche mit Polygon-Bounding-Box
331
+ results = await nominatim_search(place_name, limit=3)
332
+
333
+ if not results:
334
+ return {"error": f"Keine Grenzen fuer '{place_name}' gefunden."}
335
+
336
+ # Beste Ergebnisse filtern (bevorzugt administrative Grenzen)
337
+ boundaries = []
338
+ for r in results:
339
+ entry: dict[str, Any] = {
340
+ "name": r.get("display_name", ""),
341
+ "type": r.get("type", "unknown"),
342
+ "class": r.get("class", "unknown"),
343
+ "osm_type": r.get("osm_type", ""),
344
+ "osm_id": r.get("osm_id", ""),
345
+ "lat": float(r.get("lat", 0)),
346
+ "lon": float(r.get("lon", 0)),
347
+ }
348
+
349
+ # Bounding Box
350
+ bbox = r.get("boundingbox")
351
+ if bbox and len(bbox) == 4:
352
+ entry["bounding_box"] = {
353
+ "south": float(bbox[0]),
354
+ "north": float(bbox[1]),
355
+ "west": float(bbox[2]),
356
+ "east": float(bbox[3]),
357
+ }
358
+
359
+ # Adress-Hierarchie zeigt administrative Ebenen
360
+ address = r.get("address", {})
361
+ if address:
362
+ entry["admin_hierarchy"] = {
363
+ k: v for k, v in address.items()
364
+ if k not in ("country_code",)
365
+ }
366
+
367
+ # Extratags fuer weitere Details
368
+ extratags = r.get("extratags", {})
369
+ if extratags:
370
+ admin_level = extratags.get("admin_level") or extratags.get("linked_place")
371
+ if admin_level:
372
+ entry["admin_level"] = admin_level
373
+ if "border_type" in extratags:
374
+ entry["border_type"] = extratags["border_type"]
375
+
376
+ boundaries.append(entry)
377
+
378
+ return {
379
+ "query": place_name,
380
+ "results_count": len(boundaries),
381
+ "boundaries": boundaries,
382
+ }
383
+
384
+
385
+ async def search_pois(
386
+ query: str,
387
+ lat: float,
388
+ lon: float,
389
+ radius_m: int = 5000,
390
+ ) -> dict[str, Any]:
391
+ """
392
+ Points of Interest nach Freitext-Keyword in einem Gebiet suchen.
393
+
394
+ Args:
395
+ query: Suchbegriff (z.B. "Starbucks", "Museum", "Bahnhof")
396
+ lat: Breitengrad des Mittelpunkts
397
+ lon: Laengengrad des Mittelpunkts
398
+ radius_m: Suchradius in Metern (Standard: 5000, Max: 25000)
399
+
400
+ Returns:
401
+ Liste der gefundenen POIs mit Name, Koordinaten und Entfernung
402
+ """
403
+ # Radius begrenzen
404
+ radius_m = min(max(radius_m, 100), 25000)
405
+
406
+ try:
407
+ elements = await overpass_search_pois(query, lat, lon, radius_m)
408
+ except Exception as e:
409
+ return {"error": f"POI-Suche fehlgeschlagen: {str(e)}"}
410
+
411
+ # Ergebnisse formatieren
412
+ pois = []
413
+ for el in elements:
414
+ formatted = _format_element(el)
415
+ if formatted["lat"] and formatted["lon"]:
416
+ formatted["distance_m"] = round(
417
+ _haversine(lat, lon, formatted["lat"], formatted["lon"]) * 1000
418
+ )
419
+ pois.append(formatted)
420
+
421
+ # Nach Entfernung sortieren
422
+ pois.sort(key=lambda x: x.get("distance_m", 99999))
423
+
424
+ return {
425
+ "query": query,
426
+ "center": {"lat": lat, "lon": lon},
427
+ "radius_m": radius_m,
428
+ "results_count": len(pois),
429
+ "results": pois[:50], # Max 50 Ergebnisse
430
+ }