Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ dependencies = [
"psutil",
"s2cloudless",
"country-converter",
"ratelimit"
"ratelimit",
"obstore>=0.9.5",
"pyorbital>=1.12.1",
"h5py>=3.16.0",
"fsspec>=2026.4.0",
"satpy>=0.59.0",
]

[project.optional-dependencies]
Expand All @@ -80,4 +85,4 @@ Issues = "https://github.com/UNDP-Data/rapida/issues"
include = ["rapida*"]

# Initialize SCM to calculate versions from Git tags
[tool.setuptools_scm]
[tool.setuptools_scm]
38 changes: 31 additions & 7 deletions rapida/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from rapida.cli.aclick import RapidaCommandGroup
from rapida.cli.population import population
from rapida.util.setup_logger import setup_logger
from rapida.cli.admin import admin
Expand All @@ -6,12 +7,13 @@
from rapida.cli.assess import assess
from rapida.cli.create import create
from rapida.cli.delete import delete
from rapida.cli.list import list
from rapida.cli.list import list_project
from rapida.cli.upload import upload
from rapida.cli.download import download
from rapida.cli.publish import publish
from rapida.cli.h3id import addh3id

from rapida.cli.ntl import ntl
from rich.progress import Progress

import click
import nest_asyncio
Expand All @@ -22,12 +24,10 @@



class RapidaCommandGroup(click.Group):
def list_commands(self, ctx):
return self.commands.keys()


@click.group(cls=RapidaCommandGroup, context_settings=dict(help_option_names=['-h', '--help']))

@click.pass_context
def cli(ctx):
"""UNDP Crisis Bureau Rapida tool.
Expand All @@ -36,21 +36,45 @@ def cli(ctx):
representing exposure and vulnerability aspects of geospatial risk induced
by natural hazards.
"""
# 1. Initialize your structured logging engine
logger = setup_logger(name='rapida', make_root=False)

# 2. Ensure ctx.obj is initialized as a container dictionary
ctx.ensure_object(dict)

# 3. Instantiate a beautifully configured rich Progress engine
# progress = Progress(
# TextColumn("[progress.description]{task.description}"),
# BarColumn(bar_width=40),
# TaskProgressColumn(),
# MofNCompleteColumn(), # e.g., "3/10 granules"
# TimeRemainingColumn(),
# transient=True # Clean up bars from terminal upon completion
# )
progress = Progress(disable=False, console=None, transient=True)

# 4. Spin up the progress display canvas
progress.start()

# 5. Inject it into Click's shared context object
ctx.obj['progress'] = progress

# 6. Critical Safeguard: Register a teardown callback to exit the progress frame cleanly
ctx.call_on_close(lambda: progress.stop())

cli.add_command(init)
cli.add_command(auth)
cli.add_command(admin)
cli.add_command(create)
cli.add_command(assess)
cli.add_command(list)
cli.add_command(list_project)
cli.add_command(download)
cli.add_command(upload)
cli.add_command(publish)
cli.add_command(delete)
cli.add_command(addh3id)
cli.add_command(population)

cli.add_command(ntl)

if __name__ == '__main__':
cli()
51 changes: 51 additions & 0 deletions rapida/cli/aclick.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import inspect
import click
from functools import wraps
import asyncio


