irn-engine 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.
irn/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """IRN: Automated Road Network Modeling Engine for Lane-Free Indian Traffic Simulations."""
2
+
3
+ __version__ = "0.1.0"
irn/cli.py ADDED
@@ -0,0 +1,406 @@
1
+ """IRN Command-Line Interface (CLI) orchestrated via Typer."""
2
+
3
+ from __future__ import annotations
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import typer
8
+
9
+ from irn.common.logger import log_event, logger
10
+
11
+ app = typer.Typer(
12
+ name="irn",
13
+ help="IRN: Automated Road Network Modeling Engine for Lane-Free Indian Traffic Simulations",
14
+ add_completion=False
15
+ )
16
+
17
+
18
+ @app.command()
19
+ def info() -> None:
20
+ """Display engine environment, dependencies, and CRS projection info."""
21
+ import osmnx as ox
22
+ import pyarrow as pa
23
+ import shapely
24
+
25
+ typer.echo("=================================================================")
26
+ typer.echo("IRN: Road Network Modeling Engine (SVIIT Indore Major Project)")
27
+ typer.echo("=================================================================")
28
+ typer.echo(f"OSMnx Version: {ox.__version__}")
29
+ typer.echo(f"Shapely Version: {shapely.__version__}")
30
+ typer.echo(f"PyArrow Version: {pa.__version__}")
31
+ typer.echo("Traffic Rule: Strict Left-Hand Traffic (LHT)")
32
+ typer.echo("Coordinate Grid: WGS84 Storage (EPSG:4326) / Local Projected UTM")
33
+ typer.echo("=================================================================")
34
+
35
+
36
+ @app.command()
37
+ def run(
38
+ config: Path = typer.Option(
39
+ Path("configs/run_config.yaml"),
40
+ "--config",
41
+ help="Path to pipeline run_config.yaml"
42
+ ),
43
+ out_dir: Path = typer.Option(
44
+ Path("output_scenario"),
45
+ "--out",
46
+ help="Destination directory for scenario bundle"
47
+ ),
48
+ offline: bool = typer.Option(
49
+ True,
50
+ "--offline/--live",
51
+ help="Whether to use calibrated offline network fallback or query live Overpass API"
52
+ ),
53
+ ) -> None:
54
+ """Execute end-to-end IRN pipeline (M1 -> M2 -> M3 -> M4 -> Manifest)."""
55
+ from irn.orchestrator import PipelineOrchestrator
56
+
57
+ typer.secho("Starting End-to-End IRN Simulation Synthesis...", fg=typer.colors.CYAN)
58
+ orch = PipelineOrchestrator(config_path=config)
59
+ res = orch.run_end_to_end(output_dir=out_dir, use_offline_network=offline)
60
+
61
+ typer.secho(f"Scenario successfully synthesized at {out_dir}!", fg=typer.colors.GREEN)
62
+ report_text = orch.generate_report(out_dir)
63
+ typer.echo(report_text)
64
+
65
+
66
+ @app.command()
67
+ def report(
68
+ scenario: Path = typer.Option(
69
+ Path("output_scenario"),
70
+ "--scenario",
71
+ help="Path to compiled scenario directory"
72
+ ),
73
+ ) -> None:
74
+ """Display execution statistics and validation report for a scenario bundle."""
75
+ from irn.orchestrator import PipelineOrchestrator
76
+
77
+ orch = PipelineOrchestrator()
78
+ report_text = orch.generate_report(scenario)
79
+ typer.echo(report_text)
80
+
81
+
82
+ @app.command()
83
+ def fetch(
84
+ bbox: Optional[str] = typer.Option(
85
+ None,
86
+ "--bbox",
87
+ help="Bounding box as 'west,south,east,north' in WGS84 degrees (EPSG:4326)"
88
+ ),
89
+ place: Optional[str] = typer.Option(
90
+ None,
91
+ "--place",
92
+ help="OSM place query string (e.g. 'Gachibowli, Hyderabad, India')"
93
+ ),
94
+ out_dir: Path = typer.Option(
95
+ Path("data/d1_graph"),
96
+ "--out",
97
+ help="Destination directory for D1 artifacts (edges.gpkg, nodes.gpkg, osm_snapshot.json)"
98
+ ),
99
+ timeout: int = typer.Option(10, "--timeout", help="Overpass request timeout in seconds"),
100
+ offline: bool = typer.Option(False, "--offline", help="Generate calibrated offline network without external Overpass API request"),
101
+ ) -> None:
102
+ """Fetch drivable road network from OSM, project to local UTM, clean, and export Contract D1."""
103
+ from irn.m1_fetcher.osm_fetcher import OSMFetcher
104
+
105
+ fetcher = OSMFetcher(timeout=timeout)
106
+
107
+ if bbox:
108
+ try:
109
+ parts = [float(x.strip()) for x in bbox.split(",")]
110
+ if len(parts) != 4:
111
+ raise ValueError("Must provide 4 float values: west,south,east,north")
112
+ bbox_tuple = (parts[0], parts[1], parts[2], parts[3])
113
+ except Exception as e:
114
+ typer.secho(f"Invalid bbox parameter: {e}", fg=typer.colors.RED)
115
+ raise typer.Exit(code=1)
116
+
117
+ typer.secho(f"Acquiring network for bbox: {bbox_tuple}...", fg=typer.colors.CYAN)
118
+ if offline:
119
+ G = fetcher.generate_offline_grid(bbox_tuple)
120
+ else:
121
+ G = fetcher.fetch_by_bbox(bbox_tuple)
122
+ query_meta = {"bbox": list(bbox_tuple), "offline": offline}
123
+ elif place:
124
+ typer.secho(f"Fetching network for place: '{place}'...", fg=typer.colors.CYAN)
125
+ G = fetcher.fetch_by_place(place)
126
+ query_meta = {"place": place}
127
+ else:
128
+ # Default bounding box: Gachibowli / IIIT-H corridor
129
+ bbox_tuple = (78.345, 17.440, 78.360, 17.452)
130
+ typer.secho(f"No area specified. Using default IIIT-H bbox: {bbox_tuple}...", fg=typer.colors.YELLOW)
131
+ if offline:
132
+ G = fetcher.generate_offline_grid(bbox_tuple)
133
+ else:
134
+ G = fetcher.fetch_by_bbox(bbox_tuple)
135
+ query_meta = {"bbox": list(bbox_tuple), "default": True, "offline": offline}
136
+
137
+ res = fetcher.process_and_export(G, out_dir, query_metadata=query_meta)
138
+ typer.secho(f"D1 Road Graph Exported Successfully to {out_dir}", fg=typer.colors.GREEN)
139
+ typer.echo(f" - Edges: {res['edges_gpkg']}")
140
+ typer.echo(f" - Nodes: {res['nodes_gpkg']}")
141
+ typer.echo(f" - Snapshot: {res['osm_snapshot_json']}")
142
+ typer.echo(f" - Projected UTM: EPSG:{res['utm_epsg']}")
143
+
144
+
145
+ @app.command()
146
+ def fuse(
147
+ graph: Path = typer.Option(
148
+ Path("data/d1_graph/edges.gpkg"),
149
+ "--graph",
150
+ help="Path to D1 edges.gpkg"
151
+ ),
152
+ detections: Path = typer.Option(
153
+ Path("data/d2_detections/detections.parquet"),
154
+ "--detections",
155
+ help="Path to D2 detections.parquet"
156
+ ),
157
+ gps: Path = typer.Option(
158
+ Path("data/synthetic/d2_data/gps.csv"),
159
+ "--gps",
160
+ help="Path to GPS log CSV (gps.csv)"
161
+ ),
162
+ rig: Path = typer.Option(
163
+ Path("configs/rig.yaml"),
164
+ "--rig",
165
+ help="Path to rig calibration YAML"
166
+ ),
167
+ out_d3: Path = typer.Option(
168
+ Path("data/d3_store/anomalies.db"),
169
+ "--out-d3",
170
+ help="Path to SQLite ledger output (Contract D3)"
171
+ ),
172
+ out_d4: Path = typer.Option(
173
+ Path("data/d4_enriched"),
174
+ "--out-d4",
175
+ help="Output directory for enriched GeoParquet (Contract D4)"
176
+ ),
177
+ utm_zone: int = typer.Option(44, "--utm-zone", help="UTM zone number (default 44 for Hyderabad)"),
178
+ ) -> None:
179
+ """Execute M3 Geospatial Fusion pipeline: ray projection, HMM map matching, (s,t) referencing, and de-duplication."""
180
+ from irn.m3_fusion.pipeline import run_geospatial_fusion
181
+
182
+ typer.secho("Launching M3 Geospatial Fusion Engine...", fg=typer.colors.CYAN)
183
+ res = run_geospatial_fusion(
184
+ edges_gpkg_path=graph,
185
+ detections_parquet_path=detections,
186
+ gps_csv_path=gps,
187
+ rig_config_path=rig,
188
+ utm_zone=utm_zone,
189
+ output_d3_db=out_d3,
190
+ output_d4_dir=out_d4,
191
+ )
192
+ typer.secho("M3 Fusion Complete!", fg=typer.colors.GREEN)
193
+ typer.echo(f" - D3 Ledger (SQLite): {res['d3_db']}")
194
+ typer.echo(f" - D4 Enriched Edges: {res['d4_edges_parquet']}")
195
+ typer.echo(f" - D4 Anomalies Parquet: {res['d4_anomalies_parquet']}")
196
+ typer.echo(f" - Total Observations: {res['num_raw_observations']}")
197
+ typer.echo(f" - Canonical Anomalies: {res['num_canonical_anomalies']} ({res['num_confirmed']} confirmed)")
198
+
199
+
200
+ @app.command()
201
+ def compile(
202
+ enriched: Path = typer.Option(
203
+ Path("data/d4_enriched/enriched_edges.parquet"),
204
+ "--enriched",
205
+ help="Path to D4 enriched_edges.parquet"
206
+ ),
207
+ anomalies: Path = typer.Option(
208
+ Path("data/d4_enriched/anomalies.parquet"),
209
+ "--anomalies",
210
+ help="Path to D4 anomalies.parquet"
211
+ ),
212
+ nodes: Path = typer.Option(
213
+ Path("data/synthetic/d1_graph/nodes.gpkg"),
214
+ "--nodes",
215
+ help="Path to D1 nodes.gpkg"
216
+ ),
217
+ out_dir: Path = typer.Option(
218
+ Path("output_scenario"),
219
+ "--out",
220
+ help="Destination directory for scenario bundle"
221
+ ),
222
+ lanefree_mode: str = typer.Option(
223
+ "collapse",
224
+ "--lanefree-mode",
225
+ help="Sublane mode: 'collapse' for continuous width or 'lanes'"
226
+ ),
227
+ utm_zone: int = typer.Option(44, "--utm-zone", help="UTM zone number"),
228
+ ) -> None:
229
+ """Compile enriched network into Eclipse SUMO and ASAM OpenDRIVE 1.6.1 scenario."""
230
+ from irn.m4_compiler.compiler import compile_simulation_scenario
231
+
232
+ typer.secho("Compiling simulation scenario bundle...", fg=typer.colors.CYAN)
233
+ res = compile_simulation_scenario(
234
+ enriched_edges_path=enriched,
235
+ anomalies_parquet_path=anomalies,
236
+ nodes_gpkg_path=nodes,
237
+ out_dir=out_dir,
238
+ lanefree_mode=lanefree_mode,
239
+ utm_zone=utm_zone
240
+ )
241
+ typer.secho(f"Scenario compiled successfully in {out_dir}", fg=typer.colors.GREEN)
242
+ typer.echo(f" - SUMO Net: {res['network_net_xml']}")
243
+ typer.echo(f" - OpenDRIVE: {res['network_xodr']}")
244
+ typer.echo(f" - Fleet Profiles: {res['vtypes_add_xml']}")
245
+ typer.echo(f" - Anomalies: {res['anomalies_add_xml']}")
246
+ typer.echo(f" - SUMO Config: {res['scenario_sumocfg']}")
247
+ typer.echo(f" - Preview: {res['preview_geojson']}")
248
+
249
+
250
+ @app.command()
251
+ def detect(
252
+ sequence_id: str = typer.Option("seq_demo_01", "--seq", help="Identifier for IDD drive sequence"),
253
+ out_file: Path = typer.Option(
254
+ Path("data/d2_detections/detections.parquet"),
255
+ "--out",
256
+ help="Destination path for Contract D2 detections.parquet"
257
+ ),
258
+ frames: int = typer.Option(60, "--frames", help="Number of frames to generate in mock mode"),
259
+ ) -> None:
260
+ """Run detection engine (or mock generator) and export Contract D2 detections.parquet."""
261
+ from irn.m2_cv_interface.detector import MockDetector
262
+
263
+ typer.secho(f"Running detection ingestion for sequence '{sequence_id}'...", fg=typer.colors.CYAN)
264
+ detector = MockDetector(sequence_id=sequence_id)
265
+ records = detector.generate_synthetic_drive_detections(num_frames=frames)
266
+
267
+ from irn.m2_cv_interface.schema import save_detections_parquet
268
+ out_path = save_detections_parquet(records, out_file)
269
+ typer.secho(f"D2 Detections Exported Successfully ({len(records)} items) to {out_path}", fg=typer.colors.GREEN)
270
+
271
+
272
+ @app.command("ingest-idd")
273
+ def ingest_idd(
274
+ seq_dir: Path = typer.Option(
275
+ Path(r"D:\final\DATASET\Primary\idd_multimodal\primary\d0"),
276
+ "--seq-dir",
277
+ help="Path to IDD Multimodal sequence directory (e.g. primary/d0)"
278
+ ),
279
+ out_dir: Path = typer.Option(
280
+ Path("data/idd_d0"),
281
+ "--out",
282
+ help="Destination directory for normalized GPS and D2 Parquet"
283
+ ),
284
+ pothole_model: Optional[Path] = typer.Option(
285
+ Path(r"D:\Major_2\roadscope\backend\models\potholes.pt") if Path(r"D:\Major_2\roadscope\backend\models\potholes.pt").exists() else None,
286
+ "--pothole-model",
287
+ help="Path to trained YOLO pothole detector weights (.pt)"
288
+ ),
289
+ vehicle_model: Optional[Path] = typer.Option(
290
+ Path(r"D:\Major_2\roadscope\backend\yolo11n.pt") if Path(r"D:\Major_2\roadscope\backend\yolo11n.pt").exists() else None,
291
+ "--vehicle-model",
292
+ help="Path to YOLO vehicle detector weights (.pt)"
293
+ ),
294
+ sample_step: int = typer.Option(10, "--sample-step", help="Subsample video frames by this factor"),
295
+ max_frames: Optional[int] = typer.Option(150, "--max-frames", help="Maximum frames to run inference on"),
296
+ seq_id: str = typer.Option("idd_d0", "--seq-id", help="Sequence ID identifier"),
297
+ ) -> None:
298
+ """Ingest real IDD Multimodal driving sequence, run YOLO inference, and export D2 parquet + normalized GPS."""
299
+ from irn.m2_cv_interface.idd_adapter import IDDAdapter
300
+
301
+ typer.secho(f"Ingesting IDD Multimodal sequence from: {seq_dir}", fg=typer.colors.CYAN)
302
+ adapter = IDDAdapter(sequence_id=seq_id)
303
+ res = adapter.process_sequence(
304
+ sequence_dir=seq_dir,
305
+ out_dir=out_dir,
306
+ pothole_weights=pothole_model,
307
+ vehicle_weights=vehicle_model,
308
+ sample_step=sample_step,
309
+ max_frames=max_frames,
310
+ )
311
+ typer.secho("IDD Multimodal Ingestion Successful!", fg=typer.colors.GREEN)
312
+ typer.echo(f" - Sequence ID: {res['sequence_id']}")
313
+ typer.echo(f" - Trajectory Bbox: {res['bbox']}")
314
+ typer.echo(f" - Normalized GPS: {res['gps_csv']} ({res['num_gps_fixes']} fixes)")
315
+ typer.echo(f" - D2 Detections: {res['detections_parquet']} ({res['num_detections']} records)")
316
+ typer.secho(f"\nNext Steps to run full pipeline on this sequence:", fg=typer.colors.YELLOW)
317
+ bbox_str = f"{res['bbox'][0]},{res['bbox'][1]},{res['bbox'][2]},{res['bbox'][3]}"
318
+ typer.echo(f" 1. Fetch OSM Graph: irn fetch --bbox \"{bbox_str}\" --out {out_dir}/d1_graph")
319
+ typer.echo(f" 2. Geospatial Fusion: irn fuse --graph {out_dir}/d1_graph/edges.gpkg --detections {res['detections_parquet']} --gps {res['gps_csv']} --out-d3 {out_dir}/d3_store/anomalies.db --out-d4 {out_dir}/d4_enriched --utm-zone 44")
320
+ typer.echo(f" 3. Compile Scenario: irn compile --enriched {out_dir}/d4_enriched/enriched_edges.parquet --anomalies {out_dir}/d4_enriched/anomalies.parquet --nodes {out_dir}/d1_graph/nodes.gpkg --out output_{seq_id} --utm-zone 44")
321
+
322
+
323
+ @app.command("batch-idd")
324
+ def batch_idd(
325
+ dataset_dir: Path = typer.Option(
326
+ Path(r"D:\final\DATASET\Primary\idd_multimodal\primary"),
327
+ "--dataset-dir",
328
+ help="Root path containing IDD sequence directories (d0, d1, d2...)"
329
+ ),
330
+ out_dir: Path = typer.Option(
331
+ Path("scenarios"),
332
+ "--out",
333
+ help="Base directory for output scenarios and multi-pass corridor bundle"
334
+ ),
335
+ sequences: Optional[str] = typer.Option(
336
+ None,
337
+ "--sequences",
338
+ help="Comma-separated sequence names to process (e.g. 'd0,d1,d2'). Default: all found"
339
+ ),
340
+ pothole_model: Optional[Path] = typer.Option(
341
+ Path(r"D:\Major_2\roadscope\backend\models\potholes.pt") if Path(r"D:\Major_2\roadscope\backend\models\potholes.pt").exists() else None,
342
+ "--pothole-model",
343
+ help="Path to trained YOLO pothole detector weights (.pt)"
344
+ ),
345
+ vehicle_model: Optional[Path] = typer.Option(
346
+ Path(r"D:\Major_2\roadscope\backend\yolo11n.pt") if Path(r"D:\Major_2\roadscope\backend\yolo11n.pt").exists() else None,
347
+ "--vehicle-model",
348
+ help="Path to YOLO vehicle detector weights (.pt)"
349
+ ),
350
+ sample_step: int = typer.Option(10, "--sample-step", help="Subsample video frames by this factor"),
351
+ max_frames: Optional[int] = typer.Option(100, "--max-frames", help="Maximum frames per sequence to run inference on"),
352
+ unified: bool = typer.Option(True, "--unified/--isolated", help="Enable multi-pass Hungarian cross-pass fusion ledger"),
353
+ ) -> None:
354
+ """Run end-to-end IRN batch processing across all IDD multimodal sequences with multi-pass fusion."""
355
+ from irn.orchestrator import PipelineOrchestrator
356
+
357
+ seq_list = [s.strip() for s in sequences.split(",")] if sequences else None
358
+ orch = PipelineOrchestrator()
359
+ typer.secho(f"Starting IDD Batch Pipeline for dataset: {dataset_dir}", fg=typer.colors.CYAN)
360
+
361
+ batch_res = orch.run_idd_batch(
362
+ dataset_dir=dataset_dir,
363
+ output_base_dir=out_dir,
364
+ sequences=seq_list,
365
+ pothole_weights=pothole_model,
366
+ vehicle_weights=vehicle_model,
367
+ sample_step=sample_step,
368
+ max_frames=max_frames,
369
+ unified_multi_pass=unified,
370
+ )
371
+
372
+ typer.secho("\n==================================================", fg=typer.colors.GREEN)
373
+ typer.secho("IDD MULTI-SEQUENCE BATCH PROCESSING COMPLETE!", fg=typer.colors.GREEN)
374
+ typer.secho("==================================================", fg=typer.colors.GREEN)
375
+ for seq, res in batch_res["sequence_results"].items():
376
+ st = res["manifest"]["statistics"]
377
+ typer.echo(f" * Sequence {seq}: {st['num_detections_ingested']} dets -> {st['num_canonical_anomalies']} anomalies mapped -> {res['output_dir']}")
378
+
379
+ if batch_res.get("corridor_scenario"):
380
+ typer.secho(f"\nUnified Multi-Pass Corridor Scenario compiled in: {out_dir}/corridor_multi_pass", fg=typer.colors.YELLOW)
381
+ typer.echo(f" - OpenDRIVE: {out_dir}/corridor_multi_pass/network.xodr")
382
+ typer.echo(f" - SUMO Config: {out_dir}/corridor_multi_pass/scenario.sumocfg")
383
+ typer.echo(f" - Preview: {out_dir}/corridor_multi_pass/preview.geojson")
384
+
385
+
386
+ @app.command()
387
+ def synth(
388
+ out_dir: Path = typer.Option(Path("data/synthetic"), "--out", help="Output directory for synthetic world"),
389
+ length: float = typer.Option(100.0, "--length", help="Length of road segment in meters"),
390
+ ) -> None:
391
+ """Generate mathematically verified synthetic ground truth world (NFR-QA-01)."""
392
+ from tests.fixtures.synthetic_world import SyntheticWorld
393
+
394
+ typer.secho(f"Building synthetic world of length {length}m...", fg=typer.colors.CYAN)
395
+ world = SyntheticWorld(road_length_m=length)
396
+ d1_res = world.build_synthetic_graph(out_dir / "d1_graph")
397
+ gps_path, d2_path = world.generate_trajectory_and_detections(out_dir / "d2_data")
398
+
399
+ typer.secho(f"Synthetic World generated at {out_dir}", fg=typer.colors.GREEN)
400
+ typer.echo(f" - Graph Edges: {d1_res['edges_gpkg']}")
401
+ typer.echo(f" - Detections: {d2_path}")
402
+ typer.echo(f" - Vehicle GPS: {gps_path}")
403
+
404
+
405
+ if __name__ == "__main__":
406
+ app()
irn/common/crs.py ADDED
@@ -0,0 +1,148 @@
1
+ """Coordinate Reference System (CRS) transformations and UTM utilities.
2
+
3
+ Strictly enforces Rule G-1:
4
+ - Stored geometry is always WGS84 (EPSG:4326).
5
+ - All metric spatial calculations use local projected UTM based on the area centroid.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import math
10
+ from typing import Tuple, Union
11
+ import numpy as np
12
+
13
+ # WGS84 Ellipsoid constants
14
+ WGS84_A = 6378137.0 # semi-major axis (meters)
15
+ WGS84_F = 1.0 / 298.257223563 # flattening
16
+ WGS84_B = WGS84_A * (1.0 - WGS84_F) # semi-minor axis
17
+ WGS84_E2 = 2.0 * WGS84_F - WGS84_F**2 # first eccentricity squared
18
+ WGS84_E_PRIME2 = WGS84_E2 / (1.0 - WGS84_E2) # second eccentricity squared
19
+ UTM_K0 = 0.9996 # central scale factor
20
+
21
+
22
+ def utm_zone_from_lon_lat(lon: float, lat: float) -> Tuple[int, bool]:
23
+ """Calculate UTM zone number and hemisphere (True for North, False for South) from lon/lat."""
24
+ zone_number = int((lon + 180.0) / 6.0) + 1
25
+ if zone_number < 1:
26
+ zone_number = 1
27
+ elif zone_number > 60:
28
+ zone_number = 60
29
+ is_northern = lat >= 0.0
30
+ return zone_number, is_northern
31
+
32
+
33
+ def epsg_from_utm_zone(zone_number: int, is_northern: bool = True) -> int:
34
+ """Return EPSG code for a given UTM zone (e.g. 32643 for UTM 43N, 32644 for UTM 44N)."""
35
+ return (32600 if is_northern else 32700) + zone_number
36
+
37
+
38
+ def optimal_utm_epsg(lon: float, lat: float) -> int:
39
+ """Find the optimal UTM EPSG code for a given centroid (Rule G-1)."""
40
+ zone, north = utm_zone_from_lon_lat(lon, lat)
41
+ return epsg_from_utm_zone(zone, north)
42
+
43
+
44
+ def wgs84_to_utm(
45
+ lon: Union[float, np.ndarray],
46
+ lat: Union[float, np.ndarray],
47
+ zone_number: int,
48
+ is_northern: bool = True
49
+ ) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray]]:
50
+ """Vectorized conversion of (longitude, latitude) in degrees to UTM (easting, northing) in meters.
51
+
52
+ Uses high-precision Karney/Snyder Transverse Mercator formulation.
53
+ """
54
+ lon_rad = np.radians(lon)
55
+ lat_rad = np.radians(lat)
56
+
57
+ lon_origin = (zone_number - 1) * 6.0 - 180.0 + 3.0
58
+ lon_origin_rad = np.radians(lon_origin)
59
+
60
+ sin_lat = np.sin(lat_rad)
61
+ cos_lat = np.cos(lat_rad)
62
+ tan_lat = np.tan(lat_rad)
63
+
64
+ N = WGS84_A / np.sqrt(1.0 - WGS84_E2 * sin_lat**2)
65
+ T = tan_lat**2
66
+ C = WGS84_E_PRIME2 * cos_lat**2
67
+ A = cos_lat * (lon_rad - lon_origin_rad)
68
+
69
+ # Meridional distance M
70
+ M = WGS84_A * (
71
+ (1.0 - WGS84_E2 / 4.0 - 3.0 * WGS84_E2**2 / 64.0 - 5.0 * WGS84_E2**3 / 256.0) * lat_rad
72
+ - (3.0 * WGS84_E2 / 8.0 + 3.0 * WGS84_E2**2 / 32.0 + 45.0 * WGS84_E2**3 / 1024.0) * np.sin(2.0 * lat_rad)
73
+ + (15.0 * WGS84_E2**2 / 256.0 + 45.0 * WGS84_E2**3 / 1024.0) * np.sin(4.0 * lat_rad)
74
+ - (35.0 * WGS84_E2**3 / 3072.0) * np.sin(6.0 * lat_rad)
75
+ )
76
+
77
+ easting = UTM_K0 * N * (
78
+ A
79
+ + (1.0 - T + C) * A**3 / 6.0
80
+ + (5.0 - 18.0 * T + T**2 + 72.0 * C - 58.0 * WGS84_E_PRIME2) * A**5 / 120.0
81
+ ) + 500000.0
82
+
83
+ northing = UTM_K0 * (
84
+ M
85
+ + N * tan_lat * (
86
+ A**2 / 2.0
87
+ + (5.0 - T + 9.0 * C + 4.0 * C**2) * A**4 / 24.0
88
+ + (61.0 - 58.0 * T + T**2 + 600.0 * C - 330.0 * WGS84_E_PRIME2) * A**6 / 720.0
89
+ )
90
+ )
91
+
92
+ if not is_northern:
93
+ northing += 10000000.0
94
+
95
+ return easting, northing
96
+
97
+
98
+ def utm_to_wgs84(
99
+ easting: Union[float, np.ndarray],
100
+ northing: Union[float, np.ndarray],
101
+ zone_number: int,
102
+ is_northern: bool = True
103
+ ) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray]]:
104
+ """Vectorized conversion of UTM (easting, northing) in meters to (longitude, latitude) in degrees."""
105
+ x = np.asarray(easting, dtype=float) - 500000.0
106
+ y = np.asarray(northing, dtype=float)
107
+ if not is_northern:
108
+ y -= 10000000.0
109
+
110
+ lon_origin = (zone_number - 1) * 6.0 - 180.0 + 3.0
111
+
112
+ M = y / UTM_K0
113
+ mu = M / (WGS84_A * (1.0 - WGS84_E2 / 4.0 - 3.0 * WGS84_E2**2 / 64.0 - 5.0 * WGS84_E2**3 / 256.0))
114
+
115
+ e1 = (1.0 - np.sqrt(1.0 - WGS84_E2)) / (1.0 + np.sqrt(1.0 - WGS84_E2))
116
+
117
+ phi1 = (
118
+ mu
119
+ + (3.0 * e1 / 2.0 - 27.0 * e1**3 / 32.0) * np.sin(2.0 * mu)
120
+ + (21.0 * e1**2 / 16.0 - 55.0 * e1**4 / 32.0) * np.sin(4.0 * mu)
121
+ + (151.0 * e1**3 / 96.0) * np.sin(6.0 * mu)
122
+ + (1097.0 * e1**4 / 512.0) * np.sin(8.0 * mu)
123
+ )
124
+
125
+ sin_phi1 = np.sin(phi1)
126
+ cos_phi1 = np.cos(phi1)
127
+ tan_phi1 = np.tan(phi1)
128
+
129
+ N1 = WGS84_A / np.sqrt(1.0 - WGS84_E2 * sin_phi1**2)
130
+ R1 = WGS84_A * (1.0 - WGS84_E2) / ((1.0 - WGS84_E2 * sin_phi1**2) ** 1.5)
131
+ D = x / (N1 * UTM_K0)
132
+
133
+ T1 = tan_phi1**2
134
+ C1 = WGS84_E_PRIME2 * cos_phi1**2
135
+
136
+ lat_rad = phi1 - (N1 * tan_phi1 / R1) * (
137
+ D**2 / 2.0
138
+ - (5.0 + 3.0 * T1 + 10.0 * C1 - 4.0 * C1**2 - 9.0 * WGS84_E_PRIME2) * D**4 / 24.0
139
+ + (61.0 + 90.0 * T1 + 298.0 * C1 + 45.0 * T1**2 - 252.0 * WGS84_E_PRIME2 - 3.0 * C1**2) * D**6 / 720.0
140
+ )
141
+
142
+ lon_rad = np.radians(lon_origin) + (
143
+ D
144
+ - (1.0 + 2.0 * T1 + C1) * D**3 / 6.0
145
+ + (5.0 - 2.0 * C1 + 28.0 * T1 - 3.0 * C1**2 + 8.0 * WGS84_E_PRIME2 + 24.0 * T1**2) * D**5 / 120.0
146
+ ) / cos_phi1
147
+
148
+ return np.degrees(lon_rad), np.degrees(lat_rad)
irn/common/io.py ADDED
@@ -0,0 +1,40 @@
1
+ """Atomic file I/O utilities enforcing Constraint C-7."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Union
9
+
10
+
11
+ def atomic_write_text(filepath: Union[str, Path], content: str, encoding: str = "utf-8") -> Path:
12
+ """Atomically write text to filepath via a temporary sibling file."""
13
+ path = Path(filepath).resolve()
14
+ path.parent.mkdir(parents=True, exist_ok=True)
15
+
16
+ with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, encoding=encoding) as tmp:
17
+ tmp.write(content)
18
+ tmp_name = tmp.name
19
+
20
+ os.replace(tmp_name, path)
21
+ return path
22
+
23
+
24
+ def atomic_write_json(filepath: Union[str, Path], data: Any, indent: int = 2) -> Path:
25
+ """Atomically write Python object to JSON file."""
26
+ content = json.dumps(data, indent=indent, default=str)
27
+ return atomic_write_text(filepath, content)
28
+
29
+
30
+ def atomic_write_bytes(filepath: Union[str, Path], data: bytes) -> Path:
31
+ """Atomically write raw bytes to filepath."""
32
+ path = Path(filepath).resolve()
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+ with tempfile.NamedTemporaryFile("wb", dir=path.parent, delete=False) as tmp:
36
+ tmp.write(data)
37
+ tmp_name = tmp.name
38
+
39
+ os.replace(tmp_name, path)
40
+ return path
irn/common/logger.py ADDED
@@ -0,0 +1,58 @@
1
+ """Structured logging system with reason codes and standardized formatting."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Optional
6
+
7
+ # ANSI Colors
8
+ CYAN = "\033[96m"
9
+ GREEN = "\033[92m"
10
+ YELLOW = "\033[93m"
11
+ RED = "\033[91m"
12
+ BOLD = "\033[1m"
13
+ RESET = "\033[0m"
14
+
15
+
16
+ class ReasonCodeFormatter(logging.Formatter):
17
+ """Formats logs with timestamp, module, reason code, and message."""
18
+
19
+ def format(self, record: logging.LogRecord) -> str:
20
+ reason_code = getattr(record, "reason_code", "GEN-000")
21
+ color = ""
22
+ if record.levelno >= logging.ERROR:
23
+ color = RED
24
+ elif record.levelno >= logging.WARNING:
25
+ color = YELLOW
26
+ elif record.levelno >= logging.INFO:
27
+ color = CYAN
28
+
29
+ timestamp = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
30
+ prefix = f"{color}[{timestamp}] [{record.levelname:<7}] [{reason_code}]{RESET}"
31
+ return f"{prefix} {record.getMessage()}"
32
+
33
+
34
+ def setup_logger(name: str = "irn", level: int = logging.INFO) -> logging.Logger:
35
+ """Configure and return the root IRN logger."""
36
+ logger = logging.getLogger(name)
37
+ logger.setLevel(level)
38
+
39
+ if not logger.handlers:
40
+ handler = logging.StreamHandler(sys.stdout)
41
+ handler.setLevel(level)
42
+ handler.setFormatter(ReasonCodeFormatter())
43
+ logger.addHandler(handler)
44
+
45
+ return logger
46
+
47
+
48
+ logger = setup_logger("irn")
49
+
50
+
51
+ def log_event(
52
+ msg: str,
53
+ reason_code: str = "INFO-001",
54
+ level: int = logging.INFO,
55
+ **kwargs
56
+ ) -> None:
57
+ """Helper to log structured events with explicit reason codes."""
58
+ logger.log(level, msg, extra={"reason_code": reason_code}, **kwargs)