dc-l3-vienna-overpass-historical

Status: failed
Score: 0
Duration: 14:02
Cost: 7.22¢
Model: deepseek/deepseek-v4-flash

Map (reference ← swipe → agent)

0:00
Fetch Vienna's current district polygons (Bezirke) and their 2014-01-01 counterparts from Overpass and identify which parts of the district boundaries have changed between then and now. District names changed casing and spelling between snapshots, so normalise them to match. Classify each resulting geometry fragment by how it changed: `added_since_2014` (in current but not 2014), `removed_since_2014` (in 2014 but not current), or `unchanged` (present in both snapshots). For each district flag whether its geometry borders any area that was added or removed. Output vienna_boundary_changes.geojson, MultiPolygon, each feature with change_type, district_name (normalised), and touches_changed (boolean).
0:00
System info
{
  "adapter": "openrouter",
  "model": "deepseek/deepseek-v4-flash",
  "base_url": "https://openrouter.ai/api/v1"
}
0:07
Let me break down this task:

1. Fetch Vienna's current district polygons (Bezirke) from Overpass
2. Fetch Vienna's district polygons from 2014-01-01
3. Normalise district names (fix casing/spelling differences)
4. Compare geometries to find changes
5. Classify changes as: added_since_2014, removed_since_2014, or unchanged
6. For each district, check if it touches any changed area
7. Output as GeoJSON with MultiPolygon geometries, with change_type, district_name, touches_changed fields

Let me start by writing a Python script to fetch the data from Overpass API.
0:07
Bash ls /work/
(no output)
0:50
Let me plan this out:

1. Fetch Vienna's current district polygons (Bezirke) from Overpass API
2. Fetch Vienna's historical district polygons from 2014-01-01 (Overpass supports retrieving historical data via the "date" parameter)
3. Normalise district names (handle casing/spelling differences)
4. Compare geometries:
   - `added_since_2014`: geometry present in current but not in 2014
   - `removed_since_2014`: geometry present in 2014 but not in current
   - `unchanged`: geometry present in both
5. For each district, flag if its geometry borders any added/removed area
6. Output as GeoJSON with MultiPolygon geometry

Let me start by querying Overpass. I'll use the Overpass API with the `[out:json]` format.

For current districts of Vienna:
```
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body;
>;
out skel qt;
```

For 2014-01-01 data, Overpass supports `[date:"2014-01-01T00:00:00Z"]` as a global option.

Actually, Overpass's date handling is tricky. Let me think about how to retrieve historical data.

The Overpass API supports `[date:"YYYY-MM-DDTHH:MM:SSZ"]` to query the database as it was at that date. But this only works if the Overpass server has the historical data.

Let me try a different approach: I'll query both current and 2014 boundaries. For the historical data I can use the `[date:"2014-01-01T00:00:00Z"]` parameter.

Actually, Overpass turbo/API can handle this. Let me write a Python script that:

1. Queries Overpass for current Vienna district boundaries
2. Queries Overpass for 2014-01-01 Vienna district boundaries
3. Processes the geometries

Let me start by writing and running a Python script.

Let me think about the Overpass queries more carefully.

For getting Vienna districts (Bezirke), I need to get relations with admin_level=9 in Vienna.

Current data query:
```
[out:json];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body geom;
```

Wait, I should use `out geom;` to get geometry for each relation.

For historical data, I can try:
```
[out:json][date:"2014-01-01T00:00:00Z"];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body geom;
```

But the historical Overpass retrieval might not work perfectly. Let me try it.

Actually, let me also consider that some districts might have changed name formatting. The districts of Vienna are:
1. Innere Stadt
2. Leopoldstadt
3. Landstraße
4. Wieden
5. Margareten
6. Mariahilf
7. Neubau
8. Josefstadt
9. Alsergrund
10. Favoriten
11. Simmering
12. Meidling
13. Hietzing
14. Penzing
15. Rudolfsheim-Fünfhaus
16. Ottakring
17. Hernals
18. Währing
19. Döbling
20. Brigittenau
21. Floridsdorf
22. Donaustadt
23. Liesing

Some might be named differently (e.g., "Wien 1. Bezirk" vs "Innere Stadt", or lowercase/uppercase differences).

Let me write a comprehensive Python script.

Actually, I realize I should also consider that the Overpass API might return a lot of data. Let me start with a simple script to fetch the data.

Let me think about the approach more carefully:

1. Fetch current districts from Overpass
2. Fetch 2014 districts from Overpass
3. Normalize names (lowercase, remove common prefixes/suffixes)
4. For each district, union all their geometries (a district might have multiple way members)
5. Compare: compute symmetric difference between current and 2014 geometries for each district
6. The symmetric difference can be split into: parts only in current (added) and parts only in 2014 (removed)
7. For the "unchanged" parts, compute the intersection
8. Determine which districts touch the changed areas

Let me write this step by step.

For the Overpass query, I should use `out geom;` to get the full geometry in the response. But `out geom` on a relation gives the geometry of each way. I'll need to combine them.

Actually, for administrative boundaries, using `out geom;` on a relation returns the geometry of the ways in the relation. I need to stitch them together into a polygon.

Let me use `[out:json];` and `out geom;` for each way, then join them.

Actually a better approach: use Overpass to get simplified boundaries. Let me use `make` or `convert` to get the boundary as a single polygon.

Hmm, Overpass QL has limited geometry processing. Let me just query the relation members and process them in Python.

Let me write the script:

```python
import requests
import geopandas as gpd
from shapely.geometry import shape, MultiPolygon, Polygon
from shapely.ops import unary_union
import json
import re
from collections import defaultdict

def fetch_overpass(query, date=None):
    """Fetch data from Overpass API"""
    url = "https://overpass-api.de/api/interpreter"
    if date:
        query = f"[date:\"{date}\"]\n{query}"
    response = requests.post(url, data={"data": query})
    response.raise_for_status()
    return response.json()

def normalize_name(name):
    """Normalize district name for comparison"""
    # Remove leading "Wien " or "Wien," or "Wien-" etc.
    name = re.sub(r'^Wien[\s,.-]*', '', name, flags=re.IGNORECASE)
    # Remove trailing Bezirk/Bez. etc.
    name = re.sub(r'\s*Bezirk\s*$', '', name, flags=re.IGNORECASE)
    name = re.sub(r'\s*Bez\.\s*$', '', name, flags=re.IGNORECASE)
    # Remove leading numbers like "1.", "01.", etc.
    name = re.sub(r'^\d+\.?\s*', '', name)
    # Lowercase and strip
    name = name.strip().lower()
    return name

# Since Viennese districts are well-defined, let me build a mapping
district_names = {
    'innere stadt': 'Innere Stadt',
    'leopoldstadt': 'Leopoldstadt',
    'landstraße': 'Landstraße',
    'wieden': 'Wieden',
    'margareten': 'Margareten',
    'mariabilf': 'Mariahilf',  # maybe typo?
    'mariahilf': 'Mariahilf',
    'neubau': 'Neubau',
    'josefstadt': 'Josefstadt',
    'alsergrund': 'Alsergrund',
    'favoriten': 'Favoriten',
    'simmering': 'Simmering',
    'meidling': 'Meidling',
    'hietzing': 'Hietzing',
    'penzing': 'Penzing',
    'rudolfsheim-fünfhaus': 'Rudolfsheim-Fünfhaus',
    'rudolfsheim': 'Rudolfsheim-Fünfhaus',
    'ottakring': 'Ottakring',
    'hernals': 'Hernals',
    'währing': 'Währing',
    'döbling': 'Döbling',
    'brigittenau': 'Brigittenau',
    'floridsdorf': 'Floridsdorf',
    'donaustadt': 'Donaustadt',
    'liesing': 'Liesing',
}

# Overpass query for Vienna districts
query = """
[out:json];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body geom;
"""
```

Wait, let me think about this differently. The Overpass query `out geom;` on a relation gives the geometry of all member ways. But I need to convert ways to polygons. If the ways form a closed ring (which they should for a boundary), I can reconstruct the polygon.

Actually, for a boundary relation, the outer members form the boundary. Let me check...

In Overpass, for administrative boundaries, the relation has members with role "outer" (and sometimes "inner"). The geometry of the ways can be used to construct the polygon.

Let me try a simpler approach: use the Overpass `convert` to get a polygon.

Actually, let me just write the script and see what happens.

Let me first try fetching the data:

```python
import requests
import json

# Current data
query_current = """
[out:json];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
(._;>;);
out geom;
"""
```

Hmm, that might give a lot of data. Let me be more targeted.

Actually, let me just write the full Python script and run it. I'll fetch both datasets, process them, and output the result.

Let me think about what the output should look like:

For each district, I need to compute:
1. The "unchanged" part (intersection of current and 2014 boundaries for that district)
2. The "added_since_2014" part (current - 2014 boundary for that district)  
3. The "removed_since_2014" part (2014 boundary - current for that district)

Wait, but this is about boundary changes, not area changes. So I need to compare the boundary lines, not the polygons.

Actually, let me re-read the task: "identify which parts of the district boundaries have changed" and "classify each resulting geometry fragment by how it changed".

So I think we're looking at:
- The boundary lines that are new (added)
- The boundary lines that were removed
- The boundary lines that are unchanged

And for each district, whether it "borders any area that was added or removed" (touches_changed).

Let me think about this geometrically:
1. Get the boundary lines for each district in both snapshots
2. Compute the symmetric difference of the boundary lines
3. Parts that are only in the current snapshot = added
4. Parts that are only in the 2014 snapshot = removed
5. Parts in both = unchanged

But since we're dealing with polygons (district areas), the boundary is the polygon's exterior ring.

Actually, I think the cleaner approach is:
1. Get polygon for each district for both time periods
2. For each district, compute:
   - current_boundary = polygon.exterior boundary as linestring
   - 2014_boundary = polygon.exterior boundary as linestring
   - The difference (linestring only in current) = added
   - The difference (linestring only in 2014) = removed
   - The intersection = unchanged

But the boundaries are shared between adjacent districts, so we need to look at the overall boundary network.

Actually, let me reconsider. The task says "district boundaries" and "parts of the district boundaries have changed". I think the approach is:

1. Take all current district polygons
2. Take all 2014 district polygons
3. Overlay them (union/intersection/symmetric difference)
4. For each resulting fragment, determine if it's:
   - In current but not 2014 → added
   - In 2014 but not current → removed
   - In both → unchanged
5. For each district, check if it touches any added or removed fragment

Let me go with this approach. I'll use geopandas for overlay operations.

Actually wait - I think the approach is more about the boundary lines themselves. Let me think...

The "district boundaries" are the lines that separate districts. If a boundary changes, it means the line between two districts moved. So:

1. Extract all boundary lines from current and 2014 datasets
2. Compare them

But this is complex. Let me go with the polygon overlay approach, which is cleaner:

For each district:
- Current polygon ∩ 2014 polygon = unchanged area
- Current polygon - 2014 polygon = added area (this district gained this area)
- 2014 polygon - Current polygon = removed area (this district lost this area)

Then the boundary changes are the edges of these areas.

Actually, I think a simpler interpretation: overlay all current districts with all 2014 districts. This gives us fragments. Each fragment is:
- Inside a current district (and maybe not in a 2014 district) → added
- Inside a 2014 district (and maybe not in a current district) → removed
- Inside both → unchanged

For the overlay, I can use `gpd.overlay` with `how='union'` but that's not quite right either.

Let me think about this more carefully with a simpler approach:

1. Compute the union of all current districts → this gives the area of Vienna
2. Compute the union of all 2014 districts → this gives the area of Vienna in 2014
3. Overlay these two to find areas that are:
   - In current but not 2014 (Vienna expanded)
   - In 2014 but not current (Vienna shrunk)
   - In both (unchanged)

But that's about the city boundary, not district boundaries.

OK, let me try a different interpretation. The task is about district boundaries changing. So:

1. Take all district boundaries as linestrings
2. Compare the two sets

For the output: "MultiPolygon, each feature with change_type, district_name (normalised), and touches_changed (boolean)"

So each feature is a multipolygon. The change_type tells if it was added, removed, or unchanged.

Let me go with this approach:
1. For each district (normalized name), get the current polygon and the 2014 polygon
2. Compute:
   - Intersection (unchanged)
   - Current - 2014 (added_since_2014)
   - 2014 - Current (removed_since_2014)
3. For each fragment, record which district it belongs to
4. Determine touches_changed: for each district, does its boundary touch any added or removed fragment?

Actually, I realize the simpler approach might be:
1. Compute the overlay of all current districts with all 2014 districts
2. This gives fragments each with a current_district_id and a 2014_district_id (can be None)
3. Classify each fragment

Let me just code this up.

Let me start by fetching the data.

Actually, let me consider that Overpass might be slow or have issues. Let me first just query to see what data we get back.

Let me write a comprehensive script.

For the Overpass query, I think `out geom;` on a relation returns the geometries of the member ways but not assembled into a polygon. I'll need to stitch them together in Python.

Actually, I just realized that `out geom;` at the relation level gives the geometry of the relation as a whole. Let me check the Overpass documentation...

Actually, in Overpass QL, `rel(...); out geom;` outputs the geometry of each way member. To get a proper polygon, I need to use `convert` or process in Python.

Let me try a different approach: Use `out geom;` on the ways (`>; out geom;`) which gives complete way geometries, then I can reconstruct polygons.

Actually, let me use `make` or the `geom` attribute in Overpass.

You know what, let me just try it and see what we get.

Let me write a script that:
1. Queries Overpass for current Vienna districts
2. Parses the response to build polygons
3. Does the same for 2014
4. Processes

Here's my plan for reconstructing polygons from Overpass relation data:
- A relation has members (ways) with roles (outer, inner)
- Each way has geometry (list of coordinates)
- I combine outer ways to form the exterior ring, inner ways for holes

But actually, Overpass might give us the ways separately. Let me use `out geom;` on the relation directly (instead of `>` then out geom on ways).

Wait, I think there's a simpler approach. Let me use the Overpass `convert` feature to output the geometry as a polygon.

Actually, I just realized there's an even simpler approach. Let me use the `geojson` output format from Overpass. Or I can use `out geom;` on the relation level.

Let me just try querying and see what the data looks like.

Let me write a quick test script.
0:50
0:50
Write /work/fetch_test.py
content · 19 lines · py
import requests, json

# Test query for current districts
query = """
[out:json];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body geom;
"""
r = requests.post("https://overpass-api.de/api/interpreter", data={"data": query})
print(f"Status: {r.status_code}")
data = r.json()
print(f"Elements: {len(data.get('elements', []))}")
for el in data.get('elements', [])[:3]:
    print(json.dumps({k:v for k,v in el.items() if k != 'members'}, indent=2)[:500])
    if 'members' in el:
        print(f"  Members count: {len(el['members'])}")
        for m in el['members'][:3]:
            print(f"    role={m['role']} type={m['type']} ref={m['ref']} geom={'geom' in m}")
Wrote /work/fetch_test.py (698 chars).
0:54
Let me run this test to see what the data looks like.
0:54
Bash cd /work && python fetch_test.py
Status: 406
Traceback (most recent call last):
  File "/usr/local/lib/python3.14/site-packages/requests/models.py", line 1116, in json
    return complexjson.loads(self.text, **kwargs)
           ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.14/json/__init__.py", line 352, in loads
    return _default_decoder.decode(s)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^
  File "/usr/local/lib/python3.14/json/decoder.py", line 345, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.14/json/decoder.py", line 363, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/work/fetch_test.py", line 12, in <module>
    data = r.json()
  File "/usr/local/lib/python3.14/site-packages/requests/models.py", line 1120, in json
    raise RequestsJSONDecodeError(e.msg, e.doc, e.pos)
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
0:58
The Overpass API returned a 406 error. Let me try a different approach - maybe use a different URL.
0:58
Write /work/fetch_test2.py
content · 13 lines · py
import requests, json

# Try with different content type headers
query = """
[out:json];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body geom;
"""
headers = {"Content-Type": "application/x-www-form-urlencoded"}
r = requests.post("https://overpass-api.de/api/interpreter", data={"data": query}, headers=headers)
print(f"Status: {r.status_code}")
print(f"Response preview: {r.text[:500]}")
Wrote /work/fetch_test2.py (426 chars).
1:01
The issue might be that Overpass API expects a different content type or the data format. Let me try a different approach.
1:01
Bash cd /work && python fetch_test2.py
Status: 406
Response preview: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate representation of the requested resource could not be found on this server.</p>
<hr>
<address>Apache/2.4.67 (Debian) Server at overpass-api.de Port 443</address>
</body></html>
1:07
Let me try a different approach - maybe I need to use a different Overpass endpoint, or use the proper content type. Let me try with curl or specifying Accept header.
1:07
Write /work/fetch_test3.py
content · 15 lines · py
import requests, json

query = """
[out:json];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body geom;
"""
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
r = requests.post("https://overpass-api.de/api/interpreter", data={"data": query}, headers=headers)
print(f"Status: {r.status_code}")
print(f"Response preview: {r.text[:500]}")
if r.status_code == 200:
    data = r.json()
    print(f"Elements: {len(data.get('elements', []))}")
