Skip to content

Commit a6940e6

Browse files
committed
Add ISTAT comune-coordinates validation as a second data source (v0.4.0)
New geocoding.py module fetches and caches an ISTAT-derived comune->(lat, lon) reference (opendatasicilia/comuni-italiani, 97.5% match against MIMIT). In query_stations: stations whose stored coord deviates >30km from their declared comune's true location are now flagged coordinate_suspect (works for single- station comuni that the centroid heuristic could not reach), and --near additionally excludes stations whose declared comune is outside radius_km+30km of the query point. The original "Agip Tankstelle Rasen" case no longer appears in --near 46.498,11.354 --radius 6. --no-comune-validate disables.
1 parent 7bc442f commit a6940e6

9 files changed

Lines changed: 183 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
55
to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.4.0] - 2026-05-30
8+
9+
### Added
10+
- **Second data source: Italian comune coordinates.** New `geocoding.py` module
11+
fetches and caches an ISTAT-derived comune→(lat, lon) reference
12+
(opendatasicilia/comuni-italiani) with 30-day cache and graceful fallback if
13+
the upstream is unreachable. Achieves 97.5% comune-name match against MIMIT.
14+
- Comune validation in `query_stations`:
15+
- flags `coordinate_suspect` when a station's stored coord is more than 30 km
16+
from its declared comune's **true** location — this catches single-station
17+
comuni (the original Rasen case) that the self-contained centroid method
18+
provably could not.
19+
- in `--near`, also excludes stations whose declared comune is outside
20+
`radius_km + 30 km` of the query point; "Agip Tankstelle Rasen" no longer
21+
appears in `--near 46.498,11.354 --radius 6`.
22+
- `--no-comune-validate` flag to skip the second source (offline/perf).
23+
24+
### Known limitations
25+
- 2.5% of MIMIT comune strings don't match the reference (mostly garbage in the
26+
comune field, recent comune mergers, minor spelling). The self-contained
27+
centroid heuristic still applies to those.
28+
- The upstream comune dataset has no explicit LICENSE in its repo, though the
29+
underlying data is ISTAT-derived. `pitstop` runtime-fetches only and credits
30+
the source — if pitstop ever goes public, prefer a Wikidata-bundled file.
31+
732
## [0.3.0] - 2026-05-29
833

934
### Added
@@ -87,6 +112,7 @@ Initial release.
87112
- `--fuel` is a substring match, so `Gasolio` also matches variants such as
88113
`Gasolio Alpino`.
89114

