Skip to content

Commit 2375298

Browse files
authored
Feat/conn setup down (#505)
* download pbf + DAG * routing and barriers * clear noaa ntl search module and add reverse_geocoding dependency
1 parent 4c4d8b6 commit 2375298

16 files changed

Lines changed: 1079 additions & 2095 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66
name = "rapida"
77
dynamic = ["version"]
88
description = "python tools that facilitate the assessment of natural hazards over various domains like population, landuse, infrastructure, etc"
9-
requires-python = ">=3.10"
9+
requires-python = ">=3.12"
1010
authors = [
1111
{ name = 'Ioan Ferencik'},
1212
{ name = 'Joseph Thuha'},
@@ -63,8 +63,9 @@ dependencies = [
6363
"matplotlib>=3.10.9",
6464
"netcdf4>=1.7.3",
6565
"h5netcdf>=1.8.1",
66-
"spacetrack>=1.4.0"
67-
66+
"spacetrack>=1.4.0",
67+
"pyvalhalla>=3.7.0",
68+
"reverse-geocoder>=1.5.1",
6869
]
6970

7071
[project.optional-dependencies]

rapida/cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from rapida.cli.publish import publish
1414
from rapida.cli.h3id import addh3id
1515
from rapida.cli.ntl import ntl
16+
from rapida.cli.connectivity import connectivity
1617
from rich.progress import Progress
1718
import click
1819
import nest_asyncio
@@ -74,6 +75,7 @@ def cli(ctx):
7475
cli.add_command(addh3id)
7576
cli.add_command(population)
7677
cli.add_command(ntl)
78+
cli.add_command(connectivity)
7779

7880
if __name__ == '__main__':
7981
cli()

rapida/cli/aclick.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@
22
import click
33
from functools import wraps
44
import asyncio
5+
import logging
56

6-
7+
def setup_debug_logging(ctx, param, value):
8+
"""Callback that switches the log level to DEBUG instantly."""
9+
if value:
10+
logger = logging.getLogger('rapida')
11+
logger.setLevel(logging.DEBUG)
12+
for handler in logger.handlers:
13+
handler.setLevel(logging.DEBUG)
14+
logger.debug("Debug logging enabled globally.")
15+
return value
716
class AsyncCommand(click.Command):
817
"""
918
Async wrapper designed to work alongside nest_asyncio in Jupyter
@@ -34,16 +43,32 @@ def wrapped_callback(*c_args, **c_kwargs):
3443
self.callback = wrapped_callback
3544
class RapidaCommandGroup(click.Group):
3645
"""
37-
Combined group that handles async subcommands and lists keys.
46+
Combined group that handles async subcommands, forces help on no arguments,
47+
and lists keys accurately.
3848
"""
3949

4050
def list_commands(self, ctx):
4151
return self.commands.keys()
4252

53+
def add_command(self, cmd: click.Command, name: str = None) -> None:
54+
# Catch-all: forces help display if a subcommand is run empty
55+
cmd.no_args_is_help = True
56+
# 2. Automatically inject --debug into every registered subcommand
57+
cmd.params.append(
58+
click.Option(
59+
['--debug'],
60+
is_flag=True,
61+
help='Enable debug logging.',
62+
expose_value=False,
63+
callback=setup_debug_logging
64+
)
65+
)
66+
super().add_command(cmd, name)
67+
4368
def command(self, *args, **kwargs):
44-
# Automatically wrap all @group.command() calls in AsyncCommand
69+
# Automatically wrap all inline @group.command() calls in AsyncCommand
4570
kwargs.setdefault('cls', AsyncCommand)
46-
return super().command(*args, no_args_is_help=True, **kwargs)
71+
return super().command(*args, **kwargs)
4772

4873
def group(self, *args, **kwargs):
4974
# Ensure nested groups inherit this behavior

rapida/cli/connectivity.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from typing import Union
2+
3+
import click
4+
import logging
5+
import tempfile
6+
from rapida.util.bbox_param_type import BboxParamType
7+
from rapida.connectivity import run_connectivity_analysis
8+
from rapida.cli.aclick import AsyncCommand
9+
from rapida.connectivity.isochrone import MODE_MAP
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def parse_intervals(ctx, param, value):
15+
"""Parses a comma-separated string of numbers into a list of integers."""
16+
if not value:
17+
return [5, 15, 30, 60] # Fallback default
18+
19+
try:
20+
# Split by comma, strip spaces, and cast to int
21+
return [int(x.strip()) for x in value.split(",")]
22+
except ValueError:
23+
raise click.BadParameter("Time intervals must be a comma-separated list of integers (e.g., 5,15,30,60).")
24+
25+
@click.command(short_help='run connectivity analysis', cls=AsyncCommand)
26+
27+
@click.option('-b', '--bbox',
28+
required=True,
29+
type=BboxParamType(),
30+
help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north'
31+
)
32+
@click.option(
33+
'-m', "--mode", "travel_mode",
34+
type=click.Choice(MODE_MAP, case_sensitive=False),
35+
default='walk',
36+
required=True,
37+
help=f"The means of travel when delineating isochrones"
38+
39+
)
40+
41+
@click.option(
42+
'-ti', '--time-intervals',
43+
type=str,
44+
default="5,15,30,60",
45+
callback=parse_intervals,
46+
help="Comma-separated time intervals in minutes for the catchment areas."
47+
)
48+
49+
@click.option(
50+
'-bd', '--barriers-dataset',
51+
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True),
52+
default=None,
53+
help="Path to an OGR-supported vector data source (e.g., GPKG, Shapefile) containing exclusion zones."
54+
)
55+
@click.option(
56+
'-bl', '--barriers-layer',
57+
type=str,
58+
default="0",
59+
help="Name or index of the layer to use from barriers dataset. Defaults to the first layer (layer 0)."
60+
)
61+
62+
@click.option('-bb', "--barriers-buffer",
63+
type=int,
64+
default=5,
65+
required=False,
66+
help="The value in meters to used to buffer the geometries in barriers/dataset/layer in case the barriers are lines"
67+
)
68+
@click.option(
69+
"--dst-dir",
70+
"-d", # Short option
71+
"dst_dir", # Function argument name
72+
type=click.Path(
73+
exists=False, # Set to True if you want Click to fail if the dir doesn't exist yet
74+
file_okay=False, # Strictly enforce that this is a directory, not a file
75+
dir_okay=True,
76+
resolve_path=True # Resolves relative paths (like '.') to absolute paths automatically
77+
),
78+
default=tempfile.gettempdir(), # Defaults to the current working directory
79+
show_default=True, # Tells the user what the default is in the --help menu
80+
help="Destination directory to save the downloaded OSM pbf files."
81+
)
82+
83+
84+
@click.pass_context
85+
async def connectivity(ctx, bbox:tuple[float, float, float, float]=None, travel_mode:str=None,
86+
time_intervals:list[int] =None, dst_dir:str=None,
87+
barriers_dataset:str=None, barriers_layer:str=None, barriers_buffer:int=None
88+
):
89+
logger.info(f'Running connectivity analysis ')
90+
progress = ctx.obj.get('progress')
91+
return await run_connectivity_analysis(
92+
bbox=bbox, dst_dir=dst_dir, travel_mode=travel_mode, time_intervals=time_intervals,
93+
barriers_dataset=barriers_dataset, barriers_layer=barriers_layer, barriers_buffer=barriers_buffer, progress=progress
94+
)

rapida/cli/ntl.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import logging
2-
import numbers
32
import os.path
43
from datetime import datetime
54
from typing import Iterable
65
import click
76
import tempfile
87

9-
108
from rapida.cli import RapidaCommandGroup
119
from rapida.ntl.nasa.const import ARCHIVE, OPERATIONAL, PROCESSING_LEVEL_NAMES, PRODUCT_NAMES, NTL_FILENAME_PATTERN, ROUTES, COLLECTIONS
1210
from rapida.ntl.nasa.search import search as nasa_search
@@ -113,7 +111,7 @@ def search():
113111

114112

115113
@click.pass_context
116-
async def search_noaa(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, satellites:list[str] = [], cmask:bool=None):
114+
async def search_noaa(ctx, bbox:tuple[float, float, float, float]=None, nominal_date:datetime=None, satellites:list[str] = [], cmask:bool=None):
117115

118116
progress = ctx.obj.get('progress')
119117
table = Table(title=f"VIIRS satellites granules for the night of {nominal_date.date()} covering {bbox}",
@@ -186,7 +184,7 @@ async def search_noaa(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetim
186184
)
187185

188186
@click.pass_context
189-
def search_nasa(ctx, bbox:tuple[numbers.Number, numbers.Number, numbers.Number, numbers.Number]=None, nominal_date:datetime=None, stream:str = None, processing_level:str=None, route:str=None):
187+
def search_nasa(ctx, bbox:tuple[tuple[float, float, float, float]]=None, nominal_date:datetime=None, stream:str = None, processing_level:str=None, route:str=None):
190188

191189
progress = ctx.obj.get('progress')
192190

@@ -390,7 +388,7 @@ async def download_noaa(ctx, satellite:str=None, timestamp:str=None, products:It
390388

391389

392390
@click.pass_context
393-
async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetime=None, end_date:datetime=None,
391+
async def bulk_download(ctx, bbox:tuple[float, float, float, float]=None, start_date:datetime=None, end_date:datetime=None,
394392
products:str=None, dst_dir:str=None):
395393
progress = ctx.obj.get('progress')
396394

@@ -455,7 +453,7 @@ async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetim
455453

456454

457455
@click.pass_context
458-
async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None):
456+
async def fetch(ctx, bbox:tuple[float, float, float, float]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None):
459457

460458
progress = ctx.obj.get('progress')
461459
return await fetch_ntl(bbox=bbox,nominal_date=nominal_date, deliverable=deliverable, progress=progress, dst_dir=dst_dir )
@@ -525,7 +523,7 @@ async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None
525523
)
526524

527525
@click.pass_context
528-
async def detect(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None,
526+
async def detect(ctx, bbox:tuple[float, float, float, float]=None, nominal_date:datetime=None, deliverable:str=None,
529527
mask_clouds:bool=True, dst_dir:str=None, percentage_drop:int=None, display:bool=False):
530528
progress = ctx.obj.get('progress')
531529
return await detect_outage(

rapida/connectivity/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
import os.path
3+
from rapida.util.bbox_param_type import get_best_semantic_label
4+
from rich.progress import Progress
5+
from rapida.connectivity.io import prepare_osm_pbf,extract_health_sites, extract_origins_from_geojson
6+
from rapida.connectivity.graph import compile_valhalla_graph
7+
from rapida.connectivity.isochrone import connectivity_areas
8+
9+
10+
11+
async def run_connectivity_analysis(
12+
bbox:tuple[float, float, float, float]=None, travel_mode:str=None, time_intervals:list[int] =None,
13+
dst_dir:str=None, barriers_dataset:str=None, barriers_layer:str=None, barriers_buffer:int=None, progress:Progress=None
14+
):
15+
bbox_label = get_best_semantic_label(bbox=bbox)
16+
dest_dir = os.path.join(dst_dir, bbox_label)
17+
bbox_pbf = await prepare_osm_pbf(bbox=bbox, dst_dir=dest_dir, progress=progress)
18+
health_sites = await extract_health_sites(pbf_path=bbox_pbf, dst_dir=dest_dir, progress=progress)
19+
dag_tar_path = await compile_valhalla_graph(pbf_path=bbox_pbf,dst_dir=dest_dir, progress=progress)
20+
origins = extract_origins_from_geojson(geojson_path=health_sites)
21+
results = await connectivity_areas(
22+
tar_path=dag_tar_path, origins=origins, travel_mode=travel_mode, intervals_minutes=time_intervals,
23+
barriers_dataset=barriers_dataset, barriers_layer=barriers_layer, barriers_buffer=barriers_buffer
24+
)
25+
with open(os.path.join(dest_dir, 'isochrones.geojson'), "w") as f:
26+
json.dump(results, f, indent=2)
27+
28+
return

rapida/connectivity/graph.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import os
2+
import json
3+
import asyncio
4+
from rapida.connectivity.runcli import run_cli
5+
from valhalla import get_config
6+
from valhalla.config import _sanitize_config, default_config
7+
from typing import Union
8+
from pathlib import Path
9+
10+
11+
def get_config_fixed(
12+
tile_extract: Union[str, Path] = "valhalla_tiles.tar",
13+
tile_dir: Union[str, Path] = "valhalla_tiles",
14+
verbose: bool = False,
15+
) -> dict:
16+
"""
17+
Returns a default Valhalla configuration.
18+
19+
:param tile_extract: The file path (with .tar extension) of the tile extract (mjolnir.tile_extract), if present. Preferred over tile_dir.
20+
:param tile_dir: The directory path where the graph tiles are stored (mjolnir.tile_dir), if present.
21+
:param verbose: Whether you want to see Valhalla's logs on stdout (mjolnir.logging). Default False.
22+
"""
23+
24+
config = _sanitize_config(default_config.copy())
25+
26+
config["mjolnir"]["tile_dir"] = (
27+
""
28+
if isinstance(tile_dir, str) and not str(tile_dir)
29+
else str(Path(tile_dir).resolve(strict=True))
30+
)
31+
config["mjolnir"]["tile_extract"] = (
32+
""
33+
if isinstance(tile_extract, str) and not str(tile_extract)
34+
else str(Path(tile_extract).resolve())
35+
)
36+
37+
config["logging"]["type"] = "std_out" if verbose else ""
38+
39+
return config
40+
41+
42+
async def compile_valhalla_graph(pbf_path: str, dst_dir: str, progress=None) -> str:
43+
"""
44+
Compiles the raw OSM PBF into a highly optimized Valhalla routing DAG.
45+
Offloads the C++ execution to a background thread to keep uvloop unblocked.
46+
"""
47+
os.makedirs(dst_dir, exist_ok=True)
48+
tile_dir = os.path.join(dst_dir, "valhalla_tiles")
49+
os.makedirs(tile_dir, exist_ok=True)
50+
tar_path = os.path.join(dst_dir, "valhalla_tiles.tar")
51+
#os.makedirs(tar_path, exist_ok=True)
52+
config_path = os.path.join(dst_dir, "valhalla.json")
53+
54+
# 1. Generate the Valhalla JSON configuration natively
55+
if progress:
56+
progress.console.print("[cyan]Generating Valhalla engine configuration...[/cyan]")
57+
58+
try:
59+
valhalla_conf = get_config(
60+
tile_dir=tile_dir,
61+
tile_extract=tar_path,
62+
verbose=False
63+
)
64+
except Exception:
65+
valhalla_conf = get_config_fixed(
66+
tile_dir=tile_dir,
67+
tile_extract=tar_path,
68+
verbose=False
69+
)
70+
# ---------------------------------------------------------
71+
# INJECT CUSTOM LIMITS BEFORE SAVING THE BUILD CONFIG
72+
# ---------------------------------------------------------
73+
if "service_limits" not in valhalla_conf:
74+
valhalla_conf["service_limits"] = {}
75+
if "isochrone" not in valhalla_conf["service_limits"]:
76+
valhalla_conf["service_limits"]["isochrone"] = {}
77+
78+
# Expand the max_locations limit to allow system-wide bulk routing
79+
valhalla_conf["service_limits"]["isochrone"]["max_locations"] = 5000
80+
# ---------------------------------------------------------
81+
82+
with open(config_path, "w") as f:
83+
json.dump(valhalla_conf, f, indent=4)
84+
85+
# 2. Define the blocking C++ execution function
86+
def run_compiler():
87+
# 1. Build the Admin Database
88+
# This parses country borders, timezones, and local driving rules (e.g., right vs left side of road)
89+
if progress:
90+
progress.console.print("[cyan]Building admin rules database...[/cyan]")
91+
run_cli([
92+
"valhalla_build_admins", "-c", config_path, pbf_path
93+
])
94+
95+
# 2. Build the Routing Tiles
96+
# This generates the actual mathematical DAG and writes it to the 'valhalla_tiles' folder
97+
if progress:
98+
progress.console.print("[cyan]Building routing graph ...[/cyan]")
99+
run_cli([
100+
"valhalla_build_tiles", "-c", config_path, pbf_path
101+
])
102+
103+
# 3. Compress into the Extract
104+
# This reads the generated folder and packs it into the high-performance memory-mapped .tar file
105+
if progress:
106+
progress.console.print("[cyan]Compressing graph into a tarball...[/cyan]")
107+
run_cli([
108+
"valhalla_build_extract", "-c", config_path, "-v", "--overwrite"
109+
])
110+
111+
# 3. Offload compilation to a worker thread
112+
if progress:
113+
progress.console.print("[cyan]Compiling binary DAG ...[/cyan]")
114+
115+
await asyncio.to_thread(run_compiler)
116+
117+
if progress:
118+
progress.console.print(f"[bold green]✓ Valhalla DAG compiled successfully: {tar_path}[/bold green]")
119+
120+
return tar_path

0 commit comments

Comments
 (0)