Wrote /work/fetch_test3.py (515 chars).
1:11
Bash cd /work && python fetch_test3.py
Status: 406
Response preview: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate representation of the requested resource could not be found on this server.</p>
<hr>
<address>Apache/2.4.67 (Debian) Server at overpass-api.de Port 443</address>
</body></html>
1:18
The overpass-api.de seems to not like something about the request. Let me try using the overpass turbo URL or another endpoint.
1:18
Write /work/fetch_test4.py
content · 27 lines · py
import requests, json

query = """
[out:json];
area["name"="Wien"]->.a;
rel(area.a)[admin_level=9][boundary=administrative];
out body geom;
"""
# Try alternative overpass server
urls = [
    "https://overpass-api.de/api/interpreter",
    "https://lz4.overpass-api.de/api/interpreter",
    "https://overpass.kumi.systems/api/interpreter",
]
for url in urls:
    try:
        r = requests.post(url, data={"data": query}, timeout=30)
        print(f"URL: {url}")
        print(f"Status: {r.status_code}")
        if r.status_code == 200:
            data = r.json()
            print(f"Elements: {len(data.get('elements', []))}")
            break
        else:
            print(f"Response: {r.text[:200]}")
    except Exception as e:
        print(f"Error with {url}: {e}")
Wrote /work/fetch_test4.py (772 chars).
1:21
 响应
1:21
Bash cd /work && python fetch_test4.py
URL: https://overpass-api.de/api/interpreter
Status: 406
Response: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate repres
URL: https://lz4.overpass-api.de/api/interpreter
Status: 406
Response: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate repres
Error with https://overpass.kumi.systems/api/interpreter: HTTPSConnectionPool(host='overpass.kumi.systems', port=443): Read timed out. (read timeout=30)
1:59
The 406 error suggests there's a content negotiation issue. Let me try with curl instead and see what's happening.
1:59
Bash curl -s -o /tmp/response.txt -w "%{http_code}" -X POST -d 'data=[out:json];area[...
bash: line 1: curl: command not found
2:02
Let me install curl or use a different approach.
2:02
Bash pip install --user curl && python -c "import urllib.request, json
pip install --user curl && python -c "import urllib.request, json
query = '[out:json];area[\"name\"=\"Wien\"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'
print(query)
data = urllib.parse.urlencode({'data': query}).encode()
req = urllib.request.Request('https://overpass-api.de/api/interpreter', data=data, headers={'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded'})
resp = urllib.request.urlopen(req, timeout=60)
print(resp.status)
text = resp.read().decode()
print(text[:300])
"
ERROR: Could not find a version that satisfies the requirement curl (from versions: none)
ERROR: No matching distribution found for curl
2:10
Let me check the urllib approach properly.
2:10
Write /work/fetch_test5.py
content · 13 lines · py
import urllib.request, urllib.parse, json

query = '[out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'
data = urllib.parse.urlencode({'data': query}).encode()
req = urllib.request.Request('https://overpass-api.de/api/interpreter', data=data, headers={'Accept': 'application/json'})
resp = urllib.request.urlopen(req, timeout=120)
print(f"Status: {resp.status}")
text = resp.read().decode()
print(f"Length: {len(text)}")
print(text[:500])
if resp.status == 200:
    obj = json.loads(text)
    print(f"Elements: {len(obj.get('elements',[]))}")
Wrote /work/fetch_test5.py (593 chars).
2:14
Bash cd /work && python fetch_test5.py
Traceback (most recent call last):
  File "/work/fetch_test5.py", line 6, in <module>
    resp = urllib.request.urlopen(req, timeout=120)
  File "/usr/local/lib/python3.14/urllib/request.py", line 187, in urlopen
    return opener.open(url, data, timeout)
           ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.14/urllib/request.py", line 493, in open
    response = meth(req, response)
  File "/usr/local/lib/python3.14/urllib/request.py", line 602, in http_response
    response = self.parent.error(
        'http', request, response, code, msg, hdrs)
  File "/usr/local/lib/python3.14/urllib/request.py", line 531, in error
    return self._call_chain(*args)
           ~~~~~~~~~~~~~~~~^^^^^^^
  File "/usr/local/lib/python3.14/urllib/request.py", line 464, in _call_chain
    result = func(*args)
  File "/usr/local/lib/python3.14/urllib/request.py", line 611, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 406: Not Acceptable
2:19
The Overpass API is returning 406 because of the Accept header. Let me try without the Accept header, or use a different approach.
2:19
Write /work/fetch_test6.py
content · 13 lines · py
import urllib.request, urllib.parse, json

query = '[out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'
data = urllib.parse.urlencode({'data': query}).encode()
req = urllib.request.Request('https://overpass-api.de/api/interpreter', data=data)
resp = urllib.request.urlopen(req, timeout=120)
print(f"Status: {resp.status}")
text = resp.read().decode()
print(f"Length: {len(text)}")
print(text[:500])
if resp.status == 200:
    obj = json.loads(text)
    print(f"Elements: {len(obj.get('elements',[]))}")
Wrote /work/fetch_test6.py (553 chars).
2:23
Bash cd /work && python fetch_test6.py
Traceback (most recent call last):
  File "/work/fetch_test6.py", line 6, in <module>
    resp = urllib.request.urlopen(req, timeout=120)
  File "/usr/local/lib/python3.14/urllib/request.py", line 187, in urlopen
    return opener.open(url, data, timeout)
           ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.14/urllib/request.py", line 493, in open
    response = meth(req, response)
  File "/usr/local/lib/python3.14/urllib/request.py", line 602, in http_response
    response = self.parent.error(
        'http', request, response, code, msg, hdrs)
  File "/usr/local/lib/python3.14/urllib/request.py", line 531, in error
    return self._call_chain(*args)
           ~~~~~~~~~~~~~~~~^^^^^^^
  File "/usr/local/lib/python3.14/urllib/request.py", line 464, in _call_chain
    result = func(*args)
  File "/usr/local/lib/python3.14/urllib/request.py", line 611, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 406: Not Acceptable
2:33
The 406 error is coming from the Overpass server. Let me check if there's a custom User-Agent or something else needed. Let me try using requests with a proper User-Agent and Accept header.
2:33
Write /work/fetch_test7.py
content · 12 lines · py
import requests, json

query = '[out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'
headers = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Python/3.14",
    "Accept": "application/json, text/plain, */*",
    "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
}
r = requests.post("https://overpass-api.de/api/interpreter", data={"data": query}, headers=headers, timeout=120)
print(f"Status: {r.status_code}")
print(f"Headers: {dict(r.headers)}")
print(f"Response: {r.text[:500]}")
Wrote /work/fetch_test7.py (555 chars).
2:36
The 406 error is about content negotiation. Let me try with a different approach - maybe the overpass server has changed its requirements. Actually, looking at the error it's a 406 Not Acceptable which typically means the server can't produce a response matching the Accept headers sent by the client. Let me try without any specific headers.
2:36
Bash cd /work && python fetch_test7.py
Status: 406
Headers: {'Date': 'Thu, 18 Jun 2026 23:09:43 GMT', 'Server': 'Apache/2.4.67 (Debian)', 'Content-Length': '371', 'Connection': 'close', 'Content-Type': 'text/html; charset=iso-8859-1'}
Response: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate representation of the requested resource could not be found on this server.</p>
<hr>
<address>Apache/2.4.67 (Debian) Server at overpass-api.de Port 443</address>
</body></html>
2:42
The 406 error seems odd. Let me check if there's an issue with the query syntax. Perhaps the double quotes inside the query string are causing issues. Let me try a simpler query.
2:42
Write /work/fetch_test8.py
content · 25 lines · py
import requests, json

# Simplified query - no area filter first
query = """
[out:json];
rel(3860455);
out body geom;
"""
# Try using osm3s to prepend _[out:json] differently
query2 = "[out:json];rel(3860455);out body geom;"

# Also try getting node
query3 = "[out:json];node(1);out;"

for i, q in enumerate([query, query2, query3], 1):
    try:
        r = requests.post("https://overpass-api.de/api/interpreter", data={"data": q.strip()}, timeout=30)
        print(f"Query {i}: Status={r.status_code}")
        if r.status_code == 200:
            print(f"  Elements: {len(r.json().get('elements',[]))}")
            print(f"  Text: {r.text[:300]}")
        else:
            print(f"  Error: {r.text[:200]}")
    except Exception as e:
        print(f"Query {i}: Exception: {e}")
Wrote /work/fetch_test8.py (782 chars).
2:47
Bash cd /work && python fetch_test8.py
Query 1: Status=406
  Error: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate repres
Query 2: Status=406
  Error: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate repres
Query 3: Status=406
  Error: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>406 Not Acceptable</title>
</head><body>
<h1>Not Acceptable</h1>
<p>An appropriate repres
2:52
The Overpass API is returning 406 for all queries. This might be because the server is not accepting our requests. Let me try the `wget` command or see if we have `wget` or `httpie` available.
2:52
Bash which wget httpie alpine curl 2>/dev/null; apt list --installed 2>/dev/null | gr...
libcurl3t64-gnutls/now 8.14.1-2+deb13u3 amd64 [installed,local]
libcurl4-openssl-dev/now 8.14.1-2+deb13u3 amd64 [installed,local]
libcurl4t64/now 8.14.1-2+deb13u3 amd64 [installed,local]
2:56
The Overpass API is returning 406 Not Acceptable. This is odd. Let me check if the API endpoint has changed or if there's an issue with the query format. Maybe the server requires a specific query format or there's a rate limiting issue. Let me try using a different URL or approach.

Actually, let me try using the `curl` command line tool more carefully. Actually curl is not installed. Let me install it.
2:56
Bash apt-get install -y curl 2>&1 | tail -5
E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)
E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?
3:00
Let me try installing curl via pip. Actually let me look for it via which or find.
3:00
Bash find / -name curl -type f 2>/dev/null; pip install --user pycurl 2>&1 | tail -3
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.1/5.1 MB 12.2 MB/s  0:00:00
Installing collected packages: pycurl
Successfully installed pycurl-7.46.0
3:07
Let me try using pycurl or the requests library more carefully. The 406 error might be related to the Accept header. Let me try without any Accept header, or with different approaches.
3:07
Write /work/fetch_test9.py
content · 12 lines · py
import json, io, urllib.request, urllib.parse

query = '[out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'
data = urllib.parse.urlencode({'data': query}).encode()
req = urllib.request.Request('https://overpass-api.de/api/interpreter', data=data)
# Remove all accept headers by not setting one
resp = urllib.request.urlopen(req, timeout=120)
print(f"Status: {resp.status}")
print(f"Headers: {dict(resp.headers)}")
text = resp.read().decode()
print(f"Length: {len(text)}")
print(text[:500])
Wrote /work/fetch_test9.py (540 chars).
3:10
 
3:10
Bash cd /work && python fetch_test9.py
Traceback (most recent call last):
  File "/work/fetch_test9.py", line 7, in <module>
    resp = urllib.request.urlopen(req, timeout=120)
  File "/usr/local/lib/python3.14/urllib/request.py", line 187, in urlopen
    return opener.open(url, data, timeout)
           ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.14/urllib/request.py", line 493, in open
    response = meth(req, response)
  File "/usr/local/lib/python3.14/urllib/request.py", line 602, in http_response
    response = self.parent.error(
        'http', request, response, code, msg, hdrs)
  File "/usr/local/lib/python3.14/urllib/request.py", line 531, in error
    return self._call_chain(*args)
           ~~~~~~~~~~~~~~~~^^^^^^^
  File "/usr/local/lib/python3.14/urllib/request.py", line 464, in _call_chain
    result = func(*args)
  File "/usr/local/lib/python3.14/urllib/request.py", line 611, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 406: Not Acceptable
3:21
The issue might be that the Overpass API requires a specific User-Agent. Let me try pycurl which I just installed.
3:21
Write /work/fetch_test10.py
content · 19 lines · py
import pycurl, io, json, urllib.parse

query = '[out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 120)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}")
print(f"Length: {len(body)}")
print(body[:500])
if status == 200:
    obj = json.loads(body)
    print(f"Elements: {len(obj.get('elements',[]))}")
Wrote /work/fetch_test10.py (619 chars).
3:38
Bash cd /work && python fetch_test10.py
Status: 200
Length: 665643
{
  "version": 0.6,
  "generator": "Overpass API 0.7.62.11 87bfad18",
  "osm3s": {
    "timestamp_osm_base": "2026-06-18T23:08:53Z",
    "timestamp_areas_base": "2026-06-18T08:44:00Z",
    "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
  },
  "elements": [

{
  "type": "relation",
  "id": 1990590,
  "bounds": {
    "minlat": 48.2138140,
    "minlon": 16.3408703,
    "maxlat": 48.2363090,
    "maxlon": 16.3701490
  },
  "mem
Elements: 23
3:51
Great, I got the data! Now I need to also get the 2014-01-01 snapshot. I can use Overpass with the date parameter. Let me also check each relation's details.
3:51
3:51
Write /work/fetch_current.py
content · 25 lines · py
import pycurl, io, json, urllib.parse, pickle

query = '[out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 120)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}, Length: {len(body)}")
obj = json.loads(body)
print(f"Elements: {len(obj.get('elements',[]))}")
with open('/work/current_districts.json', 'w') as f:
    json.dump(obj, f)
print("Saved current_districts.json")

# Show element types and names
for el in obj['elements']:
    tags = el.get('tags', {})
    name = tags.get('name', '?')
    print(f"  id={el['id']} type={el['type']} name={name}")
Wrote /work/fetch_current.py (869 chars).
3:53
Bash cd /work && python fetch_current.py
Status: 200, Length: 665643
Elements: 23
Saved current_districts.json
  id=1990590 type=relation name=Alsergrund
  id=1990591 type=relation name=Hietzing
  id=1990592 type=relation name=Innere Stadt
  id=1990593 type=relation name=Josefstadt
  id=1990594 type=relation name=Leopoldstadt
  id=1990595 type=relation name=Mariahilf
  id=1990596 type=relation name=Meidling
  id=1990597 type=relation name=Neubau
  id=1990598 type=relation name=Penzing
  id=1990599 type=relation name=Rudolfsheim-Fünfhaus
  id=1990600 type=relation name=Währing
  id=1991416 type=relation name=Landstraße
  id=1991433 type=relation name=Brigittenau
  id=1991434 type=relation name=Donaustadt
  id=1991435 type=relation name=Döbling
  id=1991436 type=relation name=Favoriten
  id=1991437 type=relation name=Floridsdorf
  id=1991438 type=relation name=Hernals
  id=1991439 type=relation name=Liesing
  id=1991440 type=relation name=Margareten
  id=1991441 type=relation name=Ottakring
  id=1991442 type=relation name=Simmering
  id=1991443 type=relation name=Wieden
4:01
Now I need to get the 2014-01-01 data. For that, I can use Overpass's date suffix on `out` or use the "date" parameter in the query. Let me try using the `[date:"2014-01-01T00:00:00Z"]` syntax.
4:01
4:01
Write /work/fetch_2014.py
content · 27 lines · py
import pycurl, io, json, urllib.parse

# Use the 'date' parameter in the [out:json] or use a query with date
query = '[out:json][date:"2014-01-01T00:00:00Z"];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 120)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}, Length: {len(body)}")
if status == 200:
    obj = json.loads(body)
    print(f"Elements: {len(obj.get('elements',[]))}")
    with open('/work/districts_2014.json', 'w') as f:
        json.dump(obj, f)
    print("Saved districts_2014.json")
    for el in obj['elements']:
        tags = el.get('tags', {})
        name = tags.get('name', '?')
        print(f"  id={el['id']} type={el['type']} name={name}")
else:
    print(body[:500])
Wrote /work/fetch_2014.py (1004 chars).
4:02
Bash cd /work && python fetch_2014.py
<exit_code>-1</exit_code>
<error>Command timed out after 120000ms</error>
6:09
The 2014 snapshot query timed out. Let me try a different approach - maybe use the Overpass API with a different endpoint or try the attic queries differently.
6:09
Write /work/fetch_2014b.py
content · 33 lines · py
import pycurl, io, json, urllib.parse

# Method: use the 'out' modifier with 'geom' and specify date in the request
# Actually Overpass supports [date:"YYYY-MM-DDTHH:MM:SSZ"] in the query header
# But maybe the server doesn't have attic data for that date.
# Let's try without the area filter first, get all relations with admin_level=9 in Vienna 

# Try a different syntax - put date right at the beginning
query = '[date:"2014-01-01T00:00:00Z"][out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;'

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 120)
c.setopt(c.NOSIGNAL, 1)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}, Length: {len(body)}")
if status == 200:
    obj = json.loads(body)
    print(f"Elements: {len(obj.get('elements',[]))}")
    with open('/work/districts_2014.json', 'w') as f:
        json.dump(obj, f)
    print("Saved districts_2014.json")
    for el in obj['elements']:
        tags = el.get('tags', {})
        name = tags.get('name', '?')
        print(f"  id={el['id']} type={el['type']} name={name}")
else:
    print(body[:500])
Wrote /work/fetch_2014b.py (1327 chars).
6:15
The timeout might be because area data doesn't exist for 2014. Let me try a different approach - get the data without using area, using the known relation IDs, or use the attic queries differently.
6:15
Write /work/fetch_2014c.py
content · 32 lines · py
import pycurl, io, json, urllib.parse

# Let's get all Vienna districts by their relation IDs directly
# First get current to find the IDs
# Actually we know they're 1990590-1990600, 1991416, 1991433-1991443
# Let's try with a simpler query first to see if attic data works

# Try getting a single relation at that date
query = '[out:json][date:"2014-01-01T00:00:00Z"];rel(1990590);out body geom;'

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 120)
c.setopt(c.NOSIGNAL, 1)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}, Length: {len(body)}")
if status == 200:
    obj = json.loads(body)
    print(f"Elements: {len(obj.get('elements',[]))}")
    for el in obj['elements']:
        tags = el.get('tags', {})
        name = tags.get('name', '?')
        print(f"  id={el['id']} name={name}")
    with open('/work/test_2014.json', 'w') as f:
        json.dump(obj, f)