115+
[0.4.0]: https://github.com/galjos/pitstop/releases/tag/v0.4.0
90116
[0.3.0]: https://github.com/galjos/pitstop/releases/tag/v0.3.0
91117
[0.2.0]: https://github.com/galjos/pitstop/releases/tag/v0.2.0
92118
[0.1.1]: https://github.com/galjos/pitstop/releases/tag/v0.1.1

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Unofficial community project. Not affiliated with or endorsed by MIMIT. Fuel dat
1616
- **Known caveats:**
1717
- Some operators report placeholder values (e.g. `1.000`); use `--min-price` (e.g. `1.2`) to drop them.
1818
- Some price records are **stale** (a few were last updated years ago); use `--fresh-within-days` and check the `UPDATED` column / `updated` field.
19-
- Some stations are **mis-geocoded** in the registry. v0.3.0 annotates stations >30 km from their comune's median (or outside Italy) as `coordinate_suspect` (`*` in the table), and `--near` skips coordinates outside the Italy bounding box. Single-station comuni cannot be validated this way and need a second source (planned).
19+
- Some stations are **mis-geocoded** in the registry. As of v0.4.0 `pitstop` joins a second data source (ISTAT-derived comune coordinates from [opendatasicilia/comuni-italiani](https://github.com/opendatasicilia/comuni-italiani), 97.5% match) to validate each station's coordinate against its declared comune's *true* location. Stations >30 km off are flagged `coordinate_suspect` (`*` in the table), and `--near` excludes stations whose declared comune is geographically too far from the query point — even single-station comuni like RASUN-ANTERSELVA. Pass `--no-comune-validate` to disable.
2020

2121
## Install
2222

@@ -87,13 +87,17 @@ pytest -q
8787

8888
## Status & roadmap
8989

90-
v0.3.0 — fuel-price core (registry+price join, filters, proximity, cheapest, `--min-price` floor, `--fresh-within-days` freshness filter, `coordinate_suspect` geocoding signal, JSON), an MCP server, a Claude skill, tests, and CI.
90+
v0.4.0 — fuel-price core (registry+price join, filters, proximity, cheapest, `--min-price` floor, `--fresh-within-days` freshness filter, ISTAT-derived comune-coordinate validation, JSON), an MCP server, a Claude skill, tests, and CI.
9191

9292
Planned, roughly in order:
93-
- a **second data source** of Italian comune coordinates (e.g. ISTAT) to validate single-station-comune coordinates that the self-contained method can't reach;
9493
- **EV charging** (locations via Open Charge Map; prices via the AFIR National Access Point / DATEX II as that data matures);
9594
- additional countries behind a per-country source adapter (e.g. Germany Tankerkönig, France/Spain official feeds).
9695

96+
## Data sources & attributions
97+
98+
- Fuel stations and prices: **MIMIT Osservaprezzi Carburanti** open data.
99+
- Comune coordinates (validation): **opendatasicilia/comuni-italiani** (ISTAT-derived).
100+
97101
## Links
98102

99103
- MIMIT fuel open data: https://www.mimit.gov.it/it/open-data/elenco-dataset/carburanti-prezzi-praticati-e-anagrafica-degli-impianti

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pitstop"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "Unofficial JSON-first CLI for Italian fuel-station prices (MIMIT Osservaprezzi Carburanti open data)."
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎skills/pitstop/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@ Exit codes: `0` ok, `1` runtime error (e.g. network), `2` usage error.
4747
- **`--fuel` is a substring match** — `Gasolio` also matches `Gasolio Premium`, `Gasolio Oro Diesel`, etc. Use `pitstop fuels` to pick exact names; inspect the `fuel` field in results.
4848
- **Placeholder prices** — some operators report junk values like `1.000`; with `--cheapest` these can rank first. Pass `--min-price 1.2` (petrol/diesel) to drop them; use a lower floor for cheaper fuels like GPL. Still sanity-check the lowest result.
4949
- **Stale prices** — some records were last updated years ago and may be far below the current market. Pass `--fresh-within-days 90` and check the `updated` field before reporting a price as current.
50-
- **Mis-geocoded stations** — a few registry coordinates are wrong. Results carry a `coordinate_suspect: true` flag (table marker `*`) when a station is far from its comune's other stations or outside Italy. When you see it, treat the result as low-confidence and double-check the `comune`/`address`. **Single-station comuni cannot be flagged** with the current method, so for `--near` results in remote/small comuni, always sanity-check the comune name against the user's query.
50+
- **Mis-geocoded stations** — some registry coordinates are wrong. `pitstop` now joins an ISTAT-derived comune-coordinates reference (97.5% coverage): a `coordinate_suspect: true` flag (table marker `*`) appears when a station's coord is far from its declared comune's true location, and `--near` excludes stations whose declared comune is geographically too far from the query point. Even single-station comuni are now caught. Still: when you see `coordinate_suspect`, treat as low-confidence and surface the `address`/`comune` to the user.

‎src/pitstop/cli.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def _build_parser() -> argparse.ArgumentParser:
6363
help="drop prices below this floor (e.g. 1.2 to skip placeholder values); 0 = off")
6464
stations.add_argument("--fresh-within-days", dest="fresh_days", type=int, default=0,
6565
help="drop prices last updated more than N days ago; 0 = off")
66+
stations.add_argument("--no-comune-validate", dest="validate_comune", action="store_false",
67+
help="skip validating coordinates against the comune-coords reference")
6668
stations.add_argument("--limit", type=int, default=20, help="max stations; 0 = no limit")
6769
stations.set_defaults(func=_cmd_stations)
6870

@@ -121,6 +123,7 @@ def _cmd_stations(args) -> int:
121123
cheapest=args.cheapest,
122124
min_price=args.min_price,
123125
max_age_days=args.fresh_days,
126+
validate_comune=args.validate_comune,
124127
limit=args.limit,
125128
)
126129