class AsyncCommand(click.Command):
"""
Async wrapper designed to work alongside nest_asyncio in Jupyter
and standard terminal environments.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
orig_callback = self.callback

if orig_callback is not None:
@wraps(orig_callback)
def wrapped_callback(*c_args, **c_kwargs):
actual_func = inspect.unwrap(orig_callback)

if inspect.iscoroutinefunction(actual_func):
# Safely get or create the event loop
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

return loop.run_until_complete(orig_callback(*c_args, **c_kwargs))

return orig_callback(*c_args, **c_kwargs)

self.callback = wrapped_callback
class RapidaCommandGroup(click.Group):
"""
Combined group that handles async subcommands and lists keys.
"""

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

def command(self, *args, **kwargs):
# Automatically wrap all @group.command() calls in AsyncCommand
kwargs.setdefault('cls', AsyncCommand)
return super().command(*args, **kwargs)

def group(self, *args, **kwargs):
# Ensure nested groups inherit this behavior
kwargs.setdefault('cls', RapidaCommandGroup)
return super().group(*args, **kwargs)
2 changes: 1 addition & 1 deletion rapida/cli/assess.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def assess(ctx, all=False, components=None, variables=None, year=None, datetime
return
else:
if all:
logger.warning(f"--all option is ignored and to process {", ".join(components)}")
logger.warning(f"--all option is ignored and to process {', '.join(components)}")

for component_name in target_components:
if not component_name in all_components:
Expand Down
2 changes: 1 addition & 1 deletion rapida/cli/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
default=False,
help="Set log level to debug"
)
def list(debug=False):
def list_project(debug=False):
setup_logger(name='rapida', level=logging.DEBUG if debug else logging.INFO)

if not is_rapida_initialized():
Expand Down
175 changes: 175 additions & 0 deletions rapida/cli/ntl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import logging
import numbers
from datetime import date
import click
from rapida.cli import RapidaCommandGroup
from rapida.ntl.nasa.const import ARCHIVE, OPERATIONAL, PROCESSING_LEVEL_NAMES
from rapida.ntl.nasa.search import search as nasa_search
from rapida.ntl.noaa.search import async_search_granules, VIIRSNavigator
from rapida.util.bbox_param_type import BboxParamType
from rich.table import Table
logger = logging.getLogger(__name__)


class ProcessingLevelChoiceOption(click.Option):
"""
Custom Click option that dynamically validates choices based on the value
of a companion option (in this case, '--stream').
"""

def handle_parse_result(self, ctx, opts, args):
# Retrieve the value of 'stream' that click has already processed
stream_val = opts.get('stream')

# If stream is found, dynamically inject its specific valid levels into the choice validator
if stream_val:
stream_key = stream_val.upper() # Handle casing normalization
valid_choices = PROCESSING_LEVEL_NAMES.get(stream_key, [])
# Map choice array to lowercase to keep user typing natural
self.type = click.Choice([c.upper() for c in valid_choices], case_sensitive=False)

return super().handle_parse_result(ctx, opts, args)


@click.group(cls=RapidaCommandGroup)


def ntl():
"""Nighttime Lights VIIRS data and impact detection"""
pass
@ntl.group(short_help=f'Search for available NTL data products across tiers and streams')
def search():
"""Search for available NTL data products across distinct data streams."""
pass

@search.command(name='noaa', short_help=f'Search for available NTL data from operational NOAA stream')

@click.option('-b', '--bbox',
required=True,
type=BboxParamType(),
help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north'
)
@click.option("--date", "target_date",
type=click.DateTime(formats=["%Y-%m-%d"]),
required=True,
help=''
)

@click.option(
"--sat",
"-s",
"satellites", # This will be the name of the argument in your function
type=click.Choice(VIIRSNavigator.SATELLITES, case_sensitive=False),
multiple=True,
default=list(VIIRSNavigator.SATELLITES),
help=f"Target satellite(s). Use multiple times for more than one ({','.join(VIIRSNavigator.SATELLITES)})."
)


@click.option(
'--cmask', '-cm', "cmask",
is_flag=True,
help=(
"Enable Cloud Mask optimization by favouring "
"the granules where the target bbox is mostly cloud free. "
"If omitted, defaults to standard Geometry filtering (elevation >20°, offset <1500km)."
)
)




@click.pass_context
async def search_noaa(ctx, bbox:tuple[numbers.Number]=None, target_date:date=None, satellites:list[str] = [], cmask:bool=None ):

progress = ctx.obj.get('progress')
table = Table(title=f"VIIRS satellites granules for the night of {target_date.date()} covering {bbox}",
title_style="bold yellow")
table.add_column("Position", justify="center", style="white")
table.add_column("Satellite", style="green", justify='center')
table.add_column("Timestamp (UTC)", style="cyan", justify='center')
# table.add_column("Scan Start Date and Time (UTC)", style="red", justify='center')
table.add_column("Bbox offset from SSP (km)", justify="center", style="white")
table.add_column("Elevation above bbox (degrees)", justify="center", style="white")
if cmask:
table.add_column("Cloud coverage in bbox (%)", justify="center", style="white")
table.add_column("Score (%)", justify="center", style="white")
table.add_column("BBOX intersection (%)", justify="center", style="white")

granules = await async_search_granules(
satellites=satellites, target_date=target_date, bbox=bbox,
cmask=cmask, progress=progress)
if granules:
for i, granule in enumerate(granules, start=1):
if cmask:
values = f'{i}', granule.sat, granule.timestamp, f'{granule.offset}', f'{granule.elevation:.2f}', f'{granule.cloud_cover}', f'{granule.rank}', f'{granule.pint}'
else:
values = f'{i}', granule.sat, granule.timestamp, f'{granule.offset}', f'{granule.elevation:.2f}', f'{granule.rank}', f'{granule.pint}'
table.add_row(*values)

if table.row_count == 0:
progress.console.print("[bold red]No granules found for this criteria.[/bold red]")
else:
progress.console.print(table)
progress.console.print(f"\n[dim]Note: Each granule represents {1025 / 12:.2f}s of instrument data.[/dim]")


@search.command(name='nasa', short_help=f'Search for available NTL data from NASA science archive stream')

@click.option('-b', '--bbox',
required=True,
type=BboxParamType(),
help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north'
)
@click.option("--date", "target_date",
type=click.DateTime(formats=["%Y-%m-%d"]),
required=True,
help=''
)
@click.option(
'-s', '--stream',
type=click.Choice((ARCHIVE, OPERATIONAL), case_sensitive=False),
required=True,
help=f"NASA data stream tier: '{OPERATIONAL}' for immediate low-latency processing, '{ARCHIVE}' for refined archives."
)

@click.option(
'-l', '--processing-level',
cls=ProcessingLevelChoiceOption,
required=True,
help=(
"NASA data stream processing level. Available options depend on selection of --stream. "
"For NRT: [a1, a1g, a2]. For ARCHIVE: [a1, a2, a3, a4]."
)
)

@click.pass_context
def search_nasa(ctx, bbox:tuple[numbers.Number]=None, target_date:date=None, stream:str = None, processing_level:str=None):

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

urls = nasa_search(processing_level=processing_level, target_date=target_date,
bbox=bbox, stream=stream, progress=progress)

if urls:
table = Table(title=f" {processing_level} VIIRS satellites tiles for the night of {target_date.date()} covering {bbox}",
title_style="bold yellow")
table.add_column("Product", style="red", justify='center')
table.add_column("URI", style="green", justify='center')
for url in urls:
table.add_row(*url)
progress.console.print(table)
@ntl.command(short_help=f'Download selected NTL data')
async def download():
logger.info('Downloading NTL')

@ntl.command(short_help=f'Execute crisis impact detection (48h Alerts / 72h Assessments)')
async def detect():
logger.info('Detecting impact on the ground')


@ntl.command(short_help=f'Track long-term resilience and recovery curves (2-3 Week horizon)')
async def monitor():
logger.info('Monitoring recovery')


2 changes: 1 addition & 1 deletion rapida/cli/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def validate_layers(ctx, param, value):
invalid_layers.append(layer)

if len(invalid_layers) > 0:
raise click.BadParameter(f"Invalid layer{'s' if len(invalid_layers) > 1 else ''}: {', '.join(invalid_layers)}. Valid options: {", ".join(layer_names)}")
raise click.BadParameter(f"Invalid layer{'s' if len(invalid_layers) > 1 else ''}: {', '.join(invalid_layers)}. Valid options: {', '.join(layer_names)}")

return value

Expand Down
Empty file added rapida/ntl/__init__.py
Empty file.
Empty file added rapida/ntl/nasa/__init__.py
Empty file.
Loading
Loading