else:
    print(body[:500])
Wrote /work/fetch_2014c.py (1114 chars).
6:17
Let me try a different approach - first understand if the attic data is available and how it works.
6:17
Bash cd /work && timeout 60 python fetch_2014c.py
Status: 200, Length: 11591
Elements: 1
  id=1990590 name=Alsergrund
6:23
So attic data works for individual relations. Let me get all 23 relations from 2014-01-01.
6:23
6:23
Write /work/fetch_2014_all.py
content · 35 lines · py
import pycurl, io, json, urllib.parse

# Get all 23 Vienna district relations from 2014-01-01
ids = [1990590, 1990591, 1990592, 1990593, 1990594, 1990595, 1990596, 
       1990597, 1990598, 1990599, 1990600, 1991416, 1991433, 1991434, 
       1991435, 1991436, 1991437, 1991438, 1991439, 1991440, 1991441, 
       1991442, 1991443]

# Build query for all relations
rel_ids = ','.join(str(i) for i in ids)
query = f'[out:json][date:"2014-01-01T00:00:00Z"];rel({rel_ids});out body geom;'

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 300)
c.setopt(c.NOSIGNAL, 1)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}, Length: {len(body)}")
if status == 200:
    obj = json.loads(body)
    print(f"Elements: {len(obj.get('elements',[]))}")
    with open('/work/districts_2014.json', 'w') as f:
        json.dump(obj, f)
    print("Saved districts_2014.json")
    for el in obj['elements']:
        tags = el.get('tags', {})
        name = tags.get('name', '?')
        print(f"  id={el['id']} name={name}")
else:
    print(body[:1000])
Wrote /work/fetch_2014_all.py (1247 chars).
6:25
Bash cd /work && timeout 120 python fetch_2014_all.py
Status: 400, Length: 1105
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" lang="en"/>
  <title>OSM3S Response</title>
</head>
<body>

<p>The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.</p>
<p><strong style="color:#FF0000">Error</strong>: line 1: static error: For the attribute &quot;n&quot; of the element &quot;bbox-query&quot; the only allowed values are floats between -90.0 and 90.0. </p>
<p><strong style="color:#FF0000">Error</strong>: line 1: static error: For the attribute &quot;w&quot; of the element &quot;bbox-query&quot; the only allowed values are floats between -180.0 and 180.0. </p>
<p><strong style="color:#FF0000">Error</strong>: line 1: static error: For the attribute &quot;e&quot; of the element &
6:31
The query seems to be too long or has some issue. Let me try fetching them one by one or in smaller batches.
6:31
Write /work/fetch_2014_batch.py
content · 36 lines · py
import pycurl, io, json, urllib.parse, time

ids = [1990590, 1990591, 1990592, 1990593, 1990594, 1990595, 1990596, 
       1990597, 1990598, 1990599, 1990600, 1991416, 1991433, 1991434, 
       1991435, 1991436, 1991437, 1991438, 1991439, 1991440, 1991441, 
       1991442, 1991443]

all_elements = []

for rid in ids:
    query = f'[out:json][date:"2014-01-01T00:00:00Z"];rel({rid});out body geom;'
    buf = io.BytesIO()
    c = pycurl.Curl()
    c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
    c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
    c.setopt(c.WRITEDATA, buf)
    c.setopt(c.TIMEOUT, 120)
    c.setopt(c.NOSIGNAL, 1)
    c.perform()
    status = c.getinfo(c.RESPONSE_CODE)
    body = buf.getvalue().decode('utf-8')
    if status == 200:
        obj = json.loads(body)
        for el in obj['elements']:
            all_elements.append(el)
            tags = el.get('tags', {})
            name = tags.get('name', '?')
            print(f"  id={el['id']} name={name}")
    else:
        print(f"  id={rid} status={status} error={body[:200]}")
    time.sleep(2)  # be nice to the server

print(f"\nTotal elements: {len(all_elements)}")
with open('/work/districts_2014.json', 'w') as f:
    json.dump({"version": 0.6, "generator": "Overpass", "elements": all_elements}, f)
print("Saved districts_2014.json")
Wrote /work/fetch_2014_batch.py (1347 chars).
6:34
Bash cd /work && timeout 300 python fetch_2014_batch.py
  id=1990590 name=Alsergrund
  id=1990591 name=Hietzing
  id=1990592 name=Innere Stadt
  id=1990593 name=Josefstadt
  id=1990594 name=Leopoldstadt
  id=1990595 name=Mariahilf
  id=1990596 name=Meidling
  id=1990597 name=Neubau
  id=1990598 name=Penzing
  id=1990599 name=Rudolfsheim-Fünfhaus
  id=1990600 name=Währing
  id=1991416 name=Landstraße
  id=1991433 name=Brigittenau
  id=1991434 name=Donaustadt
  id=1991435 name=Döbling
  id=1991436 name=Favoriten
  id=1991437 name=Floridsdorf
  id=1991438 name=Hernals
  id=1991439 name=Liesing
  id=1991440 name=Margareten
  id=1991441 name=Ottakring
  id=1991442 status=429 error=<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:
  id=1991443 status=429 error=<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:

Total elements: 21
Saved districts_2014.json
8:48
Got rate limited. Let me retry the remaining two (1991442 Simmering, 1991443 Wieden) after a wait.
8:48
Write /work/fetch_remaining.py
content · 36 lines · py
import pycurl, io, json, urllib.parse, time

ids = [1991442, 1991443]
all_elements = []

for rid in ids:
    query = f'[out:json][date:"2014-01-01T00:00:00Z"];rel({rid});out body geom;'
    buf = io.BytesIO()
    c = pycurl.Curl()
    c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
    c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
    c.setopt(c.WRITEDATA, buf)
    c.setopt(c.TIMEOUT, 120)
    c.setopt(c.NOSIGNAL, 1)
    c.setopt(c.FRESH_CONNECT, 1)
    c.perform()
    status = c.getinfo(c.RESPONSE_CODE)
    body = buf.getvalue().decode('utf-8')
    if status == 200:
        obj = json.loads(body)
        for el in obj['elements']:
            all_elements.append(el)
            tags = el.get('tags', {})
            name = tags.get('name', '?')
            print(f"  id={el['id']} name={name}")
    else:
        print(f"  id={rid} status={status}")
        print(f"  {body[:200]}")
    time.sleep(5)

with open('/work/districts_2014.json', 'r') as f:
    data = json.load(f)
data['elements'].extend(all_elements)
with open('/work/districts_2014.json', 'w') as f:
    json.dump(data, f)
print(f"Total: {len(data['elements'])}")
Wrote /work/fetch_remaining.py (1162 chars).
8:49
Bash cd /work && timeout 120 python fetch_remaining.py
  id=1991442 name=Simmering
  id=1991443 name=Wieden
Total: 23
9:07
Got all 23 districts from both current and 2014. Now let me examine the data structures more closely, particularly the relation members and geometry.
9:07
9:07
Write /work/examine.py
content · 41 lines · py
import json

with open('/work/current_districts.json') as f:
    cur = json.load(f)
with open('/work/districts_2014.json') as f:
    old = json.load(f)

# Examine a relation structure
for el in cur['elements']:
    if el['id'] == 1990592:  # Innere Stadt
        print("=== Current Innere Stadt ===")
        print(f"Tags: {json.dumps(el.get('tags',{}), indent=2)}")
        mems = el.get('members', [])
        print(f"Members: {len(mems)}")
        for m in mems[:5]:
            print(f"  role={m['role']} type={m['type']} ref={m['ref']} geom={'geom' in m}")
        # Check if any member has full geometry
        for m in mems:
            if 'geom' in m:
                print(f"  Member with geom: role={m['role']} type={m['type']}")
                print(f"    Geometry points: {len(m['geom'])}")
                print(f"    First 3: {m['geom'][:3]}")
                break
        break

# Same for 2014
for el in old['elements']:
    if el['id'] == 1990592:
        print("\n=== 2014 Innere Stadt ===")
        print(f"Tags: {json.dumps(el.get('tags',{}), indent=2)}")
        mems = el.get('members', [])
        print(f"Members: {len(mems)}")
        for m in mems[:5]:
            print(f"  role={m['role']} type={m['type']} ref={m['ref']} geom={'geom' in m}")
        for m in mems:
            if 'geom' in m:
                print(f"  Member with geom: role={m['role']} type={m['type']}")
                print(f"    Geometry points: {len(m['geom'])}")
                print(f"    First 3: {m['geom'][:3]}")
                break
        break
Wrote /work/examine.py (1559 chars).
9:08
Bash cd /work && python examine.py
=== Current Innere Stadt ===
Tags: {
  "admin_level": "9",
  "boundary": "administrative",
  "name": "Innere Stadt",
  "name:uk": "\u0412\u043d\u0443\u0442\u0440\u0456\u0448\u043d\u0454 \u043c\u0456\u0441\u0442\u043e",
  "population": "17135",
  "ref": "1",
  "ref:at:gkz": "90101",
  "street_names:wikipedia": "de:Liste der Stra\u00dfennamen von Wien/Innere Stadt",
  "type": "boundary",
  "wikidata": "Q267329",
  "wikipedia": "de:Innere Stadt (Wien)"
}
Members: 8
  role=outer type=way ref=147416055 geom=False
  role=outer type=way ref=147463458 geom=False
  role=outer type=way ref=147396089 geom=False
  role=outer type=way ref=147396086 geom=False
  role=outer type=way ref=147396090 geom=False

=== 2014 Innere Stadt ===
Tags: {
  "type": "boundary",
  "ref:at:gkz": "90101",
  "wikipedia:street_names:de": "Liste der Stra\u00dfennamen von Wien/Innere Stadt",
  "ref": "1",
  "wikipedia:de": "Innere Stadt (Wien)",
  "wikipedia:en": "Innere Stadt",
  "wikipedia:ru": "\u0412\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0433\u043e\u0440\u043e\u0434 (\u0412\u0435\u043d\u0430)",
  "name": "Innere Stadt",
  "boundary": "administrative",
  "admin_level": "9",
  "postal_code": "1010"
}
Members: 7
  role=outer type=way ref=147399466 geom=False
  role=outer type=way ref=147416055 geom=False
  role=outer type=way ref=147463458 geom=False
  role=outer type=way ref=147396089 geom=False
  role=outer type=way ref=147396086 geom=False
9:14
The relations have members that are ways with role=outer, but the full geometry is not included in the response (geom=False). I used `out body geom` which should include geometry for nodes and ways... Let me check more carefully. Actually, the `out body geom;` at the end of the query returns geometry for the elements themselves (the relations), not necessarily for their member ways. I need to use `out geom;` or convert the way references to actual coordinates.

Let me adjust - I need to either:
1. Use `(._;>;); out geom;` to recurse down and get full geometries
2. Or use `out body geom;` for relation + `recurse` to get ways with geom

Let me try with a different query approach.
9:14
Write /work/examine2.py
content · 27 lines · py
import json

with open('/work/current_districts.json') as f:
    cur = json.load(f)

# Check if any element has geometry directly
for el in cur['elements']:
    if el['type'] == 'relation' and el['id'] == 1990592:
        # Check if 'geometry' key exists
        print(f"Keys: {list(el.keys())}")
        # The members
        for m in el.get('members', []):
            if m['role'] == 'outer':
                print(f"Way ref={m['ref']}, has_geom={'geom' in m}")
        break
    
# Check the whole response for any ways
ways = [el for el in cur['elements'] if el['type'] == 'way']
print(f"\nTotal ways in response: {len(ways)}")
if ways:
    print(f"First way keys: {list(ways[0].keys())}")
    print(f"First way nodes: {ways[0].get('nodes', [])[:5]}")
    if 'geometry' in ways[0]:
        print(f"Geometry: {ways[0]['geometry'][:3]}")
    # Check if there's a 'geom' key (Overpass often calls it 'geometry')
    if 'geom' in ways[0]:
        print(f"geom: {ways[0]['geom'][:3]}")
Wrote /work/examine2.py (985 chars).
9:16
Bash cd /work && python examine2.py
Keys: ['type', 'id', 'bounds', 'members', 'tags']
Way ref=147416055, has_geom=False
Way ref=147463458, has_geom=False
Way ref=147396089, has_geom=False
Way ref=147396086, has_geom=False
Way ref=147396090, has_geom=False
Way ref=147399477, has_geom=False
Way ref=147399466, has_geom=False

Total ways in response: 0
9:28
The geometry data wasn't returned as full nodes/ways. The `out body geom` only returns geometry for nodes, not for relation members. I need a different approach.

For Overpass, to get the full geometry of relations, I should use `(._;>;)` to recurse down to nodes, or use `out geom` which gives geometry for all elements including ways.

Actually, `out body geom` should give coordinates for nodes AND ways (as geometry/geom). But the problem is that relations contain references to ways, and those ways need to be fetched too. The `out body geom` gives geometry for the relation members if we use the right syntax.

Let me try `(._;>;)` after the relation query to get all child elements, then I can reconstruct the geometries.

Actually for Overpass:
- `out body` - gives the elements themselves
- `out geom` - gives geometry for nodes and ways, but also for relation members if used with recursion
- For relation members, we need the `geom` attribute on each member, which is only available with `out geom` or `out body geom` on the relation level, but I think the way geometries need to be resolved too.

Wait, looking at the response more carefully - `out body geom` on a relation returns the relation with its members, and each member should have a `geom` array of coordinates. Let me check more carefully.

Actually the doc says: "If you use the 'geom' parameter, the geometries of all elements and members will be added to the result."

So with `out body geom`, relation members should have `geom` arrays. But from what I saw, the members don't have `geom` - they just have `ref`, `type`, and `role`.

Hmm, maybe the issue is that `out body geom` only applies to ways and nodes directly, but relation members get geometry only when using `geom` on the recurse step.

Let me try a different query approach: fetch the ways with geometry and reconstruct the relation.

Actually, the simplest approach might be to use `out geom` (or `make` or `convert`) to get the full geometry. Or I could use `(._;>;)` to recurse and get all ways and nodes, then reconstruct.

But actually, maybe the simplest is to use the Overpass API's built-in geometry output. Let me try: `rel(...); out geom;` - this should give the relation with geometry.

Wait, I think the syntax might be different for getting geometry of relation members. Let me try:

```
[out:json];
rel(1990592);
out geom;
```

This should give the relation and all its members with geometry data.
9:28
Write /work/fetch_with_geom.py
content · 41 lines · py
import pycurl, io, json, urllib.parse

# Try different query styles to get geometry for relation members

# Style 1: out geom
query1 = '[out:json];rel(1990592);out geom;'

# Style 2: recurse then out
query2 = '[out:json];rel(1990592);(._;>;);out geom;'

for label, query in [("out geom", query1), ("recurse", query2)]:
    buf = io.BytesIO()
    c = pycurl.Curl()
    c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
    c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
    c.setopt(c.WRITEDATA, buf)
    c.setopt(c.TIMEOUT, 30)
    c.setopt(c.NOSIGNAL, 1)
    c.perform()
    status = c.getinfo(c.RESPONSE_CODE)
    body = buf.getvalue().decode('utf-8')
    print(f"\n=== {label} (status={status}) ===")
    if status == 200:
        obj = json.loads(body)
        print(f"Elements: {len(obj.get('elements',[]))}")
        for el in obj['elements']:
            if el['type'] == 'relation':
                print(f"  Relation id={el['id']}")
                for m in el.get('members', []):
                    print(f"    Member: role={m['role']} type={m['type']} ref={m['ref']} geom={'geom' in m}")
            elif el['type'] == 'way':
                print(f"  Way id={el['id']} nodes={len(el.get('nodes',[]))} geom={'geom' in el or 'geometry' in el}")
                if 'geometry' in el:
                    print(f"    Geometry points: {len(el['geometry'])} first: {el['geometry'][:2]}")
                elif 'geom' in el:
                    print(f"    geom points: {len(el['geom'])}")
            elif el['type'] == 'node':
                if len(obj['elements']) < 20:
                    print(f"  Node id={el['id']} lat={el.get('lat')} lon={el.get('lon')}")
    else:
        print(body[:300])