‎src/pitstop/core.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,19 @@ def query_stations(
284284
min_price: float = 0.0,
285285
max_age_days: int = 0,
286286
limit: int = 20,
287+
validate_comune: bool = True,
288+
comune_coords: dict[str, tuple[float, float]] | None = None,
287289
) -> list[Station]:
288290
"""Filter, sort, and limit stations. Mutates the dataset's Station objects
289291
(narrows prices, sets distance_km), so pass a freshly loaded Dataset."""
290292
today = date.today() if max_age_days > 0 else None
291293
centroids = comune_centroids(ds)
294+
if validate_comune and comune_coords is None:
295+
from . import geocoding
296+
comune_coords = geocoding.load_comune_coords()
297+
elif comune_coords is None:
298+
comune_coords = {}
299+
292300
out: list[Station] = []
293301
for st in ds.stations.values():
294302
if comune and st.comune.casefold() != comune.strip().casefold():
@@ -305,11 +313,15 @@ def query_stations(
305313
continue
306314
st.prices = prices
307315

308-
# Flag coordinates that are implausible or far from the station's
309-
# comune-cluster. Single-station comuni cannot be validated this way
310-
# and remain unflagged — a known limitation requiring a second source.
316+
# Flag coordinates that are implausible or far from where they should be.
317+
# Prefer the true ISTAT-derived comune coord when available (works for
318+
# single-station comuni); fall back to the in-data comune centroid.
319+
true_coord = comune_coords.get(st.comune.upper()) if comune_coords else None
311320
if not in_italy(st.lat, st.lon):
312321
st.coordinate_suspect = True
322+
elif true_coord is not None:
323+
if haversine_km(true_coord[0], true_coord[1], st.lat, st.lon) > SUSPECT_DISTANCE_KM:
324+
st.coordinate_suspect = True
313325
else:
314326
c = centroids.get(st.comune.upper())
315327
if c is not None and haversine_km(c[0], c[1], st.lat, st.lon) > SUSPECT_DISTANCE_KM:
@@ -318,6 +330,13 @@ def query_stations(
318330
if near is not None:
319331
if not in_italy(st.lat, st.lon):
320332
continue # invalid coords cannot be reliably near anything
333+
# Reject stations whose declared comune is geographically too far
334+
# from the query point — even if the stored coordinate happens to
335+
# land close (the Rasen case). Tolerance pads for large comuni.
336+
if true_coord is not None:
337+
comune_dist = haversine_km(near[0], near[1], true_coord[0], true_coord[1])
338+
if comune_dist > radius_km + 30.0:
339+
continue
321340
d = haversine_km(near[0], near[1], st.lat, st.lon)
322341
if d > radius_km:
323342
continue

‎src/pitstop/geocoding.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Second data source: authoritative Italian comune coordinates, used to
2+
validate MIMIT station coordinates. Self-contained centroid heuristics in
3+
`core` cannot catch mis-geocoded stations in single-station comuni (e.g.
4+
RASUN-ANTERSELVA), so a true comune→(lat, lon) reference is required.
5+
6+
Source: opendatasicilia/comuni-italiani `main.csv`, derived from ISTAT.
7+
Runtime fetch + local cache; no redistribution."""
8+
9+
from __future__ import annotations
10+
11+
import csv
12+
import os
13+
import sys
14+
import time
15+
import urllib.error
16+
import urllib.request
17+
from pathlib import Path
18+
19+
COMUNI_URL = (
20+
"https://raw.githubusercontent.com/opendatasicilia/comuni-italiani/main/dati/main.csv"
21+
)
22+
COMUNI_SOURCE_NAME = "opendatasicilia/comuni-italiani (ISTAT-derived)"
23+
24+
DEFAULT_COMUNI_MAX_AGE = 30 * 24 * 60 * 60 # 30 days; comuni change rarely
25+
DEFAULT_TIMEOUT = 180
26+
27+
28+
def _cache_dir() -> Path:
29+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
30+
d = Path(base) / "pitstop"
31+
d.mkdir(parents=True, exist_ok=True)
32+
return d
33+
34+
35+
def normalize_comune(name: str) -> str:
36+
"""Uppercase, trim, collapse internal whitespace. MIMIT uses uppercase
37+
names; opendatasicilia uses capitalized — uppercase makes both match."""
38+
return " ".join(name.strip().upper().split())
39+
40+
41+
def _cached_path(refresh: bool, max_age: int, timeout: int) -> Path | None:
42+
path = _cache_dir() / "comuni_main.csv"
43+
if not refresh and path.exists():
44+
if max_age <= 0 or (time.time() - path.stat().st_mtime) < max_age:
45+
return path
46+
try:
47+
req = urllib.request.Request(COMUNI_URL, headers={"User-Agent": "pitstop"})
48+
with urllib.request.urlopen(req, timeout=timeout) as resp:
49+
data = resp.read()
50+
except (urllib.error.URLError, OSError) as e:
51+
# Graceful fallback: if we cannot fetch, return any stale cache or None.
52+
print(f"pitstop: could not fetch comune coordinates ({e}); "
53+
f"falling back to self-contained heuristics", file=sys.stderr)
54+
return path if path.exists() else None
55+
tmp = path.with_suffix(path.suffix + ".tmp")
56+
tmp.write_bytes(data)
57+
tmp.replace(path)
58+
return path
59+
60+
61+
def load_comune_coords(
62+
*,
63+
refresh: bool = False,
64+
max_age: int = DEFAULT_COMUNI_MAX_AGE,
65+
timeout: int = DEFAULT_TIMEOUT,
66+
) -> dict[str, tuple[float, float]]:
67+
"""Return {normalized_comune_name: (lat, lon)}. Empty dict on fetch failure
68+
with no cache, so callers should treat it as best-effort."""
69+
path = _cached_path(refresh, max_age, timeout)
70+
if path is None:
71+
return {}
72+
return _parse_comuni(path)
73+
74+
75+
def _parse_comuni(path: Path) -> dict[str, tuple[float, float]]:
76+
out: dict[str, tuple[float, float]] = {}
77+
with path.open("r", encoding="utf-8", errors="replace", newline="") as f:
78+
reader = csv.DictReader(f)
79+
for row in reader:
80+
name = row.get("comune", "").strip()
81+
if not name:
82+
continue
83+
try:
84+
lat = float(row["lat"])
85+
lon = float(row["long"])
86+
except (KeyError, ValueError, TypeError):
87+
continue
88+
out[normalize_comune(name)] = (lat, lon)
89+
return out

‎src/pitstop/version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.0"
1+
__version__ = "0.4.0"

‎tests/test_core.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,39 @@ def st(sid, lat, lon):
172172
assert flagged == {"1": False, "2": False, "3": False, "4": True}
173173

174174

175+
def test_query_stations_uses_true_comune_coord_to_flag_single_station_comune():
176+
# Single-station comune: centroid heuristic can't help; only the true
177+
# comune coord (from the second source) can flag it.
178+
bad = core.Station("X", "", "", "", "Rasen", "", "RASUN-ANTERSELVA", "BZ",
179+
46.4545, 11.3188, # registry-stored (wrong)
180+
[core.Price("Gasolio", 2.115, True, "2026-05-27T00:00:00")])
181+
ds = core.Dataset(stations={"X": bad}, registry_date="2026-05-28", price_date="2026-05-28")
182+
coords = {"RASUN-ANTERSELVA": (46.839, 12.112)} # true coord
183+
out = core.query_stations(ds, comune_coords=coords)
184+
assert out[0].coordinate_suspect is True
185+
# Without comune validation, single-station comune isn't flagged.
186+
bad.coordinate_suspect = False
187+
out2 = core.query_stations(ds, validate_comune=False, comune_coords={})
188+
assert out2[0].coordinate_suspect is False
189+
190+
191+
def test_query_stations_near_rejects_far_comune():
192+
# The Rasen case: stored coord lands ~5km from Bolzano so it passes the
193+
# radius check, but the declared comune's true location is ~50km away ->
194+
# excluded by the comune sanity check.
195+
rasen = core.Station("R", "", "", "", "Rasen", "", "RASUN-ANTERSELVA", "BZ",
196+
46.4545, 11.3188,
197+
[core.Price("Gasolio", 1.749, True, "2026-05-27T00:00:00")])
198+
bolzano = core.Station("B", "", "", "", "BZ Station", "", "BOLZANO", "BZ",
199+
46.498, 11.354,
200+
[core.Price("Gasolio", 2.0, True, "2026-05-27T00:00:00")])
201+
ds = core.Dataset(stations={"R": rasen, "B": bolzano},
202+
registry_date="2026-05-28", price_date="2026-05-28")
203+
coords = {"RASUN-ANTERSELVA": (46.839, 12.112), "BOLZANO": (46.498, 11.354)}
204+
out = core.query_stations(ds, near=(46.498, 11.354), radius_km=6, comune_coords=coords)
205+
assert [s.id for s in out] == ["B"]
206+
207+
175208
def test_query_stations_skips_invalid_coords_for_near():
176209
bad = core.Station("X", "", "", "", "S", "", "NOWHERE", "ZZ", 0.0, 0.0,
177210
[core.Price("Benzina", 2.0, True, "2026-05-27T00:00:00")])

0 commit comments

Comments
 (0)