geo-data-audit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
geo_audit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """geo-audit: Audit heterogeneous geospatial datasets."""
2
+
3
+ __version__ = "0.1.0"
geo_audit/cli.py ADDED
@@ -0,0 +1,205 @@
1
+ """Main CLI entry point."""
2
+
3
+ import sys
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from geo_audit.coordinates import detect_coordinate_columns, detect_planar_coordinates
11
+ from geo_audit.discovery import discover_files, get_file_size
12
+ from geo_audit.findings import (
13
+ build_audit_summary,
14
+ compute_dataset_readiness,
15
+ generate_findings,
16
+ )
17
+ from geo_audit.loaders import (
18
+ calculate_bounding_box,
19
+ calculate_bounding_box_from_coords,
20
+ infer_geometry_types,
21
+ load_dataset,
22
+ profile_columns,
23
+ )
24
+ from geo_audit.models import (
25
+ AuditReport,
26
+ DatasetInfo,
27
+ FileFormat,
28
+ )
29
+ from geo_audit.quality import detect_exact_duplicates
30
+ from geo_audit.reporting import generate_json_report, generate_terminal_report
31
+ from geo_audit.spatial import compute_cross_dataset_overlaps
32
+
33
+ APP_HELP = (
34
+ "Audit heterogeneous geospatial datasets for data-quality "
35
+ "and spatial-consistency problems."
36
+ )
37
+
38
+ app = typer.Typer(
39
+ name="geo-audit",
40
+ help=APP_HELP,
41
+ add_completion=False,
42
+ )
43
+
44
+ # Separate console for progress (stderr) and report (stdout)
45
+ progress_console = Console(
46
+ stderr=True, force_terminal=True, color_system="standard", legacy_windows=False
47
+ )
48
+
49
+
50
+ @app.command()
51
+ def audit(
52
+ input_dir: Path = typer.Argument(..., help="Directory containing geospatial datasets"),
53
+ output: Path | None = typer.Option(
54
+ None, "--output", "-o", help="Output file path (only with --format json)"
55
+ ),
56
+ format: str = typer.Option("terminal", "--format", "-f", help="Output format: terminal, json"),
57
+ ):
58
+ """Audit geospatial datasets in a directory."""
59
+ if not input_dir.exists() or not input_dir.is_dir():
60
+ progress_console.print(f"[red]Error: {input_dir} is not a valid directory[/red]")
61
+ raise typer.Exit(1)
62
+
63
+ if output and format != "json":
64
+ progress_console.print("[red]Error: --output only works with --format json[/red]")
65
+ raise typer.Exit(1)
66
+
67
+ datasets = []
68
+
69
+ for file_path, file_format in discover_files(input_dir):
70
+ if format == "terminal":
71
+ progress_console.print(f"[dim]Processing {file_path.name}...[/dim]")
72
+ ds_info = process_dataset(file_path, file_format)
73
+ datasets.append(ds_info)
74
+
75
+ if not datasets:
76
+ progress_console.print("[yellow]No supported datasets found[/yellow]")
77
+ raise typer.Exit(0)
78
+
79
+ overlaps = compute_cross_dataset_overlaps(datasets)
80
+
81
+ # Generate findings and readiness
82
+ findings = generate_findings(datasets)
83
+ dataset_readiness = compute_dataset_readiness(datasets, findings)
84
+ summary = build_audit_summary(datasets, findings)
85
+
86
+ report = AuditReport(
87
+ datasets=datasets,
88
+ cross_dataset_overlaps=overlaps,
89
+ summary=summary.to_dict(),
90
+ generated_at=datetime.now().isoformat(),
91
+ findings=findings,
92
+ dataset_readiness=dataset_readiness,
93
+ )
94
+
95
+ if format == "json":
96
+ json_output = generate_json_report(report)
97
+ if output:
98
+ output.write_text(json_output)
99
+ # Don't print success message to stdout in JSON mode
100
+ else:
101
+ # Print JSON to stdout (only JSON, no progress messages)
102
+ sys.stdout.write(json_output + "\n")
103
+ else:
104
+ generate_terminal_report(report)
105
+
106
+
107
+ def process_dataset(file_path: Path, file_format: FileFormat) -> DatasetInfo:
108
+ """Process a single dataset and return DatasetInfo."""
109
+ file_size = get_file_size(file_path)
110
+ data, parse_errors = load_dataset(file_path, file_format)
111
+
112
+ if data is None or (hasattr(data, 'empty') and data.empty):
113
+ return DatasetInfo(
114
+ filename=file_path.name,
115
+ format=file_format,
116
+ row_count=0,
117
+ columns=[],
118
+ column_profiles=[],
119
+ file_size_bytes=file_size,
120
+ parse_errors=parse_errors,
121
+ )
122
+
123
+ if file_format == FileFormat.CSV:
124
+ columns = list(data.columns)
125
+ column_profiles = profile_columns(data)
126
+ row_count = len(data)
127
+
128
+ coord_info = detect_coordinate_columns(data)
129
+ planar_info = detect_planar_coordinates(data)
130
+
131
+ duplicate_count = 0
132
+ duplicate_pct = 0.0
133
+ if coord_info.lat_column and coord_info.lon_column:
134
+ duplicate_count, duplicate_pct = detect_exact_duplicates(
135
+ data, coord_info.lat_column, coord_info.lon_column
136
+ )
137
+
138
+ bounding_box = None
139
+ if coord_info.lat_column and coord_info.lon_column:
140
+ bounding_box = calculate_bounding_box_from_coords(
141
+ data, coord_info.lat_column, coord_info.lon_column
142
+ )
143
+
144
+ # Warnings kept for backward compatibility in DatasetInfo
145
+ warnings = []
146
+ if coord_info.lat_column and coord_info.lon_column:
147
+ if coord_info.missing_percentage > 5:
148
+ warnings.append(f"{coord_info.missing_percentage:.1f}% missing coordinates")
149
+ if coord_info.invalid_percentage > 0:
150
+ warnings.append(f"{coord_info.invalid_percentage:.1f}% invalid coordinates")
151
+ if duplicate_pct > 1:
152
+ warnings.append(f"{duplicate_pct:.1f}% exact duplicate locations")
153
+ if planar_info:
154
+ if planar_info.missing_percentage > 5:
155
+ msg = f"Planar coordinates ({planar_info.x_column}/{planar_info.y_column}): "
156
+ msg += f"{planar_info.missing_percentage:.1f}% missing"
157
+ warnings.append(msg)
158
+
159
+ return DatasetInfo(
160
+ filename=file_path.name,
161
+ format=file_format,
162
+ row_count=row_count,
163
+ columns=columns,
164
+ column_profiles=column_profiles,
165
+ file_size_bytes=file_size,
166
+ coordinate_info=coord_info,
167
+ planar_coordinate_info=planar_info,
168
+ bounding_box=bounding_box,
169
+ duplicate_count=duplicate_count,
170
+ duplicate_percentage=duplicate_pct,
171
+ parse_errors=parse_errors,
172
+ warnings=warnings,
173
+ )
174
+
175
+ else:
176
+ columns = [c for c in data.columns if c != 'geometry']
177
+ non_geom = data.drop(columns=['geometry']) if 'geometry' in data.columns else data
178
+ column_profiles = profile_columns(non_geom)
179
+ row_count = len(data)
180
+
181
+ geometry_types = infer_geometry_types(data)
182
+ crs = str(data.crs) if data.crs else None
183
+ bounding_box = calculate_bounding_box(data)
184
+
185
+ warnings = []
186
+ if crs is None:
187
+ warnings.append("No CRS declared in file (GeoJSON spec assumes WGS84)")
188
+
189
+ return DatasetInfo(
190
+ filename=file_path.name,
191
+ format=file_format,
192
+ row_count=row_count,
193
+ columns=columns,
194
+ column_profiles=column_profiles,
195
+ file_size_bytes=file_size,
196
+ geometry_types=geometry_types,
197
+ crs=crs,
198
+ bounding_box=bounding_box,
199
+ parse_errors=parse_errors,
200
+ warnings=warnings,
201
+ )
202
+
203
+
204
+ if __name__ == "__main__":
205
+ app()
@@ -0,0 +1,115 @@
1
+ """Coordinate detection and validation."""
2
+
3
+
4
+ import pandas as pd
5
+
6
+ from geo_audit.models import CoordinateInfo, PlanarCoordinateInfo
7
+
8
+ LAT_PATTERNS = [
9
+ "latitude", "lat", "lat_dd", "lat_ddm", "lat_dms",
10
+ "y_lat", "ycoord_lat", "gps_lat", "gps_latitude",
11
+ ]
12
+
13
+ LON_PATTERNS = [
14
+ "longitude", "lon", "lng", "long",
15
+ "lon_dd", "lon_ddm", "lon_dms",
16
+ "x_lon", "xcoord_lon", "gps_lon", "gps_longitude",
17
+ ]
18
+
19
+ PLANAR_PATTERNS = [
20
+ ("x", "y"),
21
+ ("x_coord", "y_coord"),
22
+ ("xcoordinate", "ycoordinate"),
23
+ ("easting", "northing"),
24
+ ]
25
+
26
+
27
+ def detect_coordinate_columns(df: pd.DataFrame) -> CoordinateInfo:
28
+ """Detect latitude/longitude columns in a DataFrame.
29
+
30
+ Only explicit latitude/longitude column names are treated as geographic coordinates.
31
+ Generic x/y or easting/northing columns are NOT assumed to be lat/lon.
32
+ """
33
+ cols_lower = {c.lower().strip(): c for c in df.columns}
34
+ info = CoordinateInfo()
35
+
36
+ lat_col = _find_column(cols_lower, LAT_PATTERNS)
37
+ lon_col = _find_column(cols_lower, LON_PATTERNS)
38
+
39
+ if lat_col and lon_col:
40
+ info.lat_column = lat_col
41
+ info.lon_column = lon_col
42
+ info.detection_method = "explicit_lat_lon"
43
+ info.confidence = 0.95
44
+ _validate_coordinates(df, info)
45
+
46
+ return info
47
+
48
+
49
+ def detect_planar_coordinates(df: pd.DataFrame) -> PlanarCoordinateInfo | None:
50
+ """Detect planar coordinate columns (x/y, easting/northing).
51
+
52
+ These are NOT validated as lat/lon since their CRS is unknown.
53
+ Returns info about the columns if found.
54
+ """
55
+ cols_lower = {c.lower().strip(): c for c in df.columns}
56
+
57
+ for x_pat, y_pat in PLANAR_PATTERNS:
58
+ x_col = _find_column(cols_lower, [x_pat])
59
+ y_col = _find_column(cols_lower, [y_pat])
60
+ if x_col and y_col:
61
+ total = len(df)
62
+ missing = int(df[x_col].isna().sum() | df[y_col].isna().sum())
63
+ return PlanarCoordinateInfo(
64
+ x_column=x_col,
65
+ y_column=y_col,
66
+ detection_method="planar_heuristic",
67
+ count=total,
68
+ missing_count=missing,
69
+ )
70
+ return None
71
+
72
+
73
+ def _find_column(cols_lower: dict[str, str], patterns: list[str]) -> str | None:
74
+ """Find a column matching any of the patterns."""
75
+ for pat in patterns:
76
+ if pat in cols_lower:
77
+ return cols_lower[pat]
78
+ return None
79
+
80
+
81
+ def _validate_coordinates(df: pd.DataFrame, info: CoordinateInfo) -> None:
82
+ """Validate coordinate values as lat/lon."""
83
+ lat_col = info.lat_column
84
+ lon_col = info.lon_column
85
+
86
+ if not lat_col or not lon_col:
87
+ return
88
+
89
+ total = len(df)
90
+ info.total_count = total
91
+
92
+ lat_series = pd.to_numeric(df[lat_col], errors="coerce")
93
+ lon_series = pd.to_numeric(df[lon_col], errors="coerce")
94
+
95
+ missing_mask = lat_series.isna() | lon_series.isna()
96
+ info.missing_count = int(missing_mask.sum())
97
+
98
+ valid_rows_lat = lat_series[~missing_mask]
99
+ valid_rows_lon = lon_series[~missing_mask]
100
+ if valid_rows_lat.empty:
101
+ info.valid_count = 0
102
+ info.invalid_count = 0
103
+ return
104
+
105
+ lat_valid = (valid_rows_lat >= -90) & (valid_rows_lat <= 90)
106
+ lon_valid = (valid_rows_lon >= -180) & (valid_rows_lon <= 180)
107
+ both_valid = lat_valid & lon_valid
108
+
109
+ info.valid_count = int(both_valid.sum())
110
+ info.invalid_count = int((~both_valid).sum())
111
+
112
+
113
+ def validate_lat_lon(lat: float, lon: float) -> bool:
114
+ """Check if lat/lon values are valid."""
115
+ return -90 <= lat <= 90 and -180 <= lon <= 180
geo_audit/discovery.py ADDED
@@ -0,0 +1,40 @@
1
+ """Dataset discovery module."""
2
+
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+
6
+ from geo_audit.models import FileFormat
7
+
8
+ SUPPORTED_EXTENSIONS = {
9
+ ".csv": FileFormat.CSV,
10
+ ".geojson": FileFormat.GEOJSON,
11
+ ".json": FileFormat.GEOJSON,
12
+ }
13
+
14
+
15
+ def discover_files(root: Path) -> Iterator[tuple[Path, FileFormat]]:
16
+ """Recursively discover supported files in a directory."""
17
+ if not root.exists():
18
+ raise FileNotFoundError(f"Path does not exist: {root}")
19
+ if not root.is_dir():
20
+ raise NotADirectoryError(f"Path is not a directory: {root}")
21
+
22
+ for path in root.rglob("*"):
23
+ if path.is_file():
24
+ ext = path.suffix.lower()
25
+ if ext in SUPPORTED_EXTENSIONS:
26
+ yield path, SUPPORTED_EXTENSIONS[ext]
27
+
28
+
29
+ def get_file_format(path: Path) -> FileFormat:
30
+ """Determine file format from extension."""
31
+ ext = path.suffix.lower()
32
+ return SUPPORTED_EXTENSIONS.get(ext, FileFormat.UNKNOWN)
33
+
34
+
35
+ def get_file_size(path: Path) -> int:
36
+ """Get file size in bytes."""
37
+ try:
38
+ return path.stat().st_size
39
+ except OSError:
40
+ return 0