Wrote /work/fetch_with_geom.py (1727 chars).
9:29
Bash cd /work && python fetch_with_geom.py
=== out geom (status=200) ===
Elements: 1
  Relation id=1990592
    Member: role=outer type=way ref=147416055 geom=False
    Member: role=outer type=way ref=147463458 geom=False
    Member: role=outer type=way ref=147396089 geom=False
    Member: role=outer type=way ref=147396086 geom=False
    Member: role=outer type=way ref=147396090 geom=False
    Member: role=outer type=way ref=147399477 geom=False
    Member: role=outer type=way ref=147399466 geom=False
    Member: role=label type=node ref=10919470469 geom=False

=== recurse (status=200) ===
Elements: 184
  Way id=147396086 nodes=14 geom=True
    Geometry points: 14 first: [{'lat': 48.2143408, 'lon': 16.3565102}, {'lat': 48.2142595, 'lon': 16.3564918}]
  Way id=147396089 nodes=33 geom=True
    Geometry points: 33 first: [{'lat': 48.2184891, 'lon': 16.370149}, {'lat': 48.2184449, 'lon': 16.370089}]
  Way id=147396090 nodes=10 geom=True
    Geometry points: 10 first: [{'lat': 48.2069081, 'lon': 16.3558875}, {'lat': 48.2058124, 'lon': 16.3573321}]
  Way id=147399466 nodes=14 geom=True
    Geometry points: 14 first: [{'lat': 48.1996821, 'lon': 16.3659409}, {'lat': 48.1995268, 'lon': 16.3659742}]
  Way id=147399477 nodes=9 geom=True
    Geometry points: 9 first: [{'lat': 48.2026474, 'lon': 16.3615382}, {'lat': 48.2025319, 'lon': 16.3617213}]
  Way id=147416055 nodes=61 geom=True
    Geometry points: 61 first: [{'lat': 48.1999528, 'lon': 16.375215}, {'lat': 48.2003186, 'lon': 16.3766526}]
  Way id=147463458 nodes=41 geom=True
    Geometry points: 41 first: [{'lat': 48.2113547, 'lon': 16.3848946}, {'lat': 48.2113936, 'lon': 16.3845071}]
  Relation id=1990592
    Member: role=outer type=way ref=147416055 geom=False
    Member: role=outer type=way ref=147463458 geom=False
    Member: role=outer type=way ref=147396089 geom=False
    Member: role=outer type=way ref=147396086 geom=False
    Member: role=outer type=way ref=147396090 geom=False
    Member: role=outer type=way ref=147399477 geom=False
    Member: role=outer type=way ref=147399466 geom=False
    Member: role=label type=node ref=10919470469 geom=False
9:38
So the `out geom` (without recursion `>`) only gives the relation itself. We need `(._;>;); out geom;` to get the ways with full geometry. Let me now build the full solution. 

The approach:
1. Get current districts with `rel(ids);(._;>;);out geom;`
2. Get 2014 districts similarly with `[date:"2014-01-01T00:00:00Z"]`
3. For each relation, extract the outer ways' geometries, construct polygons
4. Build union of all districts for each snapshot
5. Compute symmetric difference (added/removed areas)
6. For each district, check if it borders any changed area
7. Output GeoJSON

Let me plan the GeoJSON structure:
- change_type: "added_since_2014", "removed_since_2014", or "unchanged"
- district_name: normalised name
- touches_changed: boolean

The geometry fragments should be individual features, each with those properties.

Let me write a comprehensive script.
9:38
9:38
Write /work/fetch_full_current.py
content · 37 lines · py
import pycurl, io, json, urllib.parse

ids = [1990590, 1990591, 1990592, 1990593, 1990594, 1990595, 1990596, 
       1990597, 1990598, 1990599, 1990600, 1991416, 1991433, 1991434, 
       1991435, 1991436, 1991437, 1991438, 1991439, 1991440, 1991441, 
       1991442, 1991443]

rel_ids = ','.join(str(i) for i in ids)
# Query: get relations, recurse to ways with geometry
query = f'[out:json];rel({rel_ids});(._;>;);out geom;'

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 300)
c.setopt(c.NOSIGNAL, 1)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}, Length: {len(body)}")
if status == 200:
    obj = json.loads(body)
    print(f"Elements: {len(obj.get('elements',[]))}")
    with open('/work/current_full.json', 'w') as f:
        json.dump(obj, f)
    print("Saved current_full.json")
    
    # Count types
    types = {}
    for el in obj['elements']:
        t = el['type']
        types[t] = types.get(t, 0) + 1
    print(f"Types: {types}")
else:
    print(body[:500])
Wrote /work/fetch_full_current.py (1195 chars).
9:39
Bash cd /work && timeout 120 python fetch_full_current.py
Status: 400, Length: 1105
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" lang="en"/>
  <title>OSM3S Response</title>
</head>
<body>

<p>The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.</p>
<p><strong style="color:#FF0000
9:46
The query with too many relation IDs at once is causing a parsing error. Let me do it in smaller batches or one by one.
9:46
Write /work/fetch_full_batch.py
content · 44 lines · py
import pycurl, io, json, urllib.parse, time

ids = [1990590, 1990591, 1990592, 1990593, 1990594, 1990595, 1990596, 
       1990597, 1990598, 1990599, 1990600, 1991416, 1991433, 1991434, 
       1991435, 1991436, 1991437, 1991438, 1991439, 1991440, 1991441, 
       1991442, 1991443]

def fetch_batch(rids, label):
    rel_ids = ','.join(str(i) for i in rids)
    query = f'[out:json];rel({rel_ids});(._;>;);out geom;'
    buf = io.BytesIO()
    c = pycurl.Curl()
    c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
    c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
    c.setopt(c.WRITEDATA, buf)
    c.setopt(c.TIMEOUT, 120)
    c.setopt(c.NOSIGNAL, 1)
    c.perform()
    status = c.getinfo(c.RESPONSE_CODE)
    body = buf.getvalue().decode('utf-8')
    print(f"  {label}: status={status}, length={len(body)}")
    if status == 200:
        return json.loads(body)
    else:
        print(f"  Error: {body[:300]}")
        return None

all_data = {"version": 0.6, "generator": "Overpass", "elements": []}

# Try batches of 5
batch_size = 5
for i in range(0, len(ids), batch_size):
    batch = ids[i:i+batch_size]
    label = f"ids {batch[0]}-{batch[-1]}"
    print(f"Fetching {label}...")
    data = fetch_batch(batch, label)
    if data:
        all_data['elements'].extend(data['elements'])
    time.sleep(3)

print(f"\nTotal elements collected: {len(all_data['elements'])}")
with open('/work/current_full.json', 'w') as f:
    json.dump(all_data, f)
print("Saved current_full.json")
Wrote /work/fetch_full_batch.py (1511 chars).
9:48
Bash cd /work && timeout 300 python fetch_full_batch.py
Fetching ids 1990590-1990594...
  ids 1990590-1990594: status=400, length=1105
  Error: <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" lan
Fetching ids 1990595-1990599...
  ids 1990595-1990599: status=400, length=1105
  Error: <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" lan
Fetching ids 1990600-1991435...
  ids 1990600-1991435: status=400, length=1105
  Error: <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" lan
Fetching ids 1991436-1991440...
  ids 1991436-1991440: status=400, length=1105
  Error: <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" lan
Fetching ids 1991441-1991443...
Traceback (most recent call last):
  File "/work/fetch_full_batch.py", line 36, in <module>
    data = fetch_batch(batch, label)
  File "/work/fetch_full_batch.py", line 18, in fetch_batch
    c.perform()
    ~~~~~~~~~^^
pycurl.error: (7, 'Failed to connect to overpass-api.de port 443 after 89 ms: Could not connect to server')
10:08
The server is rate-limiting me. Let me try a different approach - fetch each district individually with sleeping in between.
10:08
10:08
Write /work/fetch_full_slow.py
content · 56 lines · py
import pycurl, io, json, urllib.parse, time

ids = [1990590, 1990591, 1990592, 1990593, 1990594, 1990595, 1990596, 
       1990597, 1990598, 1990599, 1990600, 1991416, 1991433, 1991434, 
       1991435, 1991436, 1991437, 1991438, 1991439, 1991440, 1991441, 
       1991442, 1991443]

all_elements = []

for i, rid in enumerate(ids):
    query = f'[out:json];rel({rid});(._;>;);out geom;'
    buf = io.BytesIO()
    c = pycurl.Curl()
    c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
    c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
    c.setopt(c.WRITEDATA, buf)
    c.setopt(c.TIMEOUT, 60)
    c.setopt(c.NOSIGNAL, 1)
    c.perform()
    status = c.getinfo(c.RESPONSE_CODE)
    body = buf.getvalue().decode('utf-8')
    
    tags = {}
    if status == 200:
        data = json.loads(body)
        for el in data['elements']:
            all_elements.append(el)
            if el['type'] == 'relation':
                tags = el.get('tags', {})
        name = tags.get('name', '?')
        print(f"  [{i+1}/{len(ids)}] id={rid} name={name} status=200 elements={len(data['elements'])}")
    else:
        print(f"  [{i+1}/{len(ids)}] id={rid} status={status}")
        if status == 429:
            print("  Rate limited, waiting 10s...")
            time.sleep(10)
            # Retry
            c.perform()
            status2 = c.getinfo(c.RESPONSE_CODE)
            body2 = buf.getvalue().decode('utf-8')
            if status2 == 200:
                data = json.loads(body2)
                for el in data['elements']:
                    all_elements.append(el)
                    if el['type'] == 'relation':
                        tags = el.get('tags', {})
                name = tags.get('name', '?')
                print(f"  Retry: id={rid} name={name} status=200 elements={len(data['elements'])}")
    
    # Be nice to server
    time.sleep(5)

print(f"\nTotal elements: {len(all_elements)}")
with open('/work/current_full.json', 'w') as f:
    json.dump({"version": 0.6, "generator": "Overpass", "elements": all_elements}, f)
print("Saved current_full.json")
Wrote /work/fetch_full_slow.py (2103 chars).
10:14
Actually, instead of fetching one-by-one with pycurl which is slow, let me use a different approach. I can use the existing current_districts.json which has the relation structures with member references, along with the way/node data. But the issue is that `current_districts.json` doesn't have geometry for the ways.

Let me think about what I need. I need to build proper multipolygon geometries for each district from both dates, then compute the symmetric difference.

A better approach:
1. Use the Overpass API to get the full geometry in one call per district (using the recursed query we tested that works for one relation at a time)
2. Or use a different Overpass server
3. Or use the Overpass Turbo approach

Actually, the issue with the batch query is the area filter - wait, no, I'm not using an area filter. The error was about bbox-query. Let me check the actual error message.
10:14
Write /work/fetch_error_check.py
content · 20 lines · py
import pycurl, io, json, urllib.parse

# Check full error for batch of 5
ids = [1990590, 1990591, 1990592, 1990593, 1990594]
rel_ids = ','.join(str(i) for i in ids)
query = f'[out:json];rel({rel_ids});(._;>;);out geom;'
print(f"Query: {query}")

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 60)
c.setopt(c.NOSIGNAL, 1)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}")
print(f"Full body:\n{body}")
Wrote /work/fetch_error_check.py (618 chars).
10:16
Bash cd /work && python fetch_error_check.py
Query: [out:json];rel(1990590,1990591,1990592,1990593,1990594);(._;>;);out geom;
Traceback (most recent call last):
  File "/work/fetch_error_check.py", line 16, in <module>
    c.perform()
    ~~~~~~~~~^^
pycurl.error: (7, 'Failed to connect to overpass-api.de port 443 after 90 ms: Could not connect to server')
10:18
The server seems to be rate-limiting us heavily. Let me wait a bit and try a single, simple request first.
10:18
Bash sleep 30 && cd /work && python fetch_error_check.py
Query: [out:json];rel(1990590,1990591,1990592,1990593,1990594);(._;>;);out geom;
Traceback (most recent call last):
  File "/work/fetch_error_check.py", line 16, in <module>
    c.perform()
    ~~~~~~~~~^^
pycurl.error: (7, 'Failed to connect to overpass-api.de port 443 after 111 ms: Could not connect to server')
10:55
The Overpass API seems to be timing out on connection now. Let me check if we have the data already saved and can work with what we have. We already have:

1. `current_districts.json` - 23 districts with member info (but no way/node geometries) - this has relations with member refs but no geometry
2. `districts_2014.json` - 23 districts with member info (no way/node geometries)

Let me check what we have more carefully and see if we can reconstruct geometries from the data we have.

Actually, we already successfully fetched the full recurse data for one district (1990592) in the earlier test. Let me check if `current_full.json` got partially saved... Actually no, the batch approach failed.

Wait - we have `current_districts.json` which has 665643 bytes. Let me check if it has way elements with geometry.

Actually, looking back at the earlier test, the `out body geom;` query returned only 23 elements (all relations) and no way geometry. The `(._;>;);out geom;` query returns ways with geometry. 

Let me try a different approach - maybe I can use the node coordinates from the relation members that have geom. But looking at the earlier results, `out body geom` on a relation doesn't give geometry for the member ways.

Let me try to fetch one district at a time more carefully.
10:55
Write /work/fetch_one.py
content · 29 lines · py
import pycurl, io, json, urllib.parse, time

query = '[out:json];rel(1990590);(._;>;);out geom;'
print(f"Query: {query}")

buf = io.BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, 'https://overpass-api.de/api/interpreter')
c.setopt(c.POSTFIELDS, 'data=' + urllib.parse.quote(query))
c.setopt(c.WRITEDATA, buf)
c.setopt(c.TIMEOUT, 60)
c.setopt(c.NOSIGNAL, 1)
c.setopt(c.FRESH_CONNECT, 1)
c.setopt(c.FORBID_REUSE, 1)
c.perform()
status = c.getinfo(c.RESPONSE_CODE)
body = buf.getvalue().decode('utf-8')
print(f"Status: {status}, Length: {len(body)}")
if status == 200:
    data = json.loads(body)
    print(f"Elements: {len(data['elements'])}")
    # Count types
    types = {}
    for el in data['elements']:
        t = el['type']
        types[t] = types.get(t, 0) + 1
    print(f"Types: {types}")
else:
    print(body[:500])
Wrote /work/fetch_one.py (822 chars).
10:57
Bash cd /work && python fetch_one.py
Query: [out:json];rel(1990590);(._;>;);out geom;
Traceback (most recent call last):
  File "/work/fetch_one.py", line 15, in <module>
    c.perform()
    ~~~~~~~~~^^
pycurl.error: (7, 'Failed to connect to overpass-api.de port 443 after 93 ms: Could not connect to server')
11:03
The server seems to be down right now. But I already have the current data with way refs from the first fetch (current_districts.json) and the 2014 data with way refs (districts_2014.json). Actually, I have the relation-level data without the way geometries from those files. But the earlier one-by-one fetches (fetch_2014_batch.py) worked - those fetched single relations with `out body geom` but the geom was only on relation members, not on ways. The ways had `geom=False`.

Let me check if the already fetched files have any usable geometry. Actually no - they were fetched with `out body geom` on the relation which gives bounds but not way geometries.

Wait, let me check what we actually have in the files.
11:03
Write /work/check_data.py
content · 25 lines · py
import json

# Check what geometry data we already have
for fname, label in [('/work/current_districts.json', 'current'), ('work/districts_2014.json', '2014')]:
    try:
        with open(fname) as f:
            data = json.load(f)
        print(f"\n=== {label} ===")
        print(f"Elements: {len(data['elements'])}")
        for el in data['elements']:
            if el['type'] == 'relation':
                tags = el.get('tags', {})
                name = tags.get('name', '?')
                mems = el.get('members', [])
                outer_count = sum(1 for m in mems if m['role'] == 'outer')
                has_geom_members = any('geom' in m for m in mems)
                print(f"  id={el['id']} name={name} outer_ways={outer_count} has_member_geom={has_geom_members}")
                if has_geom_members:
                    for m in mems:
                        if 'geom' in m:
                            print(f"    First member with geom: role={m['role']} pts={len(m['geom'])}")
                            print(f"    First pt: {m['geom'][0]}")
                            break
    except FileNotFoundError:
        print(f"\nFile not found: {fname}")
Wrote /work/check_data.py (1175 chars).
11:07
The current_districts.json was fetched with `out body geom;` which doesn't include way geometry - just the relation metadata. The `districts_2014.json` was fetched the same way.

I need to get the way geometries. The `out geom;` query on a relation with recurse `(._;>;)` gets the ways with their geometry lists. But the server seems to be blocking me now.

Let me try a different approach. Since the initial queries worked with `out body geom;` (which gives member geometry on relation members if available), let me check what the `out body geom` actually returned - maybe it already has member geometry.

Actually wait, looking at the first test:
- `out body geom;` gave members with `geom=False` - so no geometry on members
- `out geom;` with recurse gave ways with `geom=True`

But the server seems overloaded now. Let me wait and try again.

Actually, let me think about this differently. I already have the data files with the relation member references. The Overpass server is having issues now but was working before. Let me try again later.

Actually wait, the error is "Failed to connect to overpass-api.de port 443 after 90 ms: Could not connect to server" - this suggests a DNS/network issue, not rate limiting. Let me check if overpass-api.de resolves.
11:07
Bash python -c "import socket; print(socket.getaddrinfo('overpass-api.de', 443))"
[(<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('162.55.144.139', 443)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('162.55.144.139', 443)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_RAW: 3>, 0, '', ('162.55.144.139', 443)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('65.109.112.52', 443)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('65.109.112.52', 443)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_RAW: 3>, 0, '', ('65.109.112.52', 443)), (<AddressFamily.AF_INET6: 10>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('2a01:4f8:261:3c4f::2', 443, 0, 0)), (<AddressFamily.AF_INET6: 10>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('2a01:4f8:261:3c4f::2', 443, 0, 0)), (<AddressFamily.AF_INET6: 10>, <SocketKind.SOCK_RAW: 3>, 0, '', ('2a01:4f8:261:3c4f::2', 443, 0, 0)), (<AddressFamily.AF_INET6: 10>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('2a01:4f9:3051:3e48::2', 443, 0, 0)), (<AddressFamily.AF_INET6: 10>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('2a01:4f9:3051:3e48::2', 443, 0, 0)), (<AddressFamily.AF_INET6: 10>, <SocketKind.SOCK_RAW: 3>, 0, '', ('2a01:4f9:3051:3e48::2', 443, 0, 0))]
11:13
The DNS resolution works, but connection is failing. Let me look at what data we already have. The current_districts.json has 23 relations but their members don't have geometry. The initial query `out body geom` on relations returns the relation with member refs but no way/node geometries. We need `(._;>;);out geom;` to recurse into ways, but that's failing now.

Let me check if we already have the geometry data we need from the earlier successful fetch_test10.py. Actually, looking back, the current_districts.json was fetched with `out body geom` on just the relations, which doesn't give way geometries. But the 2014 data was fetched one-by-one with `out body geom` on each relation, also without recursing into ways.

So neither current nor 2014 snapshots have the way-level geometries we need to build polygons.

Let me think of an alternative approach. We already have the current data with just relation members (way references but no geometry). We need to either:
1. Fetch ways with geometry separately
2. Use a different query approach

The Overpass server seems to be intermittently blocking us. Let me try again with a different approach.

Actually, wait - the test earlier with `out geom` on single relation worked:
- `[out:json];rel(1990590);out body geom;` worked and returned 1 element (the relation)
- `[out:json];rel(1990590);(._;>;);out geom;` worked earlier and returned 184 elements (relation + ways + nodes) with way geometry

The issue now seems like the server is rate-limiting or blocking our IP. Let me try with different approach.
11:13
Bash sleep 20 && cd /work && python fetch_one.py
Query: [out:json];rel(1990590);(._;>;);out geom;
Traceback (most recent call last):
  File "/work/fetch_one.py", line 15, in <module>
    c.perform()
    ~~~~~~~~~^^
pycurl.error: (7, 'Failed to connect to overpass-api.de port 443 after 109 ms: Could not connect to server')
11:40
The server seems to be temporarily blocking me. Let me check what data we already have. The original `current_districts.json` and `districts_2014.json` were fetched with `out body geom` and they had the geometry in the `geom` field of members. Let me check more carefully.
11:40
Write /work/check_geom.py
content · 44 lines · py
import json

# Check if members have 'geom' key in the data we fetched
with open('/work/current_districts.json') as f:
    cur = json.load(f)
with open('/work/districts_2014.json') as f:
    old = json.load(f)

# Check current
print("=== Current ===")
for el in cur['elements']:
    if el['type'] == 'relation':
        name = el.get('tags', {}).get('name', '?')
        mems = el.get('members', [])
        for m in mems:
            if 'geom' in m:
                print(f"  id={el['id']} name={name}: member type={m['type']} ref={m['ref']} geom_pts={len(m['geom'])}")
                break
        else:
            print(f"  id={el['id']} name={name}: NO geometry on members")
            # Check if 'geometry' key exists
            for m in mems:
                if 'geometry' in m:
                    print(f"    -> Found 'geometry' on member!")
                    break
            else:
                print(f"    -> Member keys sample: {list(mems[0].keys()) if mems else 'no members'}")
                # Check if relation itself has geometry
                if 'geometry' in el:
                    print(f"    -> Relation has geometry key!")
                if 'geom' in el:
                    print(f"    -> Relation has geom key!")

print("\n=== 2014 ===")
for el in old['elements']:
    if el['type'] == 'relation':
        name = el.get('tags', {}).get('name', '?')
        mems = el.get('members', [])
        for m in mems:
            if 'geom' in m:
                print(f"  id={el['id']} name={name}: member type={m['type']} ref={m['ref']} geom_pts={len(m['geom'])}")
                break
        else:
            print(f"  id={el['id']} name={name}: NO geometry on members")
Wrote /work/check_geom.py (1703 chars).
11:46
Let me check if the data we already fetched (current_districts.json, districts_2014.json) has geometry on the members. The earlier test showed `geom=False` for members, meaning `out body geom` only gives member references without geometry. We need the recurse approach to get way geometries, but the server is currently unresponsive due to rate limiting.

Let me think about what data we have and how to proceed. The current_districts.json and districts_2014.json have the relations with member lists (way references), but the actual way geometries weren't fetched because `out body geom` for relations gives just the relation metadata and member references.

Wait, looking back at the first fetch_test5.py run, the response had `geom=True` on members... Let me re-check.

Actually, in `examine.py` output:
```
  Member with geom: role=outer type=way ref=147416055 geom=True
    Geometry points: 61
```

Wait, that was from the first batch that actually worked (fetch_test5.py). And in `examine2.py`:
```
Way ref=147416055, has_geom=False
```

Hmm, so there's a discrepancy. Let me look more carefully.

In `examine.py`, the output was:
```
Members: 8
  role=outer type=way ref=147416055 geom=False
```

But then:
```
for m in mems:
    if 'geom' in m:
```

That found one. So the first few members didn't have 'geom' but one of them deeper in the list did. Let me re-examine.

Actually wait, looking at examine2.py output again:
```
Keys: ['type', 'id', 'bounds', 'members', 'tags']
Way ref=147416055, has_geom=False
...
Total ways in response: 0
```

So the `current_districts.json` file that we saved from the first successful fetch already has geometry in the member `geom` field! Let me just verify this.
11:46
Bash cd /work && python check_geom.py
=== Current ===
  id=1990590 name=Alsergrund: NO geometry on members
    -> Found 'geometry' on member!
  id=1990591 name=Hietzing: NO geometry on members
    -> Found 'geometry' on member!
  id=1990592 name=Innere Stadt: NO geometry on members
    -> Found 'geometry' on member!
  id=1990593 name=Josefstadt: NO geometry on members
    -> Found 'geometry' on member!
  id=1990594 name=Leopoldstadt: NO geometry on members
    -> Found 'geometry' on member!
  id=1990595 name=Mariahilf: NO geometry on members
    -> Found 'geometry' on member!
  id=1990596 name=Meidling: NO geometry on members
    -> Found 'geometry' on member!
  id=1990597 name=Neubau: NO geometry on members
    -> Found 'geometry' on member!
  id=1990598 name=Penzing: NO geometry on members
    -> Found 'geometry' on member!
  id=1990599 name=Rudolfsheim-Fünfhaus: NO geometry on members
    -> Found 'geometry' on member!
  id=1990600 name=Währing: NO geometry on members
    -> Found 'geometry' on member!
  id=1991416 name=Landstraße: NO geometry on members
    -> Found 'geometry' on member!
  id=1991433 name=Brigittenau: NO geometry on members
    -> Found 'geometry' on member!
  id=1991434 name=Donaustadt: NO geometry on members
    -> Found 'geometry' on member!
  id=1991435 name=Döbling: NO geometry on members
    -> Found 'geometry' on member!
  id=1991436 name=Favoriten: NO geometry on members
    -> Found 'geometry' on member!
  id=1991437 name=Floridsdorf: NO geometry on members
    -> Found 'geometry' on member!
  id=1991438 name=Hernals: NO geometry on members
    -> Found 'geometry' on member!
  id=1991439 name=Liesing: NO geometry on members
    -> Found 'geometry' on member!
  id=1991440 name=Margareten: NO geometry on members
    -> Found 'geometry' on member!
  id=1991441 name=Ottakring: NO geometry on members
    -> Found 'geometry' on member!
  id=1991442 name=Simmering: NO geometry on members
    -> Found 'geometry' on member!
  id=1991443 name=Wieden: NO geometry on members
    -> Found 'geometry' on member!

=== 2014 ===
  id=1990590 name=Alsergrund: NO geometry on members
  id=1990591 name=Hietzing: NO geometry on members
  id=1990592 name=Innere Stadt: NO geometry on members
  id=1990593 name=Josefstadt: NO geometry on members
  id=1990594 name=Leopoldstadt: NO geometry on members
  id=1990595 name=Mariahilf: NO geometry on members
  id=1990596 name=Meidling: NO geometry on members
  id=1990597 name=Neubau: NO geometry on members
  id=1990598 name=Penzing: NO geometry on members
  id=1990599 name=Rudolfsheim-Fünfhaus: NO geometry on members
  id=1990600 name=Währing: NO geometry on members
  id=1991416 name=Landstraße: NO geometry on members
  id=1991433 name=Brigittenau: NO geometry on members
  id=1991434 name=Donaustadt: NO geometry on members
  id=1991435 name=Döbling: NO geometry on members
  id=1991436 name=Favoriten: NO geometry on members
  id=1991437 name=Floridsdorf: NO geometry on members
  id=1991438 name=Hernals: NO geometry on members
  id=1991439 name=Liesing: NO geometry on members
  id=1991440 name=Margareten: NO geometry on members
  id=1991441 name=Ottakring: NO geometry on members
  id=1991442 name=Simmering: NO geometry on members
  id=1991443 name=Wieden: NO geometry on members
11:54
So the current data has 'geometry' key on members (not 'geom'), while the 2014 data doesn't have geometry at all because we used `out body geom;` which gives member geometry only for the current snapshot. For the 2014 data we also used `out body geom;` but it might not have returned member geometries because... hmm, let me check more carefully.

Actually, looking at the initial current data query `[out:json];area["name"="Wien"]->.a;rel(area.a)[admin_level=9][boundary=administrative];out body geom;` - this returned 23 relations with NO geometry on members either! But wait, the earlier check showed `Has 'geometry' on member!` for current. So maybe the `out body geom;` returns geometry only when you add `(._;>;);` before out.

Let me look more carefully at the 2014 data to see if the members have 'geometry' key.
11:54
Write /work/check_geom2.py
content · 44 lines · py
import json

# Check geometry details
with open('/work/current_districts.json') as f:
    cur = json.load(f)
with open('/work/districts_2014.json') as f:
    old = json.load(f)

# Check first member of first relation in current  
el = cur['elements'][0]
print(f"Current first element: id={el['id']} type={el['type']}")
mems = el.get('members', [])
if mems:
    m = mems[0]
    print(f"  First member keys: {list(m.keys())}")
    print(f"  type={m['type']} ref={m['ref']} role={m['role']}")
    if 'geometry' in m:
        print(f"  geometry len: {len(m['geometry'])}")
        print(f"  first pt: {m['geometry'][0]}")

# Check 2014
el = old['elements'][0]
print(f"\n2014 first element: id={el['id']} type={el['type']}")
mems = el.get('members', [])
if mems:
    m = mems[0]
    print(f"  First member keys: {list(m.keys())}")
    print(f"  type={m['type']} ref={m['ref']} role={m['role']}")

# Let's check all 2014 members for a single district
for el in old['elements']:
    if el['id'] == 1990590:
        print(f"\n2014 Alsergrund members:")
        for m in el.get('members', []):
            print(f"  type={m['type']} ref={m['ref']} role={m['role']} keys={list(m.keys())}")
        break

# And for current
for el in cur['elements']:
    if el['id'] == 1990590:
        print(f"\nCurrent Alsergrund members:")
        for m in el.get('members', []):
            print(f"  type={m['type']} ref={m['ref']} role={m['role']} keys={list(m.keys())}")
        break
Wrote /work/check_geom2.py (1464 chars).
11:56
So the data was fetched with `out body geom;` and the members have 'geometry' key (not 'geom'). This is the Overpass geometry format where each member has a 'geometry' array of {lat, lon} objects. Let me check how to build polygon geometries from this.

Also for the 2014 data, I need to check if members have 'geometry' key too - they might not since I fetched them differently.
11:56
Bash cd /work && python check_geom2.py
Current first element: id=1990590 type=relation
  First member keys: ['type', 'ref', 'role', 'geometry']
  type=way ref=147468285 role=outer
  geometry len: 32
  first pt: {'lat': 48.2151672, 'lon': 16.3408703}

2014 first element: id=1990590 type=relation
  First member keys: ['type', 'ref', 'role', 'geometry']
  type=way ref=147468285 role=outer

2014 Alsergrund members:
  type=way ref=147468285 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=8046654 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=104962880 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=68480634 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147396089 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463455 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463456 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463454 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463462 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=206221053 role=outer keys=['type', 'ref', 'role', 'geometry']

Current Alsergrund members:
  type=way ref=147468285 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=8046654 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147396089 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463455 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463456 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=390540126 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=390540129 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463454 role=outer keys=['type', 'ref', 'role', 'geometry']
  type=way ref=147463462 role=outer keys=['type', 'ref', 'role', 'geometry']
12:03
So both datasets have 'geometry' on members. The 2014 data has `geometry` key on members too. Let me check the 2014 more closely - it seems the first member didn't show geometry because I printed the wrong thing. Let me check again.

Actually looking more carefully:
- Current first member shows `geometry` key with 32 points
- 2014 first member shows keys=['type', 'ref', 'role', 'geometry'] - so it DOES have geometry!

Great, so both datasets have geometry. Now I need to:
1. Build polygon geometries from the outer way members for each district
2. Normalize district names
3. Find changed areas between the two snapshots
4. Classify them

Let me check the 2014 geometry more carefully.
12:03
Write /work/check_geom3.py
content · 27 lines · py
import json

with open('/work/current_districts.json') as f:
    cur = json.load(f)
with open('/work/districts_2014.json') as f:
    old = json.load(f)

# Check 2014 geometry data
for el in old['elements']:
    if el['id'] == 1990590:
        for m in el.get('members', []):
            if 'geometry' in m:
                print(f"2014: type={m['type']} ref={m['ref']} role={m['role']} geometry_pts={len(m['geometry'])}")
                print(f"  first pt: {m['geometry'][0]}")
                break
        break

# Check if 2014 data has geometry for all
for el in old['elements']:
    if el['type'] == 'relation':
        name = el.get('tags', {}).get('name', '?')
        mems = el.get('members', [])
        has_geom = any('geometry' in m for m in mems if m['role'] == 'outer')
        if not has_geom:
            print(f"2014: {name} has NO geometry on outer ways!")
        else:
            print(f"2014: {name} has geometry OK")
Wrote /work/check_geom3.py (939 chars).
12:04
Bash cd /work && python check_geom3.py
2014: type=way ref=147468285 role=outer geometry_pts=17
  first pt: {'lat': 48.2151672, 'lon': 16.3408703}
2014: Alsergrund has geometry OK
2014: Hietzing has geometry OK
2014: Innere Stadt has geometry OK
2014: Josefstadt has geometry OK
2014: Leopoldstadt has geometry OK
2014: Mariahilf has geometry OK
2014: Meidling has geometry OK
2014: Neubau has geometry OK
2014: Penzing has geometry OK
2014: Rudolfsheim-Fünfhaus has geometry OK
2014: Währing has geometry OK
2014: Landstraße has geometry OK
2014: Brigittenau has geometry OK
2014: Donaustadt has geometry OK
2014: Döbling has geometry OK
2014: Favoriten has geometry OK
2014: Floridsdorf has geometry OK
2014: Hernals has geometry OK
2014: Liesing has geometry OK
2014: Margareten has geometry OK
2014: Ottakring has geometry OK
2014: Simmering has geometry OK
2014: Wieden has geometry OK
12:10
Now I have both datasets with geometry data. Let me look at the actual geometry keys more carefully and also check for any name differences between the datasets.

The current data has "geometry" on members (as seen in check_geom2.py). The 2014 data also has "geometry" on members. Good.

Now the plan:
1. Parse both datasets - for each district, extract the outer way geometries and build a polygon
2. Normalise district names (handle casing/spelling differences)
3. Union all current polygons and all 2014 polygons
4. Compute the symmetric difference to find what's changed
5. Classify fragments as added_since_2014 (in current but not 2014), removed_since_2014 (in 2014 but not current), or unchanged
6. For each district, flag whether its geometry touches any area that was added or removed

Let me check the name differences first.
12:10
12:10
Write /work/check_names.py
content · 16 lines · py
import json

with open('/work/current_districts.json') as f:
    cur = json.load(f)
with open('/work/districts_2014.json') as f:
    old = json.load(f)

print("=== Current names ===")
for el in sorted(cur['elements'], key=lambda x: int(x.get('tags',{}).get('ref', '99'))):
    tags = el.get('tags', {})
    print(f"  ref={tags.get('ref','?')} name='{tags.get('name','?')}'")

print("\n=== 2014 names ===")
for el in sorted(old['elements'], key=lambda x: int(x.get('tags',{}).get('ref', '99'))):
    tags = el.get('tags', {})
    print(f"  ref={tags.get('ref','?')} name='{tags.get('name','?')}'")
Wrote /work/check_names.py (596 chars).
12:11
Bash cd /work && python check_names.py
=== Current names ===
  ref=1 name='Innere Stadt'
  ref=2 name='Leopoldstadt'
  ref=3 name='Landstraße'
  ref=4 name='Wieden'
  ref=5 name='Margareten'
  ref=6 name='Mariahilf'
  ref=7 name='Neubau'
  ref=8 name='Josefstadt'
  ref=9 name='Alsergrund'
  ref=10 name='Favoriten'
  ref=11 name='Simmering'
  ref=12 name='Meidling'
  ref=13 name='Hietzing'
  ref=14 name='Penzing'
  ref=15 name='Rudolfsheim-Fünfhaus'
  ref=16 name='Ottakring'
  ref=17 name='Hernals'
  ref=18 name='Währing'
  ref=19 name='Döbling'
  ref=20 name='Brigittenau'
  ref=21 name='Floridsdorf'
  ref=22 name='Donaustadt'
  ref=23 name='Liesing'

=== 2014 names ===
  ref=1 name='Innere Stadt'
  ref=2 name='Leopoldstadt'
  ref=3 name='Landstraße'
  ref=4 name='Wieden'
  ref=5 name='Margareten'
  ref=6 name='Mariahilf'
  ref=7 name='Neubau'
  ref=8 name='Josefstadt'
  ref=9 name='Alsergrund'
  ref=10 name='Favoriten'
  ref=11 name='Simmering'
  ref=12 name='Meidling'
  ref=13 name='Hietzing'
  ref=14 name='Penzing'
  ref=15 name='Rudolfsheim-Fünfhaus'
  ref=16 name='Ottakring'
  ref=17 name='Hernals'
  ref=18 name='Währing'
  ref=19 name='Döbling'
  ref=20 name='Brigittenau'
  ref=21 name='Floridsdorf'
  ref=22 name='Donaustadt'
  ref=23 name='Liesing'
12:43
The names look the same. The "casing and spelling" differences mentioned might refer to older data, but in 2014 the names are already the same as current. Let me look more carefully at the tags - maybe there are extra names.

Actually, the user said "District names changed casing and spelling between snapshots" - so let me try to match by ref number, which is a reliable district identifier. Let me also look at the actual geometry changes.

Now I have a plan:
1. Build district polygons from both snapshots using the member way geometries (outer ways form the boundary ring)
2. Match districts by ref number (since names should be normalised)
3. Compute symmetric difference to find changed areas
4. Classify fragments

Let me write the main processing script.
12:43
12:43
Write /work/solve.py
content · 379 lines · py
import json
from shapely.geometry import Polygon, MultiPolygon, shape, mapping
from shapely.ops import unary_union
import geopandas as gpd

def build_polygon_from_relation(rel):
    """Build a Polygon from a relation's outer way geometries."""
    outer_rings = []
    for m in rel.get('members', []):
        if m['role'] == 'outer' and 'geometry' in m:
            coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
            if len(coords) >= 3:
                outer_rings.append(coords)
    
    if not outer_rings:
        return None
    
    # Try to merge into a single polygon (most districts have 1 outer ring)
    # For districts with multiple outer ways, we need to assemble them
    # First, collect all rings as linestrings
    from shapely.geometry import LineString
    from shapely.ops import polygonize
    
    lines = [LineString(ring) for ring in outer_rings]
    
    # Try simple approach: if the first and last points match, it's a closed ring
    # Use polygonize to merge the lines
    merged = unary_union(lines)
    
    if merged.geom_type == 'LineString':
        # Single line - try to close it
        if merged.is_ring:
            return Polygon(merged.coords)
        else:
            print(f"  WARNING: merged line is not a ring for rel {rel['id']}")
            return None
    elif merged.geom_type == 'MultiLineString':
        # Try polygonizing
        polygons = list(polygonize(merged.geoms))
        if len(polygons) == 1:
            return polygons[0]
        elif len(polygons) > 1:
            return MultiPolygon(polygons)
        else:
            print(f"  WARNING: cannot polygonize for rel {rel['id']}")
            return None
    else:
        return None


def build_polygon_simple(rel):
    """
    Build polygon from ways in order. 
    Overpass returns geometry arrays which are sequential segments.
    We need to connect them properly.
    """
    from shapely.geometry import LineString, Polygon
    
    outer_ways = []
    for m in rel.get('members', []):
        if m['role'] == 'outer' and 'geometry' in m:
            coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
            if len(coords) >= 2:
                outer_ways.append(coords)
    
    if not outer_ways:
        return None
    
    # For Vienna districts, outer ways form closed rings
    # Let's try to connect them by matching endpoints
    # First, let's see if there's just one closed ring
    if len(outer_ways) == 1 and len(outer_ways[0]) >= 3:
        coords = outer_ways[0]
        if coords[0] == coords[-1]:
            return Polygon(coords)
        else:
            # Try to close it
            return Polygon(coords + [coords[0]])
    
    # Multiple ways - sort by connecting endpoints
    # Build a dict mapping endpoint tuples to line segments
    segments = []
    for coords in outer_ways:
        seg = LineString(coords)
        segments.append(seg)
    
    merged = unary_union(segments)
    
    if merged.geom_type == 'LineString':
        if merged.is_ring:
            return Polygon(merged.coords)
        else:
            # Try to close
            coords = list(merged.coords)
            if coords[0] != coords[-1]:
                coords.append(coords[0])
            return Polygon(coords)
    elif merged.geom_type == 'MultiLineString':
        polygs = list(polygonize(merged.geoms))
        if len(polygs) == 1:
            return polygs[0]
        elif len(polygs) > 1:
            # Check for holes - inner rings
            # For Vienna districts, there might be holes (enclaves)
            # Sort by area, largest is outer
            polygs_sorted = sorted(polygs, key=lambda p: p.area, reverse=True)
            outer = polygs_sorted[0]
            holes = [p.exterior.coords for p in polygs_sorted[1:] if p.area > 0.000001]
            if holes:
                return Polygon(outer.exterior.coords, holes)
            return outer
        else:
            print(f"  WARNING: cannot polygonize for rel {rel['id']}")
            return None
    else:
        return None


def build_polygon_simple2(rel):
    """
    Simpler approach: just collect all outer way coordinates,
    close them, and build polygon via unary_union of LineStrings.
    """
    from shapely.geometry import LineString
    from shapely.ops import polygonize
    
    lines = []
    for m in rel.get('members', []):
        if m['role'] == 'outer' and 'geometry' in m:
            coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
            if len(coords) >= 2:
                lines.append(LineString(coords))
    
    if not lines:
        return None
    
    merged = unary_union(lines)
    
    if merged.geom_type == 'GeometryCollection':
        geoms = [g for g in merged.geoms if g.geom_type in ('LineString', 'MultiLineString')]
        if not geoms:
            return None
        merged = unary_union(geoms)
    
    if merged.geom_type in ('LineString',):
        if merged.is_ring:
            return Polygon(merged)
        else:
            coords = list(merged.coords)
            if len(coords) >= 3:
                if coords[0] != coords[-1]:
                    coords.append(coords[0])
                return Polygon(coords)
            return None
    elif merged.geom_type == 'MultiLineString':
        polys = list(polygonize(merged.geoms))
        if not polys:
            return None
        if len(polys) == 1:
            return polys[0]
        # Sort by area
        polys.sort(key=lambda p: p.area, reverse=True)
        outer_poly = polys[0]
        holes = []
        for p in polys[1:]:
            if p.representative_point().within(outer_poly) and not p.touches(outer_poly):
                holes.append(p.exterior.coords)
        if holes:
            return Polygon(outer_poly.exterior.coords, holes)
        return outer_poly
    else:
        return None


def build_polygon_robust(rel):
    """
    Build a polygon from the relation's outer way geometries.
    This handles Vienna's district boundaries which are made of multiple ways.
    """
    from shapely.geometry import LineString, Polygon, MultiLineString
    from shapely.ops import polygonize, linemerge
    
    # Collect all outer way geometries as LineStrings
    lines = []
    for m in rel.get('members', []):
        if m['role'] == 'outer' and 'geometry' in m:
            coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
            if len(coords) >= 2:
                lines.append(LineString(coords))
    
    if not lines:
        return None
    
    # Merge connected lines
    merged = linemerge(lines)
    
    if merged.geom_type == 'LineString':
        coords = list(merged.coords)
        if coords[0] != coords[-1]:
            coords.append(coords[0])
        return Polygon(coords)
    elif merged.geom_type == 'MultiLineString':
        # Try to polygonize
        polys = list(polygonize(merged.geoms))
        if not polys:
            return None
        
        if len(polys) == 1:
            return polys[0]
        
        # Multiple polygons - check for holes
        polys_sorted = sorted(polys, key=lambda p: p.area, reverse=True)
        outer = polys_sorted[0]
        holes = []
        for p in polys_sorted[1:]:
            # Check if this polygon is a hole (inner ring)
            if p.area > 1e-10:
                # Check if it's inside the outer polygon
                centroid = p.representative_point()
                if outer.contains(centroid) or outer.touches(p):
                    holes.append(p.exterior.coords)
        if holes:
            return Polygon(outer.exterior.coords, holes)
        return outer
    else:
        return None


# Load data
print("Loading data...")
with open('/work/current_districts.json') as f:
    current_data = json.load(f)
with open('/work/districts_2014.json') as f:
    old_data = json.load(f)

# Build district polygons
print("Building current district polygons...")
current_polys = {}
for el in current_data['elements']:
    if el['type'] == 'relation':
        ref = el.get('tags', {}).get('ref')
        name = el.get('tags', {}).get('name', '')
        poly = build_polygon_robust(el)
        if poly:
            current_polys[ref] = {'name': name, 'polygon': poly, 'relation': el}
        else:
            print(f"  FAILED to build polygon for current: {name} (ref={ref})")

print("Building 2014 district polygons...")
old_polys = {}
for el in old_data['elements']:
    if el['type'] == 'relation':
        ref = el.get('tags', {}).get('ref')
        name = el.get('tags', {}).get('name', '')
        poly = build_polygon_robust(el)
        if poly:
            old_polys[ref] = {'name': name, 'polygon': poly, 'relation': el}
        else:
            print(f"  FAILED to build polygon for 2014: {name} (ref={ref})")

print(f"\nCurrent districts built: {len(current_polys)}")
print(f"2014 districts built: {len(old_polys)}")

# Check which refs match
print(f"\nCurrent refs: {sorted(current_polys.keys())}")
print(f"2014 refs: {sorted(old_polys.keys())}")

# Build complete Vienna boundaries for both time periods
print("\nComputing Vienna boundary differences...")
current_vienna = unary_union([current_polys[r]['polygon'] for r in current_polys])
old_vienna = unary_union([old_polys[r]['polygon'] for r in old_polys])

# Compute symmetric difference
# added_since_2014 = in current but not in 2014
added = current_vienna.difference(old_vienna)
# removed_since_2014 = in 2014 but not in current
removed = old_vienna.difference(current_vienna)
# unchanged = intersection
unchanged = current_vienna.intersection(old_vienna)

print(f"  Added area: {added.area:.6f} (type={added.geom_type})")
print(f"  Removed area: {removed.area:.6f} (type={removed.geom_type})")
print(f"  Unchanged area: {unchanged.area:.6f}")

# Build features
features = []

def geom_to_feature(geom, change_type, district_name, touches_changed):
    """Convert a shapely geometry to a GeoJSON feature."""
    if geom.is_empty:
        return None
    if geom.geom_type == 'GeometryCollection':
        # Flatten
        result = []
        for g in geom.geoms:
            f = geom_to_feature(g, change_type, district_name, touches_changed)
            if f:
                result.append(f)
        return result
    
    # Ensure MultiPolygon
    if geom.geom_type == 'Polygon':
        geom = MultiPolygon([geom])
    elif geom.geom_type != 'MultiPolygon':
        return None
    
    return {
        "type": "Feature",
        "geometry": mapping(geom),
        "properties": {
            "change_type": change_type,
            "district_name": district_name,
            "touches_changed": touches_changed
        }
    }

# For added and removed, the district_name is not clearly one district
# We'll classify by which districts are involved
# For added: which current districts overlap with the added area
# For removed: which old districts overlap with the removed area

# Compute per-district changes
features_list = []

# Unchanged - per district
for ref in sorted(current_polys.keys(), key=int):
    name = current_polys[ref]['name']
    poly = current_polys[ref]['polygon']
    
    district_unchanged = poly.intersection(unchanged)
    district_added = poly.intersection(added)
    district_removed = poly.intersection(removed)
    
    touches = not district_added.is_empty or not district_removed.is_empty
    
    if not district_unchanged.is_empty:
        f = geom_to_feature(district_unchanged, 'unchanged', name, touches)
        if f:
            if isinstance(f, list):
                features_list.extend(f)
            else:
                features_list.append(f)
    
    if not district_added.is_empty:
        f = geom_to_feature(district_added, 'added_since_2014', name, True)
        if f:
            if isinstance(f, list):
                features_list.extend(f)
            else:
                features_list.append(f)
    
    if not district_removed.is_empty:
        f = geom_to_feature(district_removed, 'removed_since_2014', name, True)
        if f:
            if isinstance(f, list):
                features_list.extend(f)
            else:
                features_list.append(f)

print(f"\nTotal features: {len(features_list)}")

# Build GeoJSON
geojson = {
    "type": "FeatureCollection",
    "features": features_list
}

with open('/work/vienna_boundary_changes.geojson', 'w') as f:
    json.dump(geojson, f, indent=2)

print("\nSaved vienna_boundary_changes.geojson")
print(f"Feature count: {len(features_list)}")

# Summary stats
stats = {}
for feat in features_list:
    ct = feat['properties']['change_type']
    stats[ct] = stats.get(ct, 0) + 1
print(f"By type: {stats}")
Wrote /work/solve.py (12697 chars).
12:45
Let me also check if there are name differences that need normalizing. Let me run this first.
12:45
Bash cd /work && timeout 120 python solve.py
Loading data...
Building current district polygons...
Building 2014 district polygons...

Current districts built: 23
2014 districts built: 23

Current refs: ['1', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '2', '20', '21', '22', '23', '3', '4', '5', '6', '7', '8', '9']
2014 refs: ['1', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '2', '20', '21', '22', '23', '3', '4', '5', '6', '7', '8', '9']

Computing Vienna boundary differences...
  Added area: 0.000002 (type=MultiPolygon)
  Removed area: 0.000002 (type=MultiPolygon)
  Unchanged area: 0.050197

Total features: 33

Saved vienna_boundary_changes.geojson
Feature count: 33
By type: {'unchanged': 23, 'added_since_2014': 9, 'removed_since_2014': 1}
12:50
The changes are very small (0.000002 area in degrees). This could be due to very minor boundary changes, or it could be a geometry precision issue. Let me examine the results more carefully and also check the district name normalization. The names seem to already match between the two snapshots, but the task says "District names changed casing and spelling between snapshots, so normalise them to match." Let me check more carefully.

Also, the added area found is very tiny - let me verify this is correct and not just numerical noise.

Let me check the output and also improve the normalization.
12:50
Write /work/check_result.py
content · 22 lines · py
import json

with open('/work/vienna_boundary_changes.geojson') as f:
    data = json.load(f)

print(f"Total features: {len(data['features'])}")

# Check added features
for feat in data['features']:
    props = feat['properties']
    if props['change_type'] != 'unchanged':
        geom = feat['geometry']
        from shapely.geometry import shape
        s = shape(geom)
        print(f"  change_type={props['change_type']} district={props['district_name']} touches={props['touches_changed']} area={s.area:.8f}")

print("\nAll district names in output:")
names = set()
for feat in data['features']:
    names.add(feat['properties']['district_name'])
for n in sorted(names):
    print(f"  {n}")
Wrote /work/check_result.py (695 chars).
12:52
Bash cd /work && python check_result.py
Total features: 33
  change_type=added_since_2014 district=Favoriten touches=True area=0.00000038
  change_type=added_since_2014 district=Simmering touches=True area=0.00000032
  change_type=added_since_2014 district=Hietzing touches=True area=0.00000002
  change_type=removed_since_2014 district=Hietzing touches=True area=0.00000000
  change_type=added_since_2014 district=Penzing touches=True area=0.00000041
  change_type=added_since_2014 district=Hernals touches=True area=0.00000003
  change_type=added_since_2014 district=Döbling touches=True area=0.00000025
  change_type=added_since_2014 district=Floridsdorf touches=True area=0.00000017
  change_type=added_since_2014 district=Donaustadt touches=True area=0.00000012
  change_type=added_since_2014 district=Liesing touches=True area=0.00000025

All district names in output:
  Alsergrund
  Brigittenau
  Donaustadt
  Döbling
  Favoriten
  Floridsdorf
  Hernals
  Hietzing
  Innere Stadt
  Josefstadt
  Landstraße
  Leopoldstadt
  Liesing
  Margareten
  Mariahilf
  Meidling
  Neubau
  Ottakring
  Penzing
  Rudolfsheim-Fünfhaus
  Simmering
  Wieden
  Währing
13:00
The results look reasonable - there are tiny differences in some district boundaries. However, I should check whether the very small areas (some as small as 0.00000000) are just numerical noise from coordinate precision differences, or actual boundary changes. Let me also verify the geometry for each district more carefully, and check if there are any areas that touch changed boundaries.

Also, I need to check the "touches_changed" flag more carefully. For unchanged areas that are in districts that border any added/removed area, it should be True. Let me refine this.

Also, I should verify that the polygon building is correct - maybe I should inspect a few polygons to make sure.

Let me also check if there are really changes or if these are just floating point precision issues.
13:00
13:00
Write /work/inspect_geom.py
content · 38 lines · py
import json
from shapely.geometry import shape, Polygon, MultiPolygon
from shapely.ops import unary_union

with open('/work/current_districts.json') as f:
    cur = json.load(f)

# Check a polygon
for el in cur['elements']:
    if el['tags'].get('ref') == '1':  # Innere Stadt
        print(f"Innere Stadt members:")
        for m in el['members']:
            if m['role'] == 'outer':
                coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
                print(f"  way {m['ref']}: {len(coords)} pts, first={coords[0]}, last={coords[-1]}")
        break

# Also check a district with changes, like Favoriten
for el in cur['elements']:
    if el['tags'].get('ref') == '10':  # Favoriten
        print(f"\nFavoriten (current) members:")
        for m in el['members']:
            if m['role'] == 'outer':
                coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
                print(f"  way {m['ref']}: {len(coords)} pts, first={coords[0]}, last={coords[-1]}")
        break

# Check 2014 Favoriten
with open('/work/districts_2014.json') as f:
    old = json.load(f)
for el in old['elements']:
    if el['tags'].get('ref') == '10':  # Favoriten
        print(f"\nFavoriten (2014) members:")
        for m in el['members']:
            if m['role'] == 'outer':
                coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
                print(f"  way {m['ref']}: {len(coords)} pts, first={coords[0]}, last={coords[-1]}")
        break
Wrote /work/inspect_geom.py (1484 chars).
13:03
Bash cd /work && python inspect_geom.py
Innere Stadt members:
  way 147416055: 61 pts, first=(16.375215, 48.1999528), last=(16.3848946, 48.2113547)
  way 147463458: 41 pts, first=(16.3848946, 48.2113547), last=(16.370149, 48.2184891)
  way 147396089: 33 pts, first=(16.370149, 48.2184891), last=(16.3565102, 48.2143408)
  way 147396086: 14 pts, first=(16.3565102, 48.2143408), last=(16.3558875, 48.2069081)
  way 147396090: 10 pts, first=(16.3558875, 48.2069081), last=(16.3615382, 48.2026474)
  way 147399477: 9 pts, first=(16.3615382, 48.2026474), last=(16.3659409, 48.1996821)
  way 147399466: 14 pts, first=(16.3659409, 48.1996821), last=(16.375215, 48.1999528)

Favoriten (current) members:
  way 150887614: 30 pts, first=(16.3356406, 48.1592699), last=(16.375462, 48.1479138)
  way 147412715: 8 pts, first=(16.3356406, 48.1592699), last=(16.3319178, 48.1633398)
  way 350993577: 5 pts, first=(16.3319178, 48.1633398), last=(16.3372646, 48.1709121)
  way 352828698: 2 pts, first=(16.3372646, 48.1709121), last=(16.3373518, 48.1710406)
  way 147412714: 2 pts, first=(16.3373518, 48.1710406), last=(16.3374508, 48.1710281)
  way 33526676: 8 pts, first=(16.3374508, 48.1710281), last=(16.3383478, 48.1709207)
  way 357136448: 2 pts, first=(16.3383478, 48.1709207), last=(16.3391076, 48.170822)
  way 187831671: 2 pts, first=(16.3391076, 48.170822), last=(16.3394466, 48.1707781)
  way 135752427: 2 pts, first=(16.3394466, 48.1707781), last=(16.3397652, 48.1707386)
  way 187831673: 7 pts, first=(16.3397652, 48.1707386), last=(16.3424389, 48.1703933)
  way 357141690: 4 pts, first=(16.3424389, 48.1703933), last=(16.3431117, 48.1702951)
  way 351160311: 8 pts, first=(16.3431117, 48.1702951), last=(16.3445536, 48.1700237)
  way 355040417: 4 pts, first=(16.3451124, 48.1698965), last=(16.3445536, 48.1700237)
  way 355040416: 15 pts, first=(16.3474909, 48.1720558), last=(16.3451124, 48.1698965)
  way 355040415: 18 pts, first=(16.3474909, 48.1720558), last=(16.3460737, 48.1742019)
  way 211671729: 2 pts, first=(16.3460737, 48.1742019), last=(16.3454687, 48.1739951)
  way 149641272: 31 pts, first=(16.3454687, 48.1739951), last=(16.349725, 48.1792208)
  way 351251048: 9 pts, first=(16.3557569, 48.1797866), last=(16.349725, 48.1792208)
  way 351251057: 12 pts, first=(16.3596862, 48.1807316), last=(16.3557569, 48.1797866)
  way 150887613: 20 pts, first=(16.3688339, 48.1838352), last=(16.3596862, 48.1807316)
  way 351251050: 11 pts, first=(16.3735607, 48.1851384), last=(16.3688339, 48.1838352)
  way 150887611: 17 pts, first=(16.3809988, 48.1881182), last=(16.3735607, 48.1851384)
  way 351251058: 26 pts, first=(16.3847753, 48.1837312), last=(16.3809988, 48.1881182)
  way 149746699: 10 pts, first=(16.3954544, 48.1755271), last=(16.3847753, 48.1837312)
  way 390598534: 13 pts, first=(16.3970787, 48.1743676), last=(16.3954544, 48.1755271)
  way 1301555186: 2 pts, first=(16.3971845, 48.1743018), last=(16.3970787, 48.1743676)
  way 1301555187: 2 pts, first=(16.3971554, 48.1740636), last=(16.3971845, 48.1743018)
  way 149746696: 31 pts, first=(16.4362198, 48.1379787), last=(16.3971554, 48.1740636)
  way 59178611: 2 pts, first=(16.4362198, 48.1379787), last=(16.43313, 48.1378785)
  way 1391646370: 8 pts, first=(16.43313, 48.1378785), last=(16.433439, 48.1315569)
  way 355136961: 23 pts, first=(16.433439, 48.1315569), last=(16.4370202, 48.1199322)
  way 33118870: 14 pts, first=(16.4370202, 48.1199322), last=(16.4225504, 48.1225594)
  way 1391646369: 8 pts, first=(16.4225504, 48.1225594), last=(16.4128199, 48.1182387)
  way 33118671: 16 pts, first=(16.4128199, 48.1182387), last=(16.4028004, 48.1223583)
  way 351265908: 10 pts, first=(16.4028004, 48.1223583), last=(16.3910196, 48.1244957)
  way 1391646365: 4 pts, first=(16.3910196, 48.1244957), last=(16.3883438, 48.1253693)
  way 33118668: 6 pts, first=(16.3883438, 48.1253693), last=(16.3829747, 48.1263617)
  way 351265907: 17 pts, first=(16.3829747, 48.1263617), last=(16.3658913, 48.1283994)
  way 33118389: 3 pts, first=(16.3658913, 48.1283994), last=(16.3656377, 48.1286732)
  way 351265905: 34 pts, first=(16.374245, 48.1422088), last=(16.3656377, 48.1286732)
  way 855764994: 4 pts, first=(16.3770042, 48.1473033), last=(16.374245, 48.1422088)
  way 350993586: 4 pts, first=(16.3771178, 48.1475247), last=(16.3770042, 48.1473033)
  way 855764995: 3 pts, first=(16.375462, 48.1479138), last=(16.3771178, 48.1475247)

Favoriten (2014) members:
  way 150887614: 46 pts, first=(16.3356483, 48.1592757), last=(16.3656351, 48.1286691)
  way 33118389: 3 pts, first=(16.3658883, 48.1284024), last=(16.3656351, 48.1286691)
  way 33118668: 18 pts, first=(16.3886968, 48.1252979), last=(16.3658883, 48.1284024)
  way 33118671: 24 pts, first=(16.4128199, 48.1182387), last=(16.3886968, 48.1252979)
  way 33118870: 15 pts, first=(16.437007, 48.1199415), last=(16.4128199, 48.1182387)
  way 59178611: 21 pts, first=(16.4362198, 48.1379787), last=(16.437007, 48.1199415)
  way 149746696: 35 pts, first=(16.4362198, 48.1379787), last=(16.3954544, 48.1755271)
  way 149746699: 24 pts, first=(16.3954544, 48.1755271), last=(16.3809988, 48.1881182)
  way 150887611: 22 pts, first=(16.3809988, 48.1881182), last=(16.3688339, 48.1838352)
  way 150887613: 30 pts, first=(16.3688339, 48.1838352), last=(16.3497539, 48.1792189)
  way 149641272: 12 pts, first=(16.3416033, 48.1764135), last=(16.3497539, 48.1792189)
  way 149641213: 2 pts, first=(16.3416033, 48.1764135), last=(16.3421118, 48.1752885)
  way 149641277: 2 pts, first=(16.3434088, 48.175364), last=(16.3421118, 48.1752885)
  way 149641280: 7 pts, first=(16.3454687, 48.1739951), last=(16.3434088, 48.175364)
  way 13836031: 12 pts, first=(16.347385, 48.1718515), last=(16.3460737, 48.1742019)
  way 23304527: 4 pts, first=(16.3455794, 48.1717043), last=(16.347385, 48.1718515)
  way 23304513: 10 pts, first=(16.3455794, 48.1717043), last=(16.3451514, 48.1698745)
  way 172969675: 2 pts, first=(16.3444712, 48.1700384), last=(16.3451514, 48.1698745)
  way 135752427: 2 pts, first=(16.3394466, 48.1707781), last=(16.3397652, 48.1707386)
  way 187831673: 6 pts, first=(16.3397652, 48.1707386), last=(16.3444712, 48.1700384)
  way 187831671: 2 pts, first=(16.3391076, 48.170822), last=(16.3394466, 48.1707781)
  way 33526676: 3 pts, first=(16.3374656, 48.1710308), last=(16.3391076, 48.170822)
  way 147412714: 2 pts, first=(16.3373691, 48.1710398), last=(16.3374656, 48.1710308)
  way 147412715: 12 pts, first=(16.3356483, 48.1592757), last=(16.3373691, 48.1710398)
  way 211671729: 2 pts, first=(16.3460737, 48.1742019), last=(16.3454687, 48.1739951)
13:28
I see the geometries look correct - the ways connect end-to-end. The changes seem very tiny (area ~0.000002), which could be from actual small boundary adjustments or from numerical precision differences. Let me also check the "touches_changed" flag more carefully - the task asks: "For each district flag whether its geometry borders any area that was added or removed."

Currently I'm setting touches_changed per geometry fragment, but the question says per district. Let me re-read the requirements:

"Classify each resulting geometry fragment by how it changed: added_since_2014, removed_since_2014, or unchanged. For each district flag whether its geometry borders any area that was added or removed."

So: each output feature should have change_type, district_name, and touches_changed. The touches_changed is a boolean per district indicating if that district's geometry (any part) borders an area that was added or removed.

Let me redo the solution:
1. Per district, find the changed geometries (added/removed/unchanged parts)
2. For each district, compute if any part of its boundary (current or 2014 version) is adjacent to changed areas
3. For each geometry fragment, set touches_changed to the district-level flag

Let me also refine the polygon building to be more robust, and think about what "touches_changed" really means:
- A district's geometry "borders any area that was added or removed" - so if the district (in either time period) touches added or removed areas.

Let me redo this more carefully.
13:28
13:28
Write /work/solve_final.py
content · 274 lines · py
import json
from shapely.geometry import Polygon, MultiPolygon, LineString, shape, mapping
from shapely.ops import unary_union, polygonize, linemerge
import geopandas as gpd

def build_polygon_robust(rel):
    """
    Build a polygon from the relation's outer way geometries.
    Vienna districts are made of multiple ways forming closed rings.
    """
    lines = []
    for m in rel.get('members', []):
        if m['role'] == 'outer' and 'geometry' in m:
            coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
            if len(coords) >= 2:
                lines.append(LineString(coords))
    
    if not lines:
        return None
    
    merged = linemerge(lines)
    
    if merged is None or merged.is_empty:
        return None
    
    if merged.geom_type == 'LineString':
        coords = list(merged.coords)
        if coords[0] != coords[-1]:
            coords.append(coords[0])
        return Polygon(coords)
    elif merged.geom_type == 'MultiLineString':
        polys = list(polygonize(merged.geoms))
        if not polys:
            return None
        
        if len(polys) == 1:
            return polys[0]
        
        # Sort by area descending
        polys_sorted = sorted(polys, key=lambda p: p.area, reverse=True)
        outer = polys_sorted[0]
        holes = []
        for p in polys_sorted[1:]:
            if p.area > 1e-10:
                centroid = p.representative_point()
                if outer.contains(centroid) or outer.touches(p):
                    holes.append(p.exterior.coords)
        if holes:
            return Polygon(outer.exterior.coords, holes)
        return outer
    else:
        return None


def ensure_multipolygon(geom):
    """Convert to MultiPolygon if needed."""
    if geom.is_empty:
        return None
    if geom.geom_type == 'Polygon':
        return MultiPolygon([geom])
    if geom.geom_type == 'MultiPolygon':
        return geom
    if geom.geom_type == 'GeometryCollection':
        polys = [g for g in geom.geoms if g.geom_type in ('Polygon', 'MultiPolygon')]
        if not polys:
            return None
        merged = unary_union(polys)
        return ensure_multipolygon(merged)
    return None


# Load data
print("Loading data...")
with open('/work/current_districts.json') as f:
    current_data = json.load(f)
with open('/work/districts_2014.json') as f:
    old_data = json.load(f)

# Build polygons for each district, keyed by ref number
print("Building district polygons...")
current_districts = {}  # ref -> {name, polygon}
old_districts = {}

for el in current_data['elements']:
    if el['type'] == 'relation':
        ref = el.get('tags', {}).get('ref')
        name = el.get('tags', {}).get('name', '')
        poly = build_polygon_robust(el)
        if poly and not poly.is_empty:
            current_districts[ref] = {'name': name, 'polygon': poly}
        else:
            print(f"  FAILED: current {name} (ref={ref})")

for el in old_data['elements']:
    if el['type'] == 'relation':
        ref = el.get('tags', {}).get('ref')
        name = el.get('tags', {}).get('name', '')
        poly = build_polygon_robust(el)
        if poly and not poly.is_empty:
            old_districts[ref] = {'name': name, 'polygon': poly}
        else:
            print(f"  FAILED: 2014 {name} (ref={ref})")

print(f"Current districts: {len(current_districts)}")
print(f"2014 districts: {len(old_districts)}")

# Build complete Vienna outlines
current_vienna = unary_union([current_districts[r]['polygon'] for r in current_districts])
old_vienna = unary_union([old_districts[r]['polygon'] for r in old_districts])

# Buffer slightly to handle numerical precision issues
BUF = 1e-10

# Compute the symmetric difference areas
added_total = current_vienna.difference(old_vienna)
removed_total = old_vienna.difference(current_vienna)
unchanged_total = current_vienna.intersection(old_vienna)

# Clean up small slivers
def clean_geom(geom, min_area=1e-10):
    if geom.is_empty:
        return None
    if geom.geom_type == 'MultiPolygon':
        polys = [p for p in geom.geoms if p.area >= min_area]
        if not polys:
            return None
        if len(polys) == 1:
            g = polys[0]
        else:
            g = MultiPolygon(polys)
    else:
        g = geom
    return ensure_multipolygon(g)

added_total = clean_geom(added_total)
removed_total = clean_geom(removed_total)

print(f"\nAdded area: {added_total.area:.10f}" if added_total else "Added: None")
print(f"Removed area: {removed_total.area:.10f}" if removed_total else "Removed: None")
print(f"Unchanged area: {unchanged_total.area:.10f}")

# Compute per-district properties
# For each district (by ref), get its current and 2014 polygon
# Determine which parts are added, removed, unchanged

features = []

# Check all district refs (union of both)
all_refs = sorted(set(list(current_districts.keys()) + list(old_districts.keys())), key=int)

for ref in all_refs:
    cur_info = current_districts.get(ref)
    old_info = old_districts.get(ref)
    
    # Use current name if available, else 2014 name
    if cur_info:
        name = cur_info['name']
        cur_poly = cur_info['polygon']
    else:
        name = old_info['name']
        cur_poly = None
    
    if old_info:
        old_poly = old_info['polygon']
    else:
        old_poly = None
    
    # Compute district-level changes
    district_added = None
    district_removed = None
    district_unchanged = None
    
    if cur_poly:
        # What's in this district's current area
        if added_total and not added_total.is_empty:
            district_added = cur_poly.intersection(added_total)
            district_added = clean_geom(district_added)
        district_unchanged = cur_poly.intersection(unchanged_total)
        district_unchanged = clean_geom(district_unchanged)
    
    if old_poly:
        if removed_total and not removed_total.is_empty:
            district_removed = old_poly.intersection(removed_total)
            district_removed = clean_geom(district_removed)
    
    # Does this district border any changed area?
    # Check if the district polygon (either current or old) touches added or removed areas
    touches_changed = False
    
    if cur_poly:
        if added_total and not added_total.is_empty:
            if cur_poly.touches(added_total) or cur_poly.intersects(added_total.buffer(BUF)):
                touches_changed = True
        if removed_total and not removed_total.is_empty:
            if cur_poly.touches(removed_total) or cur_poly.intersects(removed_total.buffer(BUF)):
                touches_changed = True
    
    if old_poly and not touches_changed:
        if added_total and not added_total.is_empty:
            if old_poly.touches(added_total) or old_poly.intersects(added_total.buffer(BUF)):
                touches_changed = True
        if removed_total and not removed_total.is_empty:
            if old_poly.touches(removed_total) or old_poly.intersects(removed_total.buffer(BUF)):
                touches_changed = True
    
    # Create features
    if district_unchanged:
        f = {
            "type": "Feature",
            "geometry": mapping(district_unchanged),
            "properties": {
                "change_type": "unchanged",
                "district_name": name,
                "touches_changed": touches_changed
            }
        }
        features.append(f)
    
    if district_added:
        f = {
            "type": "Feature",
            "geometry": mapping(district_added),
            "properties": {
                "change_type": "added_since_2014",
                "district_name": name,
                "touches_changed": True
            }
        }
        features.append(f)
    
    if district_removed:
        f = {
            "type": "Feature",
            "geometry": mapping(district_removed),
            "properties": {
                "change_type": "removed_since_2014",
                "district_name": name,
                "touches_changed": True
            }
        }
        features.append(f)

print(f"\nTotal features: {len(features)}")

# Build final GeoJSON
geojson = {
    "type": "FeatureCollection",
    "features": features
}

with open('/work/vienna_boundary_changes.geojson', 'w') as f:
    json.dump(geojson, f, indent=2)

print("Saved vienna_boundary_changes.geojson")

# Summary
stats = {}
for feat in features:
    ct = feat['properties']['change_type']
    stats[ct] = stats.get(ct, 0) + 1
print(f"By type: {stats}")

# Check touches_changed values
touches_stats = {}
for feat in features:
    tc = feat['properties']['touches_changed']
    touches_stats[tc] = touches_stats.get(tc, 0) + 1
print(f"By touches_changed: {touches_stats}")

# Print details
for feat in features:
    p = feat['properties']
    geom = shape(feat['geometry'])
    print(f"  {p['change_type']:20s} | {p['district_name']:25s} | touches={str(p['touches_changed']):5s} | area={geom.area:.10f}")
Wrote /work/solve_final.py (8962 chars).
13:30
Bash cd /work && timeout 120 python solve_final.py
Loading data...
Building district polygons...
Current districts: 23
2014 districts: 23

Added area: 0.0000019526
Removed area: 0.0000016865
Unchanged area: 0.0501965578

Total features: 41
Saved vienna_boundary_changes.geojson
By type: {'unchanged': 23, 'added_since_2014': 9, 'removed_since_2014': 9}
By touches_changed: {False: 14, True: 27}
  unchanged            | Innere Stadt              | touches=False | area=0.0003470665
  unchanged            | Leopoldstadt              | touches=False | area=0.0023271617
  unchanged            | Landstraße                | touches=False | area=0.0008959061
  unchanged            | Wieden                    | touches=False | area=0.0002148371
  unchanged            | Margareten                | touches=False | area=0.0002432927
  unchanged            | Mariahilf                 | touches=False | area=0.0001761840
  unchanged            | Neubau                    | touches=False | area=0.0001945226
  unchanged            | Josefstadt                | touches=False | area=0.0001318855
  unchanged            | Alsergrund                | touches=False | area=0.0003591304
  unchanged            | Favoriten                 | touches=True  | area=0.0038459194
  added_since_2014     | Favoriten                 | touches=True  | area=0.0000003801
  removed_since_2014   | Favoriten                 | touches=True  | area=0.0000002457
  unchanged            | Simmering                 | touches=True  | area=0.0028113669
  added_since_2014     | Simmering                 | touches=True  | area=0.0000003223
  removed_since_2014   | Simmering                 | touches=True  | area=0.0000000617
  unchanged            | Meidling                  | touches=False | area=0.0009796713
  unchanged            | Hietzing                  | touches=True  | area=0.0045602415
  added_since_2014     | Hietzing                  | touches=True  | area=0.0000000236
  removed_since_2014   | Hietzing                  | touches=True  | area=0.0000000354
  unchanged            | Penzing                   | touches=True  | area=0.0040855011
  added_since_2014     | Penzing                   | touches=True  | area=0.0000004106
  removed_since_2014   | Penzing                   | touches=True  | area=0.0000002790
  unchanged            | Rudolfsheim-Fünfhaus      | touches=False | area=0.0004738972
  unchanged            | Ottakring                 | touches=False | area=0.0010494819
  unchanged            | Hernals                   | touches=True  | area=0.0013795330
  added_since_2014     | Hernals                   | touches=True  | area=0.0000000304
  removed_since_2014   | Hernals                   | touches=True  | area=0.0000000584
  unchanged            | Währing                   | touches=False | area=0.0007683824
  unchanged            | Döbling                   | touches=True  | area=0.0030210545
  added_since_2014     | Döbling                   | touches=True  | area=0.0000002451
  removed_since_2014   | Döbling                   | touches=True  | area=0.0000000765
  unchanged            | Brigittenau               | touches=False | area=0.0006916144
  unchanged            | Floridsdorf               | touches=True  | area=0.0053845325
  added_since_2014     | Floridsdorf               | touches=True  | area=0.0000001654
  removed_since_2014   | Floridsdorf               | touches=True  | area=0.0000001111
  unchanged            | Donaustadt                | touches=True  | area=0.0123806994
  added_since_2014     | Donaustadt                | touches=True  | area=0.0000001223
  removed_since_2014   | Donaustadt                | touches=True  | area=0.0000003333
  unchanged            | Liesing                   | touches=True  | area=0.0038746758
  added_since_2014     | Liesing                   | touches=True  | area=0.0000002527
  removed_since_2014   | Liesing                   | touches=True  | area=0.0000004854
14:01
The results look reasonable - there are small changes along district boundaries between 2014 and now, which is expected due to mapping improvements and real boundary changes. The areas are tiny (in decimal degree units), which is normal for coordinate differences.

Let me verify the output and also make sure the `touches_changed` is correctly set for districts that border added/removed areas. Let me check if some districts that touch changes might be missed - like, any district that has `added_since_2014` or `removed_since_2014` should have `touches_changed=True` (already done), and any unchanged district that touches a changed area should also have `touches_changed=True`.

Looking at the output:
- Favoriten has added and removed, touches_changed=True ✓
- Simmering has added and removed, touches_changed=True ✓
- Hietzing has added and removed, touches_changed=True ✓
- Penzing has added and removed, touches_changed=True ✓
- Hernals has added and removed, touches_changed=True ✓
- Döbling has added and removed, touches_changed=True ✓
- Floridsdorf has added and removed, touches_changed=True ✓
- Donaustadt has added and removed, touches_changed=True ✓
- Liesing has added and removed, touches_changed=True ✓

And the unchanged districts that touch changes:
- Favoriten unchanged → touches=True ✓
- Simmering unchanged → touches=True ✓
- Hietzing unchanged → touches=True ✓
- Penzing unchanged → touches=True ✓
- Hernals unchanged → touches=True ✓
- Döbling unchanged → touches=True ✓
- Floridsdorf unchanged → touches=True ✓
- Donaustadt unchanged → touches=True ✓
- Liesing unchanged → touches=True ✓

That's correct because those districts have the added/removed parts.

But what about districts that DON'T have added/removed parts but border a district that does? For example, if Innere Stadt borders a district with changes... Actually, the added/removed parts are on the outer boundary of Vienna or between districts. Let me think about which districts DON'T have added/removed parts but might be adjacent to ones that do.

Actually, the way I've computed `touches_changed` - I'm checking if the district polygon touches the total added/removed areas. If an unchanged district shares a border with a changed area (e.g., the changed area is on the boundary between two districts), both districts would touch it. But since I'm only using `added_total` and `removed_total` (the sum of all changes), and they're typically small slivers along boundaries, a district might not actually touch the change if the change is entirely inside another district.

Let me re-examine: the added and removed areas come from `current_vienna.difference(old_vienna)` and vice versa. These represent the symmetric difference of the whole city outlines, so they could be along the outer boundary OR along internal administrative boundaries that changed.

Actually, since we're comparing Vienna's total boundary (union of all districts), any change in internal boundaries BETWEEN districts would not show up in the symmetric difference of the union! The union of all districts is the same regardless of how the internal boundaries are drawn.

Let me think more carefully:
- `current_vienna = union of all current district polygons`
- `old_vienna = union of all old district polygons`
- `added = current_vienna - old_vienna` → areas that are in current Vienna but not in old Vienna
- `removed = old_vienna - current_vienna` → areas that are in old Vienna but not in current Vienna

These only capture changes in the OUTER boundary of Vienna (expansion/contraction of the city limits), not internal administrative boundary changes.

For internal boundary changes, I need to compare each district with its 2014 counterpart individually.

Let me reconsider the approach. The task says "which parts of the district boundaries have changed between then and now" and to classify fragments as added_since_2014, removed_since_2014, or unchanged. This should capture ALL boundary changes, including when a boundary between two districts shifts.

So I think the correct approach is:
1. For each district, compare its current polygon with its 2014 polygon
2. The symmetric difference for each district pair gives:
   - added: area in current but not in 2014 (this district gained)
   - removed: area in 2014 but not in current (this district lost)
   - unchanged: area present in both

Then the `touches_changed` flag would indicate whether any part of the district's boundary (current or old) touches any changed area.

Let me redo this properly.
14:01
14:01
Write /work/solve_v2.py
content · 240 lines · py
import json
from shapely.geometry import Polygon, MultiPolygon, LineString, shape, mapping
from shapely.ops import unary_union, polygonize, linemerge

def build_polygon(rel):
    """Build a polygon from the relation's outer way geometries."""
    lines = []
    for m in rel.get('members', []):
        if m['role'] == 'outer' and 'geometry' in m:
            coords = [(pt['lon'], pt['lat']) for pt in m['geometry']]
            if len(coords) >= 2:
                lines.append(LineString(coords))
    if not lines:
        return None
    merged = linemerge(lines)
    if merged is None or merged.is_empty:
        return None
    if merged.geom_type == 'LineString':
        coords = list(merged.coords)
        if coords[0] != coords[-1]:
            coords.append(coords[0])
        return Polygon(coords)
    elif merged.geom_type == 'MultiLineString':
        polys = list(polygonize(merged.geoms))
        if not polys:
            return None
        if len(polys) == 1:
            return polys[0]
        polys_sorted = sorted(polys, key=lambda p: p.area, reverse=True)
        outer = polys_sorted[0]
        holes = []
        for p in polys_sorted[1:]:
            if p.area > 1e-10:
                centroid = p.representative_point()
                if outer.contains(centroid) or outer.touches(p):
                    holes.append(p.exterior.coords)
        if holes:
            return Polygon(outer.exterior.coords, holes)
        return outer
    return None

def to_multipolygon(geom):
    """Convert to MultiPolygon, return None if empty."""
    if geom is None or geom.is_empty:
        return None
    if geom.geom_type == 'Polygon':
        return MultiPolygon([geom])
    if geom.geom_type == 'MultiPolygon':
        return geom
    if geom.geom_type == 'GeometryCollection':
        polys = [g for g in geom.geoms if g.geom_type in ('Polygon', 'MultiPolygon')]
        if not polys:
            return None
        merged = unary_union(polys)
        return to_multipolygon(merged)
    return None

def clean_slivers(geom, min_area=1e-10):
    """Remove tiny sliver polygons."""
    if geom is None:
        return None
    geom = to_multipolygon(geom)
    if geom is None:
        return None
    polys = [p for p in geom.geoms if p.area >= min_area]
    if not polys:
        return None
    if len(polys) == 1:
        return MultiPolygon([polys[0]])
    return MultiPolygon(polys)

# Load data
print("Loading data...")
with open('/work/current_districts.json') as f:
    current_data = json.load(f)
with open('/work/districts_2014.json') as f:
    old_data = json.load(f)

print("Building district polygons...")
current_districts = {}
old_districts = {}

for el in current_data['elements']:
    if el['type'] == 'relation':
        ref = el.get('tags', {}).get('ref')
        name = el.get('tags', {}).get('name', '')
        poly = build_polygon(el)
        if poly:
            current_districts[ref] = {'name': name, 'polygon': poly}

for el in old_data['elements']:
    if el['type'] == 'relation':
        ref = el.get('tags', {}).get('ref')
        name = el.get('tags', {}).get('name', '')
        poly = build_polygon(el)
        if poly:
            old_districts[ref] = {'name': name, 'polygon': poly}

print(f"Current: {len(current_districts)}, 2014: {len(old_districts)}")

# Collect all refs
all_refs = sorted(set(list(current_districts.keys()) + list(old_districts.keys())), key=int)

# For each district, compute the symmetric difference between current and 2014
features = []
BUF = 1e-10

# Also compute overall changes for boundary detection
# Collect all changed areas (union of all per-district changes)
all_changed_areas = []

for ref in all_refs:
    cur_info = current_districts.get(ref)
    old_info = old_districts.get(ref)
    
    if cur_info:
        cur_name = cur_info['name']
        cur_poly = cur_info['polygon']
    else:
        cur_name = old_info['name']
        cur_poly = None
    
    if old_info:
        old_poly = old_info['polygon']
    else:
        old_poly = None
    
    if cur_poly and old_poly:
        # Normalise names - use the normalised name (current name)
        name = cur_info['name']
        
        # Compute differences
        added = cur_poly.difference(old_poly)
        removed = old_poly.difference(cur_poly)
        unchanged = cur_poly.intersection(old_poly)
        
        added = clean_slivers(added)
        removed = clean_slivers(removed)
        unchanged = clean_slivers(unchanged)
        
        if added:
            all_changed_areas.append(added)
        if removed:
            all_changed_areas.append(removed)
        
        touches = False  # Will compute later
        if added:
            features.append({
                "type": "Feature",
                "geometry": mapping(added),
                "properties": {"change_type": "added_since_2014", "district_name": name, "touches_changed": False}
            })
            touches = True
        if removed:
            features.append({
                "type": "Feature",
                "geometry": mapping(removed),
                "properties": {"change_type": "removed_since_2014", "district_name": name, "touches_changed": False}
            })
            touches = True
        if unchanged:
            features.append({
                "type": "Feature",
                "geometry": mapping(unchanged),
                "properties": {"change_type": "unchanged", "district_name": name, "touches_changed": False}
            })
    elif cur_poly and not old_poly:
        # New district (existed in current but not 2014 - unlikely for Vienna)
        name = cur_info['name']
        features.append({
            "type": "Feature",
            "geometry": mapping(to_multipolygon(cur_poly)),
            "properties": {"change_type": "added_since_2014", "district_name": name, "touches_changed": False}
        })
        all_changed_areas.append(to_multipolygon(cur_poly))
    elif old_poly and not cur_poly:
        # District removed (existed in 2014 but not current)
        name = old_info['name']
        features.append({
            "type": "Feature",
            "geometry": mapping(to_multipolygon(old_poly)),
            "properties": {"change_type": "removed_since_2014", "district_name": name, "touches_changed": False}
        })
        all_changed_areas.append(to_multipolygon(old_poly))

print(f"Features before touches: {len(features)}")

# Compute the union of all changed areas
if all_changed_areas:
    changed_union = unary_union(all_changed_areas)
    changed_union = clean_slivers(changed_union)
else:
    changed_union = None

print(f"Changed union: {changed_union.area if changed_union else 0}")

# Now set touches_changed for each feature
for feat in features:
    p = feat['properties']
    geom = shape(feat['geometry'])
    
    if p['change_type'] in ('added_since_2014', 'removed_since_2014'):
        p['touches_changed'] = True
    else:
        # unchanged - check if this geometry touches any changed area
        if changed_union and not changed_union.is_empty:
            # Buffer slightly for robustness
            touches = geom.buffer(BUF).intersects(changed_union.buffer(BUF))
            p['touches_changed'] = bool(touches)
        else:
            p['touches_changed'] = False

# Build GeoJSON
geojson = {
    "type": "FeatureCollection",
    "features": features
}

with open('/work/vienna_boundary_changes.geojson', 'w') as f:
    json.dump(geojson, f, indent=2)

print("Saved vienna_boundary_changes.geojson")

# Summary
stats = {}
for feat in features:
    ct = feat['properties']['change_type']
    stats[ct] = stats.get(ct, 0) + 1
print(f"By type: {stats}")

touches_stats = {}
for feat in features:
    tc = feat['properties']['touches_changed']
    touches_stats[tc] = touches_stats.get(tc, 0) + 1
print(f"By touches_changed: {touches_stats}")

for feat in features:
    p = feat['properties']
    g = shape(feat['geometry'])
    print(f"  {p['change_type']:20s} | {p['district_name']:25s} | touches={str(p['touches_changed']):5s} | area={g.area:.12f}")
Wrote /work/solve_v2.py (8098 chars).