From 6249a712d61d52ad3dbb0ac1421aa6f72406079b Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Sat, 30 May 2026 11:57:45 +0200 Subject: [PATCH 01/20] NTL component and variable --- rapida/cli/assess.py | 7 +++- rapida/cli/init.py | 2 + rapida/cli/ntl.py | 57 +++++++++++++++++++++++++--- rapida/components/ntl/__init__.py | 60 ++++++++++++++++++++++++++++++ rapida/components/ntl/variables.py | 41 ++++++++++++++++++++ rapida/ntl/nasa/detect.py | 0 6 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 rapida/components/ntl/__init__.py create mode 100644 rapida/components/ntl/variables.py create mode 100644 rapida/ntl/nasa/detect.py diff --git a/rapida/cli/assess.py b/rapida/cli/assess.py index 5e6c685f..60b14d28 100644 --- a/rapida/cli/assess.py +++ b/rapida/cli/assess.py @@ -167,6 +167,10 @@ def build_variable_help(): show_default=True,help=f'The year for which to compute population' ) @click.option('--datetime-range', '-dt', required=False, type=str, callback=validate_datetime_range, default=datetime.date.today().strftime('%Y-%m-%d'), help=f"Optional. Date range for landuse component in YYYY-MM-DD/YYYY-MM-DD format or single date YYYY-MM-DD (12 months range). Only valid when 'landuse' component is selected. Start date must be after end date, end date must be before today, and at least 1 day apart.") + +@click.option("--outage-date", "outage_date", type=click.DateTime(formats=["%Y-%m-%d"]), required=False, + help='The human experience of a specific night, local time zone matched to the center of bbox') + @click.option('--cloud-cover', '-cc', required=False, type=int, multiple=False, default=5, show_default=True,help=f"Optional. Minimum cloud cover rate to search items for landuse component.") @click.option('-p', '--project', @@ -181,7 +185,7 @@ def build_variable_help(): help="Set log level to debug" ) @click.pass_context -def assess(ctx, all=False, components=None, variables=None, year=None, datetime_range=None, cloud_cover=None, project: str = None, force=False, debug=False): +def assess(ctx, all=False, components=None, variables=None, year=None, datetime_range=None, outage_date=None, cloud_cover=None, project: str = None, force=False, debug=False): """ Assess/evaluate a specific geospatial exposure components/variables @@ -265,6 +269,7 @@ def assess(ctx, all=False, components=None, variables=None, year=None, datetime variables=variables, target_year=year, datetime_range=datetime_range, + outage_date=outage_date, cloud_cover=cloud_cover, force=force) diff --git a/rapida/cli/init.py b/rapida/cli/init.py index aa9f10e3..3ff4f362 100644 --- a/rapida/cli/init.py +++ b/rapida/cli/init.py @@ -10,6 +10,7 @@ from rapida.components.deprivation.variables import generate_variables as gen_depriv_vars from rapida.components.landuse.variables import generate_variables as gen_landuse_vars from rapida.components.gdp.variables import generate_variables as gen_gdp_vars +from rapida.components.ntl.variables import generate_variables as gen_ntl_vars from rapida.util.setup_logger import setup_logger logger = logging.getLogger(__name__) @@ -62,6 +63,7 @@ def setup_prompt(session: Session): "elegrid": gen_electric_vars(), "landuse": gen_landuse_vars(), "gdp": gen_gdp_vars(), + "ntl": gen_ntl_vars() } } session.config.update(vars_dict) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index 661bfa49..69f381c6 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -16,7 +16,7 @@ from rapida.ntl.noaa.io import download as download_from_noaa, bytesto from rich.table import Table from rapida.ntl.nasa.io import bulk_download as bdownload - +from rapida.project.project import Project logger = logging.getLogger(__name__) @@ -424,14 +424,59 @@ async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetim ) +@ntl.command(short_help=f'Find and download best NTL data for a specific area and time ') +@click.option('-b', '--bbox', + required=True, + type=BboxParamType(), + help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north' + ) +@click.option("--from", "start_date", + type=click.DateTime(formats=["%Y-%m-%d"]), + required=True, + help='The start date of required period' + ) +@click.option("--to", "end_date", + type=click.DateTime(formats=["%Y-%m-%d"]), + required=True, + help='The end date of required period' + ) +@click.option( + '-p','--products', + # type=click.Choice(PRODUCTS, case_sensitive=False), + cls=NASAProductsChoiceOption, + #callback=validate_products_strict, + required=True, + multiple=True, + help=f"One or more STAC collections hosting different processing level or products limited to one stream. " + ) +@click.option( + "--dst-dir", + "dst_dir", # Function argument name + type=click.Path( + exists=False, # Set to True if you want Click to fail if the dir doesn't exist yet + file_okay=False, # Strictly enforce that this is a directory, not a file + dir_okay=True, + resolve_path=True # Resolves relative paths (like '.') to absolute paths automatically + ), + default=tempfile.gettempdir(), # Defaults to the current working directory + show_default=True, # Tells the user what the default is in the --help menu + help="Destination directory to save the downloaded the images." +) + + +@click.pass_context +async def fetch(ctx): + progress = ctx.obj.get('progress') + + + +@ntl.command(short_help=f'Execute crisis impact detection (48h Alerts / 72h Assessments)') +@click.pass_context +async def detect(ctx): + logger.info('Detecting impact on the ground') -# @ntl.command(short_help=f'Execute crisis impact detection (48h Alerts / 72h Assessments)') -# @click.pass_context -# async def detect(ctx): -# 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(): diff --git a/rapida/components/ntl/__init__.py b/rapida/components/ntl/__init__.py new file mode 100644 index 00000000..29224ad8 --- /dev/null +++ b/rapida/components/ntl/__init__.py @@ -0,0 +1,60 @@ +from rapida.core.component import Component +from rapida.core.variable import Variable +from rapida.project.project import Project +from rapida.session import Session +import logging +import os + +logger = logging.getLogger('rapida') + +class NtlComponent(Component): + + def __call__(self, variables: list[str], **kwargs): + if not variables: + variables = self.variables + else: + for var_name in variables: + if var_name not in self.variables: + logger.error(f'variable "{var_name}" is invalid. Valid options are "{", ".join(self.variables)}"') + return + + with Session() as session: + variable_data = session.get_component(self.component_name) + + for var_name in variables: + var_data = variable_data[var_name] + + v = NTLVariable( + name=var_name, + component=self.component_name, + + **var_data + ) + v(**kwargs) + + +class NTLVariable(Variable): + + + def __init__(self, **kwargs): + + super().__init__(**kwargs) + project = Project(path=os.getcwd()) + geopackage_path = project.geopackage_file_path + output_filename = f"{self.name}.tif" + self.local_path = os.path.join(os.path.dirname(geopackage_path), self.component, output_filename) + + def download(self,force=False, **kwargs): + pass + + def download(self, **kwargs): + pass + def resolve(self, **kwargs): + pass + + def compute(self, **kwargs): + pass + def evaluate(self, **kwargs): + pass + def __call__(self, *args, **kwargs): + print(self.name) \ No newline at end of file diff --git a/rapida/components/ntl/variables.py b/rapida/components/ntl/variables.py new file mode 100644 index 00000000..6adc3856 --- /dev/null +++ b/rapida/components/ntl/variables.py @@ -0,0 +1,41 @@ +from collections import OrderedDict +import logging + +logger = logging.getLogger(__name__) + + +def generate_variables(): + """ + Generate relative wealth index variables dict + :return RWI variables definition + """ + + # https://www.earthdata.nasa.gov/data/catalog/sedac-ciesin-sedac-pmp-grdi-2010-2020-1.00 + license = "Creative Commons Zero (CC0 1.0)" + attribution = "NASA Black Marble data courtesy of the NASA Goddard Space Flight Center’s Terrestrial Information Systems Laboratory and the Earth from Space Institute (EfSI)." + + variables = OrderedDict() + variables['noaa_nrt_outage'] = dict(title='Outage detected through NOAA real time data', + source=f"NOAA", + operator='sum', + percentage=True, + license=license, + attribution="Data sourced from the NOAA Open Data Dissemination (NODD) Program, utilizing the Joint Polar Satellite System (JPSS) VIIRS Sensor Data Records hosted on [AWS Open Data / Google Cloud Public Datasets].", + ) + variables['nasa_oper_outage'] = dict(title='Outage detected through NASA Black Marble operational (LANCEMODIS) data', + source=f"NASA", + operator='sum', + percentage=True, + license=license, + attribution=attribution, + ) + + variables['nasa_arch_outage'] = dict(title='Outage detected through NASA Black Marble archived (LAADS) data', + source=f"NASA", + operator='sum', + percentage=True, + license=license, + attribution=attribution, + ) + + return variables \ No newline at end of file diff --git a/rapida/ntl/nasa/detect.py b/rapida/ntl/nasa/detect.py new file mode 100644 index 00000000..e69de29b From 91cbeeb7007fda0ca6152a65d8a38fc5261c2f45 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Tue, 2 Jun 2026 22:12:52 +0200 Subject: [PATCH 02/20] outage detection from NASA --- rapida/cli/ntl.py | 113 ++++++------ rapida/components/ntl/variables.py | 4 +- rapida/ntl/fetch.py | 267 +++++++++++++++++++++++++++++ rapida/ntl/nasa/const.py | 2 +- rapida/ntl/nasa/detect.py | 0 rapida/ntl/nasa/io.py | 17 +- rapida/ntl/nasa/outage.py | 94 ++++++++++ rapida/ntl/nasa/search.py | 60 +++++-- rapida/ntl/noaa/search.py | 24 +-- rapida/ntl/vis.py | 113 ++++++++++++ 10 files changed, 604 insertions(+), 90 deletions(-) create mode 100644 rapida/ntl/fetch.py delete mode 100644 rapida/ntl/nasa/detect.py create mode 100644 rapida/ntl/nasa/outage.py create mode 100644 rapida/ntl/vis.py diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index 69f381c6..1c066d21 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -16,7 +16,8 @@ from rapida.ntl.noaa.io import download as download_from_noaa, bytesto from rich.table import Table from rapida.ntl.nasa.io import bulk_download as bdownload -from rapida.project.project import Project +from rapida.ntl.fetch import DELIVERABLES, fetch as fetch_ntl +from rapida.ntl.nasa.outage import detect_outage logger = logging.getLogger(__name__) @@ -41,27 +42,7 @@ def handle_parse_result(self, ctx, opts, args): return super().handle_parse_result(ctx, opts, args) -def validate_products_strict(ctx, param, value): - if not value: - return value - - # Check for mixed catalogs - has_nrt = any('nrt' in p.lower() for p in value) - has_std = any('nrt' not in p.lower() for p in value) - nrt_choices = [item for p in COLLECTIONS['LANCEMODIS'].values() for item in p] - std_choices = [item for p in COLLECTIONS['LAADS'].values() for item in p] - if has_nrt and has_std: - raise click.BadParameter( - f"Cannot mix NRT and Standard products in the same command. " - f"They belong to different catalogs:\n" - f"LANCEMODIS - NOAA operational: {', '.join(nrt_choices)}\n" - f"LAADS - NASA archive : {', '.join(std_choices)}" - ) - - if has_nrt: - return tuple(nrt_choices) - else: - return tuple(std_choices) + class NASAProductsChoiceOption(click.Option): """ @@ -132,10 +113,10 @@ def search(): @click.pass_context -async def search_noaa(ctx, bbox:tuple[numbers.Number]=None, target_date:datetime=None, satellites:list[str] = [], cmask:bool=None ): +async def search_noaa(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=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}", + table = Table(title=f"VIIRS satellites granules for the night of {nominal_date.date()} covering {bbox}", title_style="bold yellow") table.add_column("Position", justify="center", style="white") table.add_column("Satellite", style="green", justify='center') @@ -149,7 +130,7 @@ async def search_noaa(ctx, bbox:tuple[numbers.Number]=None, target_date:datetime table.add_column("BBOX intersection (%)", justify="center", style="white") granules = await async_search_granules( - satellites=satellites, target_date=target_date, bbox=bbox, + satellites=satellites, nominal_date=nominal_date, bbox=bbox, cmask=cmask, progress=progress) if granules: for i, granule in enumerate(granules, start=1): @@ -301,9 +282,8 @@ async def download_nasa(ctx, timestamp:str = None, product:str=None, tile:str=No ) @click.option("--timestamp", "-t", "timestamp", type=str, required=True, help='Granule timestamp string as date and time. Ex: 202604152232 ') -@click.option( - "--products", - "-p", +@click.option("-p", + "--products" "products", type=click.Choice(OPER_PRODUCT_NAMES, case_sensitive=False), default=OPER_PRODUCT_NAMES, @@ -424,33 +404,64 @@ async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetim ) -@ntl.command(short_help=f'Find and download best NTL data for a specific area and time ') +@ntl.command(short_help=f'Find and download best NTL data for a specific event associated with an area and date ') @click.option('-b', '--bbox', required=True, type=BboxParamType(), help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north' ) -@click.option("--from", "start_date", + + +@click.option("--date", "nominal_date", type=click.DateTime(formats=["%Y-%m-%d"]), required=True, - help='The start date of required period' + help='The human experience of a specific night, local time zone matched to the center of bbox' ) -@click.option("--to", "end_date", + +@click.option( + "--dst-dir", + "dst_dir", # Function argument name + type=click.Path( + exists=False, # Set to True if you want Click to fail if the dir doesn't exist yet + file_okay=False, # Strictly enforce that this is a directory, not a file + dir_okay=True, + resolve_path=True # Resolves relative paths (like '.') to absolute paths automatically + ), + default=tempfile.gettempdir(), # Defaults to the current working directory + show_default=True, # Tells the user what the default is in the --help menu + help="Destination directory to save the downloaded the images." +) + +@click.option("-d","deliverable", + type=click.Choice(DELIVERABLES, case_sensitive=False), + required=True, + help=f'One or more of the RAPIDA NTL deliverables.' + ) + + +@click.pass_context +async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None): + + progress = ctx.obj.get('progress') + return await fetch_ntl(bbox=bbox,nominal_date=nominal_date, progress=progress, deliverable=deliverable, dst_dir=dst_dir) + + +@ntl.command(short_help=f'Execute crisis impact detection (48h Alerts / 72h Assessments)') + +@click.option('-b', '--bbox', + required=True, + type=BboxParamType(), + help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north' + ) + + +@click.option("--date", "nominal_date", type=click.DateTime(formats=["%Y-%m-%d"]), required=True, - help='The end date of required period' + help='The human experience of a specific night, local time zone matched to the center of bbox' ) -@click.option( - '-p','--products', - # type=click.Choice(PRODUCTS, case_sensitive=False), - cls=NASAProductsChoiceOption, - #callback=validate_products_strict, - required=True, - multiple=True, - help=f"One or more STAC collections hosting different processing level or products limited to one stream. " - ) @click.option( "--dst-dir", "dst_dir", # Function argument name @@ -465,19 +476,25 @@ async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetim help="Destination directory to save the downloaded the images." ) +@click.option("-d","deliverable", + type=click.Choice(DELIVERABLES, case_sensitive=False), + required=True, + help=f'One or more of the RAPIDA NTL deliverables.' + ) + @click.pass_context -async def fetch(ctx): +async def detect(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None): progress = ctx.obj.get('progress') + return await detect_outage( + bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, dst_dir=dst_dir, + progress=progress + ) + -@ntl.command(short_help=f'Execute crisis impact detection (48h Alerts / 72h Assessments)') -@click.pass_context -async def detect(ctx): - 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') diff --git a/rapida/components/ntl/variables.py b/rapida/components/ntl/variables.py index 6adc3856..9e229a71 100644 --- a/rapida/components/ntl/variables.py +++ b/rapida/components/ntl/variables.py @@ -15,14 +15,14 @@ def generate_variables(): attribution = "NASA Black Marble data courtesy of the NASA Goddard Space Flight Center’s Terrestrial Information Systems Laboratory and the Earth from Space Institute (EfSI)." variables = OrderedDict() - variables['noaa_nrt_outage'] = dict(title='Outage detected through NOAA real time data', + variables['noaa_oper_outage'] = dict(title='Outage detected through NOAA real time data', source=f"NOAA", operator='sum', percentage=True, license=license, attribution="Data sourced from the NOAA Open Data Dissemination (NODD) Program, utilizing the Joint Polar Satellite System (JPSS) VIIRS Sensor Data Records hosted on [AWS Open Data / Google Cloud Public Datasets].", ) - variables['nasa_oper_outage'] = dict(title='Outage detected through NASA Black Marble operational (LANCEMODIS) data', + variables['nasa_nrt_outage'] = dict(title='Outage detected through NASA Black Marble operational (LANCEMODIS) data', source=f"NASA", operator='sum', percentage=True, diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py new file mode 100644 index 00000000..615eb3bc --- /dev/null +++ b/rapida/ntl/fetch.py @@ -0,0 +1,267 @@ +import json +import os.path +from datetime import datetime, timedelta +import numbers +import logging +from itertools import product +from rich.progress import Progress +from rapida.components.ntl.variables import generate_variables +from rapida.ntl.nasa import const as nasaconst +from rapida.ntl.nasa.util import get_intersecting_tiles +from rapida.util.geo import gdal_callback +import h5py +from rapida.ntl.nasa.search import calculate_local_utc +import fsspec +import urllib +import secrets +from rapida.ntl.nasa.io import download +from rapida.ntl.nasa.search import search +from rapida.ntl.nasa.io import download +from osgeo import gdal + +DELIVERABLES = tuple([g.upper() for g in generate_variables()]) +logger = logging.getLogger('rapida') + + +def build_viirs_vrt( + paths: list[str], + sds_name: str, + is_remote:bool=False, + vrt_path: str = None, + bbox: tuple[float, float, float, float] = None +) -> str: + """ + Creates an in-memory mosaicked VRT from an iterable of local or remote VIIRS HDF5 files. + Bypasses GDAL's internal georeferencing failures by building explicit XML. + """ + if not paths: + raise ValueError("The paths iterable cannot be empty.") + + # 1. Setup Disk-less Auth (RAM only) + storage_options = {} + header_file_path = "/vsimem/gdal_auth.txt" + + if is_remote: + token = os.environ.get('EARTHDATA_TOKEN') + # Add this sanity check + if not token: + raise ValueError("CRITICAL: EARTHDATA_TOKEN environment variable is not set or is empty!") + storage_options = { + "block_size": 1024 * 1024, + "headers": {"Authorization": f"Bearer {token}"} + } + # Inject the header file directly into GDAL's virtual memory + gdal.FileFromMemBuffer(header_file_path, f"Authorization: Bearer {token}\r\n".encode('utf-8')) + + hdf_root = "HDFEOS/GRIDS/VIIRS_Grid_DNB_2d/Data_Fields" + dataset_path = f"{hdf_root}/{sds_name}" + + tile_vrts = [] + global_metadata = {} + # 2. Process each path to create geographically aware sub-VRTs + for i, path in enumerate(paths): + # Use fsspec to natively handle both local OS files and HTTP streams + with fsspec.open(path, "rb", **(storage_options if is_remote else {})) as f: + with h5py.File(f, 'r') as hfile: + + + # Extract Extent (Required for EVERY tile to mosaic correctly) + def get_val(key): + v = hfile.attrs[key] + return float(v[0]) if isinstance(v, (list, tuple)) or hasattr(v, '__iter__') else float(v) + + west = get_val('WestBoundingCoord') + north = get_val('NorthBoundingCoord') + east = get_val('EastBoundingCoord') + south = get_val('SouthBoundingCoord') + + # Extract Dimension & Metadata ONLY from the FIRST file + if i == 0: + try: + target_ds = hfile[dataset_path] + except KeyError as ke: + target_ds = hfile[dataset_path.replace('Data_Fields', 'Data Fields')] + + height, width = target_ds.shape[-2:] + + for k, v in target_ds.attrs.items(): + if isinstance(v, bytes): + v = v.decode('utf-8') + elif hasattr(v, '__iter__') and not isinstance(v, str): + v = v[0].decode('utf-8') if isinstance(v[0], bytes) else str(v[0]) + global_metadata[k] = str(v) + + nodata_value = float(global_metadata.get('_FillValue', -999.9)) + + # Calculate Geotransform for THIS specific tile + px_w = (east - west) / width + px_h = (south - north) / height + + # Construct GDAL connection string + if is_remote: + encoded = urllib.parse.quote(path, safe="") + auth_str = f"&header_file={header_file_path}" if token else "" + vsi = f"/vsicurl?empty_dir=yes{auth_str}&url={encoded}" + conn_str = f'HDF5:"{vsi}"://{dataset_path}' + else: + abs_path = os.path.abspath(path) + conn_str = f'HDF5:"{abs_path}"://{dataset_path}' + _, file_name = os.path.split(path) + + # XML Template (EPSG:4326 is the immutable standard for VIIRS L3/L4) + tile_xml = f""" + EPSG:4326 + {west}, {px_w}, 0.0, {north}, 0.0, {px_h} + + {nodata_value} + + {conn_str} + 1 + + + + + """ + + # Save individual tile VRT to memory + tile_vrt_path = f"/vsimem/{file_name}{secrets.token_hex(6)}.vrt" + gdal.FileFromMemBuffer(tile_vrt_path, tile_xml.encode('utf-8')) + tile_vrts.append(tile_vrt_path) + + # 3. Mosaic all virtual tiles into the final master VRT + vrt_opts = gdal.BuildVRTOptions( + outputBounds=bbox, + outputSRS='EPSG:4326', + resolution='highest', + resampleAlg=gdal.GRA_NearestNeighbour, + srcNodata=nodata_value, + VRTNodata=nodata_value + ) + + master_ds = gdal.BuildVRT(vrt_path, tile_vrts, options=vrt_opts) + if master_ds is None: + raise RuntimeError("GDAL failed to build the master VRT.") + + # 4. Inject the unified metadata & explicitly set NoData + band = master_ds.GetRasterBand(1) + band.SetMetadata(global_metadata) + band.SetNoDataValue(nodata_value) + + master_ds.FlushCache() + master_ds = None + + + + if is_remote: + tile_vrts+=[vrt_path, header_file_path] + + else: + tile_vrts+=[vrt_path] + return tile_vrts + + + +async def download_and_extract(urls:list[str]=None, stream:str=None, route=None, processing_level=None, expected_tiles:list[str]=None, + bbox: tuple[float, float, float, float] = None, vsimem=False, deliverable=None, dst_dir:str=None, progress=None): + + + + # search returns tuple product, timestamp, tile, url + products = tuple(sorted(set([e[0] for e in urls]))) # logic HOLDS because J is before N, newer is preferred + selected_product = products[0] + selected_urls = dict([(e[-1], e[1]) for e in urls if e[0] == selected_product]) + if len(selected_urls) != len(expected_tiles): + logger.info( + f'Expected to get {len(expected_tiles)} for {selected_product} stream {stream} and processing level {processing_level} over route {route}. Got {len(selected_urls)}') + return + timestamps = set(selected_urls.values()) + if len(timestamps) > 1: + logger.info( + f'Got more than one timestamp for for {selected_product} stream {stream} and processing level {processing_level} over route {route} ') + return + timestamp, = timestamps + urls = list(selected_urls) + sds_name = nasaconst.SUB_DATASETS.get(processing_level) + if not sds_name: + raise ValueError(f"Processing level '{processing_level}' not found in mapping.") + vrt_path = f'/vsimem/{selected_product}_{timestamp}_{secrets.token_hex(6)}.vrt' + to_unlink = [] + if not vsimem: + downloaded_files = await download(timestamp=timestamp, product=selected_product, dst_dir=dst_dir, urls=urls, + progress=progress) + to_unlink += build_viirs_vrt(paths=downloaded_files, sds_name=sds_name, is_remote=False, bbox=bbox, + vrt_path=vrt_path) + else: + to_unlink += build_viirs_vrt(paths=urls, sds_name=sds_name, is_remote=True, bbox=bbox, vrt_path=vrt_path) + + to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] + _, vrt_name = os.path.split(vrt_path) + output_tif_path: str = os.path.join(dst_dir, vrt_name.replace('.vrt', '.tif')) + + translate_options = dict( + format="GTiff", + creationOptions=[ + "TILED=YES", # Optimizes read performance + "BIGTIFF=IF_SAFER", + "COPY_SRC_MDD=YES" + ], + outputSRS='EPSG:4326', + + ) + + if progress: + task = progress.add_task(f'[red]Extracting data using GDAL') + callback_dict = dict( + callback=gdal_callback, + callback_data=(progress, task, None) + ) + translate_options.update(callback_dict) + + # Pass the dataset object directly instead of the string path + ds = gdal.Translate(destName=output_tif_path, srcDS=vrt_path, **translate_options) + ds = None + + [gdal.Unlink(e) for e in to_unlink] + return output_tif_path + + + + +async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, + dst_dir:str=None, progress:Progress=None, vsimem=False): + """ + Indentify and download the BEST quality available data suitable to detect outages. + :param bbox: + :param nominal_date: + :param progress: + :return: + """ + deliverable = deliverable.lower() + expected_tiles = get_intersecting_tiles(bbox=bbox) + + if 'noaa' in deliverable: # operational real time data + pass + routes = nasaconst.ROUTES + stream = nasaconst.ARCHIVE + if deliverable == 'baseline': + processing_levels = ['A3'] + if 'nasa' in deliverable: #data from NASA LAADS catalogs + processing_levels = 'A2', 'A1' # best daily data ??? + if 'nrt' in deliverable: + stream = nasaconst.OPERATIONAL + for processing_level, route in product(processing_levels, routes): + + urls = search(processing_level=processing_level, nominal_date=nominal_date, bbox=bbox, + stream=stream, route=route,progress=progress, push_to_cache=False + ) + if not urls: + logger.debug( + f'No data was found for deliverable {deliverable} at processing level {processing_level} through route {route}') + continue + + + return await download_and_extract(urls=urls, stream=stream, route=route, processing_level=processing_level, expected_tiles=expected_tiles, + bbox=bbox, vsimem=vsimem, deliverable=deliverable, dst_dir=dst_dir, progress=progress) + + + diff --git a/rapida/ntl/nasa/const.py b/rapida/ntl/nasa/const.py index 2ea5c6c3..39585626 100644 --- a/rapida/ntl/nasa/const.py +++ b/rapida/ntl/nasa/const.py @@ -59,7 +59,7 @@ "A1G": "DNB_At_Sensor_Radiance", "A2": "DNB_BRDF-Corrected_NTL", "A3": "AllAngle_Composite_Snow_Free", - "A4": "NearNadir_Composite_Snow_Free" + "A4": "AllAngle_Composite_Snow_Free" } PROCESSING_LEVELS = {stream_name: list(stream_data.keys()) for stream_name, stream_data in COLLECTIONS.items()} PROCESSING_LEVEL_NAMES = {CATALOG2STREAM[stream_name]: list(stream_data.keys()) for stream_name, stream_data in COLLECTIONS.items()} diff --git a/rapida/ntl/nasa/detect.py b/rapida/ntl/nasa/detect.py deleted file mode 100644 index e69de29b..00000000 diff --git a/rapida/ntl/nasa/io.py b/rapida/ntl/nasa/io.py index 0dd51401..199f9d0e 100644 --- a/rapida/ntl/nasa/io.py +++ b/rapida/ntl/nasa/io.py @@ -14,17 +14,15 @@ logger = logging.getLogger(__name__) -async def download(timestamp: str = None, product: str = None, tile:str=None, dst_dir:str=None, progress:Progress=None): - - - key = f'{product.upper()}_{timestamp}' - urls = cache.fetch(key=key, tile=tile) - +async def download(timestamp: str = None, product: str = None, tile:str=None, dst_dir:str=None, urls:list[str]=None, progress:Progress=None): if not urls: - logger.info(f'Failed to locate information in {cache.CACHE_PATH} for {product}-{timestamp}-{tile or ""} \n' \ - f'Consider searching first.') - return + key = f'{product.upper()}_{timestamp}' + urls = cache.fetch(key=key, tile=tile) + if not urls: + logger.info(f'Failed to locate information in {cache.CACHE_PATH} for {product}-{timestamp}-{tile or ""} \n' \ + f'Consider searching first.') + return # EarthAccess token ea_token = os.environ.get('EARTHDATA_TOKEN') @@ -34,6 +32,7 @@ async def download(timestamp: str = None, product: str = None, tile:str=None, ds raise ValueError("CRITICAL: EARTHDATA_TOKEN environment variable is not set or is empty!") headers = {"Authorization": f"Bearer {ea_token}"} + return await download_remote_files( file_urls=urls,dst_folder=dst_dir, progress=progress, headers=headers ) diff --git a/rapida/ntl/nasa/outage.py b/rapida/ntl/nasa/outage.py new file mode 100644 index 00000000..2a62efb2 --- /dev/null +++ b/rapida/ntl/nasa/outage.py @@ -0,0 +1,94 @@ + +from datetime import datetime, timedelta +import numbers +import logging +from rich.progress import Progress +from rapida.components.ntl.variables import generate_variables +from rapida.ntl.fetch import fetch +from scipy.ndimage import uniform_filter +import numpy as np +import rasterio +from scipy.ndimage import label +from rapida.ntl import vis +DELIVERABLES = tuple([g.upper() for g in generate_variables()]) +logger = logging.getLogger('rapida') + +def spatial_filter(outage_map, min_size=2): + # 1. Group connected pixels into "clumps" + labeled_array, num_features = label(outage_map) + + # 2. Count how many pixels are in each clump + clump_sizes = np.bincount(labeled_array.ravel()) + + # 3. Create a mask of clumps that meet your size requirement + mask_size = clump_sizes >= min_size + + # 4. Filter the original map (clump 0 is the background, so we ignore it) + mask_size[0] = 0 + return mask_size[labeled_array] + + +def pure_numpy_ssim(nrt_masked: np.ma.MaskedArray, baseline_masked: np.ma.MaskedArray, win_size: int = 7) -> np.ndarray: + """ + Computes the Structural Similarity Index (SSIM) between two images + using purely NumPy and SciPy uniform filters. + + Returns a 2D NumPy array matching the input dimensions (values from -1 to 1). + """ + # 1. Fill masked regions with 0.0 so they don't break the rolling windows + img1 = nrt_masked.filled(0.0) + img2 = baseline_masked.filled(0.0) + + # 2. Dynamic range stability constants (C1, C2) + # Based on log1p values, data max is roughly 9.0 to 11.0 + data_range = max(img1.max(), img2.max()) - min(img1.min(), img2.min()) + K1, K2 = 0.01, 0.03 + C1 = (K1 * data_range) ** 2 + C2 = (K2 * data_range) ** 2 + + # 3. Compute local means (mu) using a uniform box filter + mu1 = uniform_filter(img1, size=win_size, mode='reflect') + mu2 = uniform_filter(img2, size=win_size, mode='reflect') + + mu1_sq = mu1 ** 2 + mu2_sq = mu2 ** 2 + mu1_mu2 = mu1 * mu2 + + # 4. Compute local variances (sigma_sq) and covariance (sigma_12) + # Formula: Var(X) = E[X^2] - (E[X])^2 + sigma1_sq = uniform_filter(img1 ** 2, size=win_size) - mu1_sq + sigma2_sq = uniform_filter(img2 ** 2, size=win_size) - mu2_sq + sigma12 = uniform_filter(img1 * img2, size=win_size) - mu1_mu2 + + # 5. Compute full SSIM map + num = (2 * mu1_mu2 + C1) * (2 * sigma12 + C2) + den = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2) + ssim_map = num / den + + return ssim_map + + + +async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, + dst_dir:str=None, progress:Progress=None): + + logger.info(f'Fetching best imagery for {bbox} {nominal_date}') + target_data = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable=deliverable, + dst_dir=dst_dir) + + logger.info(f'Fetching baseline imagery for {bbox} {nominal_date}') + baseline_data = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable='baseline', + dst_dir=dst_dir) + data = {} + with rasterio.open(target_data) as target, rasterio.open(baseline_data) as base: + nrt = np.log1p(target.read(1, masked=True)) + baseline = np.log1p(base.read(1, masked=True)) + ssim = pure_numpy_ssim(nrt, baseline, win_size=7) + anomaly_mask = (ssim < 0.4) & (baseline.filled(0.0) > 1.5) & (~np.ma.getmaskarray(nrt)) + clusters = spatial_filter(anomaly_mask, min_size=5) + data[target_data] = nrt + data[baseline_data] = baseline + data['ssim'] = ssim + data['anom'] = clusters + vis.display1(data=data, title=f'All relevant processing levels imagery for {bbox} on {nominal_date.date()}') + diff --git a/rapida/ntl/nasa/search.py b/rapida/ntl/nasa/search.py index 89dbd834..9d781947 100644 --- a/rapida/ntl/nasa/search.py +++ b/rapida/ntl/nasa/search.py @@ -11,6 +11,7 @@ from rapida.ntl.nasa.util import get_intersecting_tiles import httpx from typing import Optional +from rapida.util.http_get_json import http_get_json logger = logging.getLogger(__name__) @@ -42,7 +43,7 @@ def calculate_night_hours(midlat: float, day_of_year: int) -> int: return int(round(night_hrs)) -def url2result(url:str=None, store=True): +def url2result(url:str, store=True): _, file_name = os.path.split(url) match = const.NTL_FILENAME_PATTERN.match(file_name) @@ -58,7 +59,8 @@ def url2result(url:str=None, store=True): -def calculate_local_utc(stream:str, processing_level:str, nominal_date: datetime, bbox:tuple[float], route:str=None): +def calculate_local_utc(stream:str, processing_level:str, nominal_date: datetime, bbox:tuple[float, float, float, float], + route:str=None, products:list[str] =None): """ Calculate VIIRS satellites local overpass time in UTC TZ :param stream: @@ -85,13 +87,28 @@ def calculate_local_utc(stream:str, processing_level:str, nominal_date: datetime dt = local_overpass_utc elif 'A3' in plevel: - day = 15 if route == 'STAC' else 1 - # A3 Monthly composites target mid-month. If current month, step back one month. - if now.month == nominal_date.month and now.year == nominal_date.year: - prev_month = nominal_date.replace(day=1) - timedelta(days=1) - dt = prev_month.replace(day=day) + + if route == 'STAC': + day=15 + # A3 Monthly composites target mid-month. If current month, step back one month. + if now.month == nominal_date.month and now.year == nominal_date.year: + dt = nominal_date.replace(month=now.month-1, day=day) + else: + dt = nominal_date.replace(day=day) else: - dt = nominal_date.replace(day=day) + months_back = 3 + day = 1 + for m in range(1, months_back+1): + exists = True + for product in products: + dt = nominal_date.replace(month=now.month - m, day=day) + content_url = f'{const.API_CONTENT[stream]}/{product}/{dt.strftime("%Y/%j")}' + try: + content = http_get_json(url=content_url, timeout=10) + exists &= content is not None + except httpx.HTTPStatusError: + exists = False + if exists:break elif 'A4' in plevel: if now.year > nominal_date.year: raise ValueError(f'Can not search in future! Please adjust target date') @@ -105,16 +122,20 @@ def calculate_local_utc(stream:str, processing_level:str, nominal_date: datetime return dt -def api_search(stream:str, products:str, dt:datetime, bbox:tuple[float], push_to_cache:bool=True)-> list[str]: +def api_search(stream:str, products:str, dt:datetime, bbox:tuple[float, float, float, float], push_to_cache:bool=True)-> list[str]: tiles = get_intersecting_tiles(bbox=bbox) urls = [] + logger.info( + f'Searching for imagery in products "{products}') for product in products: - content_url = f'{const.API_CONTENT[stream]}/{product}/{dt.strftime("%Y/%j")}' with httpx.Client() as client: # Fetch the JSON directory listing response = client.get(content_url, timeout=10.0) - response.raise_for_status() + try: + response.raise_for_status() + except httpx.HTTPStatusError: + continue # MODAPS returns a flat JSON array of file objects payload = response.json()['content'] for item in payload: @@ -129,7 +150,7 @@ def api_search(stream:str, products:str, dt:datetime, bbox:tuple[float], push_to return urls def stac_search(stream:str=None, processing_level:Optional[str]=None, products:list[str]=None, dt:datetime=None, - bbox:tuple[float]=None, push_to_cache:bool=True): + bbox:tuple[float, float, float, float]=None, push_to_cache:bool=True): catalog_name = const.STREAM2CATALOG[stream] catalog_collections = const.COLLECTIONS[catalog_name] @@ -197,26 +218,27 @@ def search( assert processing_level.upper() in stream_processing_levels, ( f'Invalid processing level {processing_level} for {stream_name}. \'' f'Valid processing levels {stream_processing_levels}') - - dt = calculate_local_utc(stream=stream,processing_level=processing_level, - nominal_date=nominal_date, bbox=bbox, route=route) products = stream_products[processing_level] + dt = calculate_local_utc(stream=stream,processing_level=processing_level, + nominal_date=nominal_date, bbox=bbox, route=route, products=products) + cached_results = [] expected_products_count = len(products) found_products_count = 0 - + keys = [] for product in products: timestamp = dt.strftime(timestamp_format(product_id=product)) key = f'{product}_{timestamp}' urls = cache.fetch(key=key) if urls: found_products_count += 1 + keys.append(key) for url in urls: cached_results.append(url2result(url=url, store=False)) # Only short-circuit if the cache successfully returned data for EVERY product requested if found_products_count == expected_products_count: - logger.info("Full cache hit. Bypassing network search.") + logger.info(f"Full cache hit for {keys}. Bypassing network search.") return cached_results # --- 2. Catalog Search --- if progress: @@ -230,6 +252,7 @@ def search( products=products, dt=dt, bbox=bbox, + push_to_cache=push_to_cache ) else: urls = stac_search( @@ -237,10 +260,11 @@ def search( processing_level=processing_level, dt=dt, bbox=bbox, + push_to_cache=push_to_cache ) if not urls: - logger.info(f"No imagery found for {processing_level} on {nominal_date.date()}") + logger.info(f"No imagery found for stream {stream} route {route} level {processing_level} on {nominal_date.date()}") return else: if progress and 'progress_task' in locals(): diff --git a/rapida/ntl/noaa/search.py b/rapida/ntl/noaa/search.py index 2e1efc5a..7d2650df 100644 --- a/rapida/ntl/noaa/search.py +++ b/rapida/ntl/noaa/search.py @@ -402,12 +402,12 @@ def pass2granule(self, p:DescendingPass=None, midlon:float=None, midlat:float=No return Granule(sat=self.satellite,start_time=start_time,offset=offset_km, elevation=elevation) - def night_passes(self, bbox:Iterable[float]=None, target_date:date=None): + def night_passes(self, bbox:Iterable[float]=None, nominal_date:date=None): midlon, midlat, northlat = self.decompose_bbox(bbox=bbox) # 1. NIGHT DURATION (Use Mid-Lat for 'Average' Night) - doy = target_date.timetuple().tm_yday + doy = nominal_date.timetuple().tm_yday declination = 0.409 * math.sin(2 * math.pi * (doy - 80) / 365) lat_rad = math.radians(midlat) cos_h = -math.tan(lat_rad) * math.tan(declination) @@ -415,12 +415,12 @@ def night_passes(self, bbox:Iterable[float]=None, target_date:date=None): # 2. THE ANCHOR (01:30 AM Local -> UTC) - utc_anchor = datetime.combine(target_date, dtime(1, 30)) - timedelta(hours=midlon / 15.0) + utc_anchor = datetime.combine(nominal_date, dtime(1, 30)) - timedelta(hours=midlon / 15.0) # 3. THE TRIGGER (Use North-Lat to find when the satellite ENTERS the box) search_start = utc_anchor - timedelta(hours=night_hrs / 2) night_passes = self.orb.get_next_passes(search_start, night_hrs, midlon, midlat, 0) # northlat??? - logger.debug(f'{self.satellite} passes {len(night_passes)} time(s) over {list(bbox)} on the night of {target_date:%y-%m-%d}') + logger.debug(f'{self.satellite} passes {len(night_passes)} time(s) over {list(bbox)} on the night of {nominal_date:%y-%m-%d}') passes = [] for _pass_ in night_passes: rise_time, fall_time, max_elev_time = _pass_ @@ -432,15 +432,15 @@ def night_passes(self, bbox:Iterable[float]=None, target_date:date=None): logger.debug(f'Skipping ascending pass {_pass_}') continue p = DescendingPass(sat=self.satellite, rise_time=rise_time, fall_time=fall_time, - max_elev_time=max_elev_time, target_date=target_date) + max_elev_time=max_elev_time, target_date=nominal_date) passes.append(p) return passes - async def night_granules_async(self, bbox:Iterable[float]=None, target_date:date=None, cmask=False, progress=None): + async def night_granules_async(self, bbox:Iterable[float]=None, nominal_date:date=None, cmask=False, progress=None): midlon, midlat, northlat = self.decompose_bbox(bbox=bbox) - passes = self.night_passes(target_date=target_date, bbox=bbox) + passes = self.night_passes(nominal_date=nominal_date, bbox=bbox) selected_granules = {} granules = [] @@ -491,7 +491,7 @@ async def night_granules_async(self, bbox:Iterable[float]=None, target_date:date def night_granules(self, bbox:Iterable[float]=None, target_date:date=None, cmask:bool=False, progress=None ): midlon, midlat, northlat = self.decompose_bbox(bbox=bbox) - passes = self.night_passes(target_date=target_date, bbox=bbox) + passes = self.night_passes(nominal_date=target_date, bbox=bbox) selected_granules = {} granules = [] @@ -574,13 +574,13 @@ def search_granules(satellites:Optional[Iterable[str]]=None, return selected_granules async def async_search_granules( - satellites:Optional[Iterable[str]]=None, target_date:date=None, bbox:Iterable[float] = None, + satellites:Optional[Iterable[str]]=None, nominal_date:date=None, bbox:Iterable[float] = None, cmask=False, progress=None ): """ :param satellites: - :param target_date: + :param nominal_date: :param bbox: :param cmask: :param progress: @@ -588,7 +588,7 @@ async def async_search_granules( """ satellite_names = list(VIIRSNavigator.SAT_CONFIGS.keys()) - assert isinstance(target_date, date), f'invalid target date {target_date}' + assert isinstance(nominal_date, date), f'invalid target date {nominal_date}' satellites = satellites or satellite_names selected_granules = [] found_granules = {} @@ -600,7 +600,7 @@ async def async_search_granules( for sat in satellites: nav = VIIRSNavigator(satellite=sat) tasks.append(tg.create_task( - nav.night_granules_async(bbox=bbox, target_date=target_date, cmask=cmask, + nav.night_granules_async(bbox=bbox, nominal_date=nominal_date, cmask=cmask, progress=progress) )) diff --git a/rapida/ntl/vis.py b/rapida/ntl/vis.py new file mode 100644 index 00000000..c1d87934 --- /dev/null +++ b/rapida/ntl/vis.py @@ -0,0 +1,113 @@ +from matplotlib import pyplot as plt +from matplotlib import gridspec +from mpl_toolkits.axes_grid1 import make_axes_locatable +import numpy as np + +def display(data=dict(), interpolation='nearest', title=''): + """ + Displays one or more arrays with legend. + @args + @da - dictionary or ordered dictionary if one wants to preserve the order of arrays, ex {'name:'np.array(2D)} + + """ + fig = plt.figure() + ncols = 2 # we want two columns + nrows = int((len(data) / 2) + 1) + + gs = gridspec.GridSpec(nrows=nrows, ncols=ncols ) + + for i, v in enumerate(data.items()): + iname, a = v + if i == 0: + + ax = fig.add_subplot(gs[i]) + fax = ax + ax.set_title(iname) + else: + ax = fig.add_subplot(gs[i], sharex=fax, sharey=fax) + ax.set_title(iname) + + im = ax.imshow(a, interpolation=interpolation) + plt.colorbar(im, use_gridspec=True, orientation='vertical') + ax.set_aspect('equal') + # fig.show() + plt.tight_layout() + fig.suptitle(title) + plt.show() + + + +def display1(data=dict(), interpolation='nearest', title=''): + """ + Improved display function that maximizes screen real estate and + ensures perfectly aligned subplots. + """ + n = len(data) + if n == 0: return + + # 1. Calculate a better figure size based on the number of plots + # (Width, Height) - 16x10 is standard for modern monitors + ncols = 2 + nrows = int(np.ceil(n / ncols)) + + # We force a large figure size so it fills the screen + fig, axes = plt.subplots(nrows=nrows, ncols=ncols, + figsize=(16, 5 * nrows), + constrained_layout=True, + squeeze=False) + + # Flatten axes for easy iteration + axes_flat = axes.flatten() + fax = None # For sharing axis + + for i, (iname, a) in enumerate(data.items()): + ax = axes_flat[i] + + # Share axis logic to keep zoom synced + if i == 0: + fax = ax + else: + # We recreate the subplot behavior to share axes dynamically + ax.sharex(fax) + ax.sharey(fax) + + # 2. Display the image + # 'magma' is usually the best for NTL (Nighttime Lights) + cmap = 'viridis' if 'Mask' in iname else 'magma' + im = ax.imshow(a, interpolation=interpolation, cmap=cmap) + + ax.set_title(iname, fontsize=14, fontweight='bold') + + # 3. FIX: Colorbar placement that doesn't 'squish' the plot + divider = make_axes_locatable(ax) + cax = divider.append_axes("right", size="5%", pad=0.1) + plt.colorbar(im, cax=cax) + + # Keep the geographic shape correct + ax.set_aspect('equal') + + # Hide any unused subplots (if n is odd) + for j in range(i + 1, len(axes_flat)): + axes_flat[j].axis('off') + + fig.suptitle(title, fontsize=20) + plt.show() + + +def plot(array): + # 1. Convert to NanoWatts and clean (The magic fix for satellite data) + import matplotlib.pyplot as plt + + + + # 2. The Matplotlib Plot + plt.figure(figsize=(10, 8)) + + # imshow is perfect for 2D spatial arrays + # 'magma' or 'inferno' are great colormaps for night lights + img = plt.imshow(array, cmap='magma', interpolation='nearest') + + plt.colorbar(img, label='') + plt.title("Night Lights - Zero Drama Edition") + + plt.show() \ No newline at end of file From 4a34d4bb763e7fe3d25e6202f0bb52ba21e0b56c Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Wed, 3 Jun 2026 14:51:55 +0200 Subject: [PATCH 03/20] restrctucture fetch --- rapida/cli/ntl.py | 20 +- rapida/components/ntl/variables.py | 4 +- rapida/ntl/fetch.py | 292 ++++++----------------------- rapida/ntl/nasa/outage.py | 234 +++++++++++++++++++++-- rapida/ntl/nasa/search.py | 2 +- 5 files changed, 283 insertions(+), 269 deletions(-) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index 1c066d21..b6b794c9 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -11,11 +11,10 @@ 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 rapida.ntl.nasa.io import download as download_from_nasa, bulk_download +from rapida.ntl.nasa.io import download as download_from_nasa, bulk_download as bdownload from rapida.ntl.noaa.const import SOURCE_NAMES, PRODUCT_NAMES as OPER_PRODUCT_NAMES from rapida.ntl.noaa.io import download as download_from_noaa, bytesto from rich.table import Table -from rapida.ntl.nasa.io import bulk_download as bdownload from rapida.ntl.fetch import DELIVERABLES, fetch as fetch_ntl from rapida.ntl.nasa.outage import detect_outage @@ -419,19 +418,6 @@ async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetim help='The human experience of a specific night, local time zone matched to the center of bbox' ) -@click.option( - "--dst-dir", - "dst_dir", # Function argument name - type=click.Path( - exists=False, # Set to True if you want Click to fail if the dir doesn't exist yet - file_okay=False, # Strictly enforce that this is a directory, not a file - dir_okay=True, - resolve_path=True # Resolves relative paths (like '.') to absolute paths automatically - ), - default=tempfile.gettempdir(), # Defaults to the current working directory - show_default=True, # Tells the user what the default is in the --help menu - help="Destination directory to save the downloaded the images." -) @click.option("-d","deliverable", type=click.Choice(DELIVERABLES, case_sensitive=False), @@ -441,10 +427,10 @@ async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetim @click.pass_context -async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None): +async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None): progress = ctx.obj.get('progress') - return await fetch_ntl(bbox=bbox,nominal_date=nominal_date, progress=progress, deliverable=deliverable, dst_dir=dst_dir) + return await fetch_ntl(bbox=bbox,nominal_date=nominal_date, deliverable=deliverable, progress=progress, ) @ntl.command(short_help=f'Execute crisis impact detection (48h Alerts / 72h Assessments)') diff --git a/rapida/components/ntl/variables.py b/rapida/components/ntl/variables.py index 9e229a71..77b33fe7 100644 --- a/rapida/components/ntl/variables.py +++ b/rapida/components/ntl/variables.py @@ -15,7 +15,7 @@ def generate_variables(): attribution = "NASA Black Marble data courtesy of the NASA Goddard Space Flight Center’s Terrestrial Information Systems Laboratory and the Earth from Space Institute (EfSI)." variables = OrderedDict() - variables['noaa_oper_outage'] = dict(title='Outage detected through NOAA real time data', + variables['noaa_outage'] = dict(title='Outage detected through NOAA real time data', source=f"NOAA", operator='sum', percentage=True, @@ -30,7 +30,7 @@ def generate_variables(): attribution=attribution, ) - variables['nasa_arch_outage'] = dict(title='Outage detected through NASA Black Marble archived (LAADS) data', + variables['nasa_outage'] = dict(title='Outage detected through NASA Black Marble archived (LAADS) data', source=f"NASA", operator='sum', percentage=True, diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py index 615eb3bc..81e5f4b8 100644 --- a/rapida/ntl/fetch.py +++ b/rapida/ntl/fetch.py @@ -1,246 +1,39 @@ -import json -import os.path -from datetime import datetime, timedelta + +from datetime import datetime import numbers import logging -from itertools import product from rich.progress import Progress from rapida.components.ntl.variables import generate_variables -from rapida.ntl.nasa import const as nasaconst from rapida.ntl.nasa.util import get_intersecting_tiles -from rapida.util.geo import gdal_callback -import h5py -from rapida.ntl.nasa.search import calculate_local_utc -import fsspec -import urllib -import secrets -from rapida.ntl.nasa.io import download from rapida.ntl.nasa.search import search -from rapida.ntl.nasa.io import download -from osgeo import gdal - -DELIVERABLES = tuple([g.upper() for g in generate_variables()]) -logger = logging.getLogger('rapida') - - -def build_viirs_vrt( - paths: list[str], - sds_name: str, - is_remote:bool=False, - vrt_path: str = None, - bbox: tuple[float, float, float, float] = None -) -> str: - """ - Creates an in-memory mosaicked VRT from an iterable of local or remote VIIRS HDF5 files. - Bypasses GDAL's internal georeferencing failures by building explicit XML. - """ - if not paths: - raise ValueError("The paths iterable cannot be empty.") - - # 1. Setup Disk-less Auth (RAM only) - storage_options = {} - header_file_path = "/vsimem/gdal_auth.txt" - - if is_remote: - token = os.environ.get('EARTHDATA_TOKEN') - # Add this sanity check - if not token: - raise ValueError("CRITICAL: EARTHDATA_TOKEN environment variable is not set or is empty!") - storage_options = { - "block_size": 1024 * 1024, - "headers": {"Authorization": f"Bearer {token}"} - } - # Inject the header file directly into GDAL's virtual memory - gdal.FileFromMemBuffer(header_file_path, f"Authorization: Bearer {token}\r\n".encode('utf-8')) - - hdf_root = "HDFEOS/GRIDS/VIIRS_Grid_DNB_2d/Data_Fields" - dataset_path = f"{hdf_root}/{sds_name}" - - tile_vrts = [] - global_metadata = {} - # 2. Process each path to create geographically aware sub-VRTs - for i, path in enumerate(paths): - # Use fsspec to natively handle both local OS files and HTTP streams - with fsspec.open(path, "rb", **(storage_options if is_remote else {})) as f: - with h5py.File(f, 'r') as hfile: - - - # Extract Extent (Required for EVERY tile to mosaic correctly) - def get_val(key): - v = hfile.attrs[key] - return float(v[0]) if isinstance(v, (list, tuple)) or hasattr(v, '__iter__') else float(v) - - west = get_val('WestBoundingCoord') - north = get_val('NorthBoundingCoord') - east = get_val('EastBoundingCoord') - south = get_val('SouthBoundingCoord') - - # Extract Dimension & Metadata ONLY from the FIRST file - if i == 0: - try: - target_ds = hfile[dataset_path] - except KeyError as ke: - target_ds = hfile[dataset_path.replace('Data_Fields', 'Data Fields')] - - height, width = target_ds.shape[-2:] - - for k, v in target_ds.attrs.items(): - if isinstance(v, bytes): - v = v.decode('utf-8') - elif hasattr(v, '__iter__') and not isinstance(v, str): - v = v[0].decode('utf-8') if isinstance(v[0], bytes) else str(v[0]) - global_metadata[k] = str(v) - - nodata_value = float(global_metadata.get('_FillValue', -999.9)) - - # Calculate Geotransform for THIS specific tile - px_w = (east - west) / width - px_h = (south - north) / height - - # Construct GDAL connection string - if is_remote: - encoded = urllib.parse.quote(path, safe="") - auth_str = f"&header_file={header_file_path}" if token else "" - vsi = f"/vsicurl?empty_dir=yes{auth_str}&url={encoded}" - conn_str = f'HDF5:"{vsi}"://{dataset_path}' - else: - abs_path = os.path.abspath(path) - conn_str = f'HDF5:"{abs_path}"://{dataset_path}' - _, file_name = os.path.split(path) - - # XML Template (EPSG:4326 is the immutable standard for VIIRS L3/L4) - tile_xml = f""" - EPSG:4326 - {west}, {px_w}, 0.0, {north}, 0.0, {px_h} - - {nodata_value} - - {conn_str} - 1 - - - - - """ - - # Save individual tile VRT to memory - tile_vrt_path = f"/vsimem/{file_name}{secrets.token_hex(6)}.vrt" - gdal.FileFromMemBuffer(tile_vrt_path, tile_xml.encode('utf-8')) - tile_vrts.append(tile_vrt_path) - - # 3. Mosaic all virtual tiles into the final master VRT - vrt_opts = gdal.BuildVRTOptions( - outputBounds=bbox, - outputSRS='EPSG:4326', - resolution='highest', - resampleAlg=gdal.GRA_NearestNeighbour, - srcNodata=nodata_value, - VRTNodata=nodata_value - ) - - master_ds = gdal.BuildVRT(vrt_path, tile_vrts, options=vrt_opts) - if master_ds is None: - raise RuntimeError("GDAL failed to build the master VRT.") - - # 4. Inject the unified metadata & explicitly set NoData - band = master_ds.GetRasterBand(1) - band.SetMetadata(global_metadata) - band.SetNoDataValue(nodata_value) - - master_ds.FlushCache() - master_ds = None - - - - if is_remote: - tile_vrts+=[vrt_path, header_file_path] - - else: - tile_vrts+=[vrt_path] - return tile_vrts - - - -async def download_and_extract(urls:list[str]=None, stream:str=None, route=None, processing_level=None, expected_tiles:list[str]=None, - bbox: tuple[float, float, float, float] = None, vsimem=False, deliverable=None, dst_dir:str=None, progress=None): - - - - # search returns tuple product, timestamp, tile, url - products = tuple(sorted(set([e[0] for e in urls]))) # logic HOLDS because J is before N, newer is preferred - selected_product = products[0] - selected_urls = dict([(e[-1], e[1]) for e in urls if e[0] == selected_product]) - if len(selected_urls) != len(expected_tiles): - logger.info( - f'Expected to get {len(expected_tiles)} for {selected_product} stream {stream} and processing level {processing_level} over route {route}. Got {len(selected_urls)}') - return - timestamps = set(selected_urls.values()) - if len(timestamps) > 1: - logger.info( - f'Got more than one timestamp for for {selected_product} stream {stream} and processing level {processing_level} over route {route} ') - return - timestamp, = timestamps - urls = list(selected_urls) - sds_name = nasaconst.SUB_DATASETS.get(processing_level) - if not sds_name: - raise ValueError(f"Processing level '{processing_level}' not found in mapping.") - vrt_path = f'/vsimem/{selected_product}_{timestamp}_{secrets.token_hex(6)}.vrt' - to_unlink = [] - if not vsimem: - downloaded_files = await download(timestamp=timestamp, product=selected_product, dst_dir=dst_dir, urls=urls, - progress=progress) - to_unlink += build_viirs_vrt(paths=downloaded_files, sds_name=sds_name, is_remote=False, bbox=bbox, - vrt_path=vrt_path) - else: - to_unlink += build_viirs_vrt(paths=urls, sds_name=sds_name, is_remote=True, bbox=bbox, vrt_path=vrt_path) - - to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] - _, vrt_name = os.path.split(vrt_path) - output_tif_path: str = os.path.join(dst_dir, vrt_name.replace('.vrt', '.tif')) - - translate_options = dict( - format="GTiff", - creationOptions=[ - "TILED=YES", # Optimizes read performance - "BIGTIFF=IF_SAFER", - "COPY_SRC_MDD=YES" - ], - outputSRS='EPSG:4326', - - ) - - if progress: - task = progress.add_task(f'[red]Extracting data using GDAL') - callback_dict = dict( - callback=gdal_callback, - callback_data=(progress, task, None) - ) - translate_options.update(callback_dict) - - # Pass the dataset object directly instead of the string path - ds = gdal.Translate(destName=output_tif_path, srcDS=vrt_path, **translate_options) - ds = None +from rapida.ntl.nasa import const as nasaconst +from rapida.ntl.noaa.search import async_search_granules, VIIRSNavigator - [gdal.Unlink(e) for e in to_unlink] - return output_tif_path +DELIVERABLES = tuple([g.upper() for g in generate_variables() if not 'nrt' in g]) +logger = logging.getLogger('rapida') async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - dst_dir:str=None, progress:Progress=None, vsimem=False): + progress:Progress=None): """ Indentify and download the BEST quality available data suitable to detect outages. :param bbox: :param nominal_date: :param progress: + :param deliverable :return: """ deliverable = deliverable.lower() - expected_tiles = get_intersecting_tiles(bbox=bbox) + if 'noaa' in deliverable: # operational real time data - pass + granules = await async_search_granules( + satellites=None, nominal_date=nominal_date, bbox=bbox, + cmask=True, progress=progress) + + expected_tiles = get_intersecting_tiles(bbox=bbox) routes = nasaconst.ROUTES stream = nasaconst.ARCHIVE if deliverable == 'baseline': @@ -249,19 +42,56 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, del processing_levels = 'A2', 'A1' # best daily data ??? if 'nrt' in deliverable: stream = nasaconst.OPERATIONAL - for processing_level, route in product(processing_levels, routes): - urls = search(processing_level=processing_level, nominal_date=nominal_date, bbox=bbox, - stream=stream, route=route,progress=progress, push_to_cache=False - ) - if not urls: - logger.debug( - f'No data was found for deliverable {deliverable} at processing level {processing_level} through route {route}') - continue + urls = {} + + for processing_level in processing_levels: + for route in routes: + + found_urls = search( + processing_level=processing_level, + nominal_date=nominal_date, + bbox=bbox, + stream=stream, + route=route, + progress=progress, + push_to_cache=True + ) + + if not found_urls: + logger.debug( + f'No data was found for deliverable {deliverable} at processing level {processing_level} through route {route}') + continue + + products = tuple(sorted(set([e[0] for e in found_urls]))) + selected_product = products[0] + + # Simplified dictionary comprehension + selected_urls = {e[-1]: e[1] for e in found_urls if e[0] == selected_product} + + if len(selected_urls) != len(expected_tiles): + logger.info( + f'Expected to get {len(expected_tiles)} for {selected_product} stream {stream} and processing level {processing_level} over route {route}. Got {len(selected_urls)}') + continue + + logger.info( + f'Selected {len(selected_urls)} images for deliverable {deliverable} at processing level {processing_level} {route}') + + # Save the successful URLs + urls[selected_product] = selected_urls + + # Break out of the 'routes' loop to stop searching for this processing_level + break + return urls - return await download_and_extract(urls=urls, stream=stream, route=route, processing_level=processing_level, expected_tiles=expected_tiles, - bbox=bbox, vsimem=vsimem, deliverable=deliverable, dst_dir=dst_dir, progress=progress) + # for product, file_urls in all_urls.items(): + # + # + # + # + # return await download_and_extract(urls=urls, stream=stream, route=route, processing_level=processing_level, expected_tiles=expected_tiles, + # bbox=bbox, vsimem=vsimem, deliverable=deliverable, dst_dir=dst_dir, progress=progress) diff --git a/rapida/ntl/nasa/outage.py b/rapida/ntl/nasa/outage.py index 2a62efb2..0077c3cd 100644 --- a/rapida/ntl/nasa/outage.py +++ b/rapida/ntl/nasa/outage.py @@ -1,4 +1,8 @@ - +import os +from osgeo import gdal +import fsspec +import h5py +import secrets from datetime import datetime, timedelta import numbers import logging @@ -7,12 +11,207 @@ from rapida.ntl.fetch import fetch from scipy.ndimage import uniform_filter import numpy as np +from rapida.ntl.nasa import const as nasaconst +from rapida.ntl.nasa.io import download import rasterio from scipy.ndimage import label +from rapida.util.geo import gdal_callback from rapida.ntl import vis -DELIVERABLES = tuple([g.upper() for g in generate_variables()]) +import urllib + logger = logging.getLogger('rapida') + +def build_viirs_vrt( + paths: list[str], + sds_name: str, + is_remote: bool = False, + vrt_path: str = None, + bbox: tuple[float, float, float, float] = None +) -> str: + """ + Creates an in-memory mosaicked VRT from an iterable of local or remote VIIRS HDF5 files. + Bypasses GDAL's internal georeferencing failures by building explicit XML. + """ + if not paths: + raise ValueError("The paths iterable cannot be empty.") + + # 1. Setup Disk-less Auth (RAM only) + storage_options = {} + header_file_path = "/vsimem/gdal_auth.txt" + + if is_remote: + token = os.environ.get('EARTHDATA_TOKEN') + # Add this sanity check + if not token: + raise ValueError("CRITICAL: EARTHDATA_TOKEN environment variable is not set or is empty!") + storage_options = { + "block_size": 1024 * 1024, + "headers": {"Authorization": f"Bearer {token}"} + } + # Inject the header file directly into GDAL's virtual memory + gdal.FileFromMemBuffer(header_file_path, f"Authorization: Bearer {token}\r\n".encode('utf-8')) + + hdf_root = "HDFEOS/GRIDS/VIIRS_Grid_DNB_2d/Data_Fields" + dataset_path = f"{hdf_root}/{sds_name}" + + tile_vrts = [] + global_metadata = {} + # 2. Process each path to create geographically aware sub-VRTs + for i, path in enumerate(paths): + # Use fsspec to natively handle both local OS files and HTTP streams + with fsspec.open(path, "rb", **(storage_options if is_remote else {})) as f: + with h5py.File(f, 'r') as hfile: + + # Extract Extent (Required for EVERY tile to mosaic correctly) + def get_val(key): + v = hfile.attrs[key] + return float(v[0]) if isinstance(v, (list, tuple)) or hasattr(v, '__iter__') else float(v) + + west = get_val('WestBoundingCoord') + north = get_val('NorthBoundingCoord') + east = get_val('EastBoundingCoord') + south = get_val('SouthBoundingCoord') + + # Extract Dimension & Metadata ONLY from the FIRST file + if i == 0: + try: + target_ds = hfile[dataset_path] + except KeyError as ke: + target_ds = hfile[dataset_path.replace('Data_Fields', 'Data Fields')] + + height, width = target_ds.shape[-2:] + + for k, v in target_ds.attrs.items(): + if isinstance(v, bytes): + v = v.decode('utf-8') + elif hasattr(v, '__iter__') and not isinstance(v, str): + v = v[0].decode('utf-8') if isinstance(v[0], bytes) else str(v[0]) + global_metadata[k] = str(v) + + nodata_value = float(global_metadata.get('_FillValue', -999.9)) + + # Calculate Geotransform for THIS specific tile + px_w = (east - west) / width + px_h = (south - north) / height + + # Construct GDAL connection string + if is_remote: + encoded = urllib.parse.quote(path, safe="") + auth_str = f"&header_file={header_file_path}" if token else "" + vsi = f"/vsicurl?empty_dir=yes{auth_str}&url={encoded}" + conn_str = f'HDF5:"{vsi}"://{dataset_path}' + else: + abs_path = os.path.abspath(path) + conn_str = f'HDF5:"{abs_path}"://{dataset_path}' + _, file_name = os.path.split(path) + + # XML Template (EPSG:4326 is the immutable standard for VIIRS L3/L4) + tile_xml = f""" + EPSG:4326 + {west}, {px_w}, 0.0, {north}, 0.0, {px_h} + + {nodata_value} + + {conn_str} + 1 + + + + + """ + + # Save individual tile VRT to memory + tile_vrt_path = f"/vsimem/{file_name}{secrets.token_hex(6)}.vrt" + gdal.FileFromMemBuffer(tile_vrt_path, tile_xml.encode('utf-8')) + tile_vrts.append(tile_vrt_path) + + # 3. Mosaic all virtual tiles into the final master VRT + vrt_opts = gdal.BuildVRTOptions( + outputBounds=bbox, + outputSRS='EPSG:4326', + resolution='highest', + resampleAlg=gdal.GRA_NearestNeighbour, + srcNodata=nodata_value, + VRTNodata=nodata_value + ) + + master_ds = gdal.BuildVRT(vrt_path, tile_vrts, options=vrt_opts) + if master_ds is None: + raise RuntimeError("GDAL failed to build the master VRT.") + + # 4. Inject the unified metadata & explicitly set NoData + band = master_ds.GetRasterBand(1) + band.SetMetadata(global_metadata) + band.SetNoDataValue(nodata_value) + + master_ds.FlushCache() + master_ds = None + + if is_remote: + tile_vrts += [vrt_path, header_file_path] + + else: + tile_vrts += [vrt_path] + return tile_vrts + + +async def download_and_extract(file_urls: list[str] = None, stream: str = None, route=None, processing_level=None, + product: str = None, + bbox: tuple[float, float, float, float] = None, vsimem=False, dst_dir: str = None, + progress=None, + ): + timestamps = set(file_urls.values()) + if len(timestamps) > 1: + logger.info( + f'Got more than one timestamp for for {product} stream {stream} and processing level {processing_level} over route {route} ') + return + timestamp, = timestamps + urls = list(file_urls) + sds_name = nasaconst.SUB_DATASETS.get(processing_level) + if not sds_name: + raise ValueError(f"Processing level '{processing_level}' not found in mapping.") + vrt_path = f'/vsimem/{product}_{timestamp}_{secrets.token_hex(6)}.vrt' + to_unlink = [] + if not vsimem: + downloaded_files = await download(timestamp=timestamp, product=product, dst_dir=dst_dir, urls=urls, + progress=progress) + to_unlink += build_viirs_vrt(paths=downloaded_files, sds_name=sds_name, is_remote=False, bbox=bbox, + vrt_path=vrt_path) + else: + to_unlink += build_viirs_vrt(paths=urls, sds_name=sds_name, is_remote=True, bbox=bbox, vrt_path=vrt_path) + + to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] + _, vrt_name = os.path.split(vrt_path) + output_tif_path: str = os.path.join(dst_dir, vrt_name.replace('.vrt', '.tif')) + + translate_options = dict( + format="GTiff", + creationOptions=[ + "TILED=YES", # Optimizes read performance + "BIGTIFF=IF_SAFER", + "COPY_SRC_MDD=YES" + ], + outputSRS='EPSG:4326', + + ) + + if progress: + task = progress.add_task(f'[red]Extracting data using GDAL') + callback_dict = dict( + callback=gdal_callback, + callback_data=(progress, task, None) + ) + translate_options.update(callback_dict) + + # Pass the dataset object directly instead of the string path + ds = gdal.Translate(destName=output_tif_path, srcDS=vrt_path, **translate_options) + ds = None + + [gdal.Unlink(e) for e in to_unlink] + return output_tif_path + + def spatial_filter(outage_map, min_size=2): # 1. Group connected pixels into "clumps" labeled_array, num_features = label(outage_map) @@ -73,22 +272,21 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N dst_dir:str=None, progress:Progress=None): logger.info(f'Fetching best imagery for {bbox} {nominal_date}') - target_data = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable=deliverable, - dst_dir=dst_dir) + data_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable=deliverable) logger.info(f'Fetching baseline imagery for {bbox} {nominal_date}') - baseline_data = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable='baseline', - dst_dir=dst_dir) - data = {} - with rasterio.open(target_data) as target, rasterio.open(baseline_data) as base: - nrt = np.log1p(target.read(1, masked=True)) - baseline = np.log1p(base.read(1, masked=True)) - ssim = pure_numpy_ssim(nrt, baseline, win_size=7) - anomaly_mask = (ssim < 0.4) & (baseline.filled(0.0) > 1.5) & (~np.ma.getmaskarray(nrt)) - clusters = spatial_filter(anomaly_mask, min_size=5) - data[target_data] = nrt - data[baseline_data] = baseline - data['ssim'] = ssim - data['anom'] = clusters - vis.display1(data=data, title=f'All relevant processing levels imagery for {bbox} on {nominal_date.date()}') + baseline_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable='baseline') + + # data = {} + # with rasterio.open(target_data) as target, rasterio.open(baseline_data) as base: + # nrt = np.log1p(target.read(1, masked=True)) + # baseline = np.log1p(base.read(1, masked=True)) + # ssim = pure_numpy_ssim(nrt, baseline, win_size=7) + # anomaly_mask = (ssim < 0.4) & (baseline.filled(0.0) > 1.5) & (~np.ma.getmaskarray(nrt)) + # clusters = spatial_filter(anomaly_mask, min_size=5) + # data[target_data] = nrt + # data[baseline_data] = baseline + # data['ssim'] = ssim + # data['anom'] = clusters + # vis.display1(data=data, title=f'All relevant processing levels imagery for {bbox} on {nominal_date.date()}') diff --git a/rapida/ntl/nasa/search.py b/rapida/ntl/nasa/search.py index 9d781947..1fa5eec7 100644 --- a/rapida/ntl/nasa/search.py +++ b/rapida/ntl/nasa/search.py @@ -221,7 +221,7 @@ def search( products = stream_products[processing_level] dt = calculate_local_utc(stream=stream,processing_level=processing_level, nominal_date=nominal_date, bbox=bbox, route=route, products=products) - + print(dt) cached_results = [] expected_products_count = len(products) found_products_count = 0 From 0aa75e714bf217da484b7a8f17b622cd05154c12 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Wed, 3 Jun 2026 15:37:00 +0200 Subject: [PATCH 04/20] fix caching bug in NASA --- rapida/ntl/cache.py | 15 ++++++++++----- rapida/ntl/nasa/search.py | 20 ++++++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/rapida/ntl/cache.py b/rapida/ntl/cache.py index 9429c1d8..c93c0dda 100644 --- a/rapida/ntl/cache.py +++ b/rapida/ntl/cache.py @@ -48,16 +48,21 @@ def fetch(key:str=None, tile:str=None, cache_path=CACHE_PATH): return # 1. Directly unpack the tuple tiles, creation_time = record - # 2. Check for expiration if time.time() - creation_time > MAX_AGE_SECONDS: del cache[key] return # Expired # 3. Handle the tile request - if tile and tile in tiles: - return tiles[tile], - if isinstance(tiles, dict): - return tuple(tiles.values()) + if tile: + if tile in tiles: + return tiles[tile] + return else: return tiles + + +if __name__ == '__main__': + key = 'VJ146A3_202604' + r = fetch(key=key) + print(r) \ No newline at end of file diff --git a/rapida/ntl/nasa/search.py b/rapida/ntl/nasa/search.py index 1fa5eec7..a9a51936 100644 --- a/rapida/ntl/nasa/search.py +++ b/rapida/ntl/nasa/search.py @@ -221,23 +221,27 @@ def search( products = stream_products[processing_level] dt = calculate_local_utc(stream=stream,processing_level=processing_level, nominal_date=nominal_date, bbox=bbox, route=route, products=products) - print(dt) + expected_tiles = get_intersecting_tiles(bbox=bbox) + expected_tiles_count = len(expected_tiles) cached_results = [] expected_products_count = len(products) found_products_count = 0 + found_tiles_count = 0 keys = [] for product in products: timestamp = dt.strftime(timestamp_format(product_id=product)) key = f'{product}_{timestamp}' - urls = cache.fetch(key=key) - if urls: - found_products_count += 1 - keys.append(key) - for url in urls: + for tile in expected_tiles: + url = cache.fetch(key=key, tile=tile) + print(url) + if url: cached_results.append(url2result(url=url, store=False)) + found_tiles_count += 1 + found_products_count += 1 + keys.append(key) - # Only short-circuit if the cache successfully returned data for EVERY product requested - if found_products_count == expected_products_count: + # Only short-circuit if the cache successfully returned expected number of tiles for EVERY product requested + if found_products_count == expected_products_count and found_tiles_count >= expected_tiles_count*expected_products_count: logger.info(f"Full cache hit for {keys}. Bypassing network search.") return cached_results # --- 2. Catalog Search --- From e8da1b9a1a84be70ddbfe77b38361c1093e43103 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Wed, 3 Jun 2026 16:59:27 +0200 Subject: [PATCH 05/20] fix caching bug in store --- rapida/ntl/cache.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/rapida/ntl/cache.py b/rapida/ntl/cache.py index c93c0dda..257f655c 100644 --- a/rapida/ntl/cache.py +++ b/rapida/ntl/cache.py @@ -34,7 +34,12 @@ def store(key:str=None, value:str=None, tile:str=None, cache_path=CACHE_PATH): if not tile in tiles: record[0].update({tile:value}) else: - record[0] = value + if tile: + if not tile in tiles: + tiles.update({tile: value}) + record = tiles, creation_time + else: + record = value, creation_time cache[key] = record @@ -64,5 +69,14 @@ def fetch(key:str=None, tile:str=None, cache_path=CACHE_PATH): if __name__ == '__main__': key = 'VJ146A3_202604' - r = fetch(key=key) + r = fetch(key=key, tile='h21v04') + print(r) + ky = '32445566' + # with shelve.open(CACHE_PATH) as cache: + # del cache[ky] + store(key=ky, value='a') + r = fetch(key=ky) + print(r) + store(key=ky, value='b') + r = fetch(key=ky) print(r) \ No newline at end of file From 096e7177322322565ddc55e55324dce885efd67b Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Wed, 3 Jun 2026 17:06:29 +0200 Subject: [PATCH 06/20] fix caching bug in store --- rapida/ntl/cache.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/rapida/ntl/cache.py b/rapida/ntl/cache.py index 257f655c..76e64fbf 100644 --- a/rapida/ntl/cache.py +++ b/rapida/ntl/cache.py @@ -32,14 +32,10 @@ def store(key:str=None, value:str=None, tile:str=None, cache_path=CACHE_PATH): tiles, creation_time = record if tile: if not tile in tiles: - record[0].update({tile:value}) + tiles.update({tile: value}) + record = tiles, creation_time else: - if tile: - if not tile in tiles: - tiles.update({tile: value}) - record = tiles, creation_time - else: - record = value, creation_time + record = value, creation_time cache[key] = record From 3b60adcc6ba37201d5c17be0bf446ef3575a07c5 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Wed, 3 Jun 2026 17:07:30 +0200 Subject: [PATCH 07/20] fix caching bug in store --- rapida/ntl/cache.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rapida/ntl/cache.py b/rapida/ntl/cache.py index 76e64fbf..eff2cc16 100644 --- a/rapida/ntl/cache.py +++ b/rapida/ntl/cache.py @@ -67,9 +67,10 @@ def fetch(key:str=None, tile:str=None, cache_path=CACHE_PATH): key = 'VJ146A3_202604' r = fetch(key=key, tile='h21v04') print(r) + r = fetch(key=key) + print(r) ky = '32445566' - # with shelve.open(CACHE_PATH) as cache: - # del cache[ky] + store(key=ky, value='a') r = fetch(key=ky) print(r) From 9531150d2123a7a3bd4707d5cb553eaa6940dd8c Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Thu, 4 Jun 2026 01:23:53 +0200 Subject: [PATCH 08/20] noaa fetch --- rapida/cli/ntl.py | 4 +- .../landuse/search_utils/mgrsconv.py | 2 +- rapida/components/landuse/stac_collection.py | 2 +- rapida/components/landuse/utils.py | 2 +- rapida/ntl/fetch.py | 15 +- rapida/ntl/nasa/io.py | 200 ++++++++++++++++- rapida/ntl/nasa/outage.py | 206 +----------------- rapida/ntl/nasa/search.py | 1 - rapida/ntl/noaa/cmask.py | 171 ++++++++++++++- rapida/ntl/noaa/search.py | 4 +- rapida/util/gpd_overlay.py | 2 +- 11 files changed, 382 insertions(+), 227 deletions(-) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index b6b794c9..9cfd1eba 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -81,7 +81,7 @@ def search(): type=BboxParamType(), help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north' ) -@click.option("--date", "target_date", +@click.option("--date", "nominal_date", type=click.DateTime(formats=["%Y-%m-%d"]), required=True, help='' @@ -282,7 +282,7 @@ async def download_nasa(ctx, timestamp:str = None, product:str=None, tile:str=No @click.option("--timestamp", "-t", "timestamp", type=str, required=True, help='Granule timestamp string as date and time. Ex: 202604152232 ') @click.option("-p", - "--products" + "--products", "products", type=click.Choice(OPER_PRODUCT_NAMES, case_sensitive=False), default=OPER_PRODUCT_NAMES, diff --git a/rapida/components/landuse/search_utils/mgrsconv.py b/rapida/components/landuse/search_utils/mgrsconv.py index 9b4738ba..10242938 100644 --- a/rapida/components/landuse/search_utils/mgrsconv.py +++ b/rapida/components/landuse/search_utils/mgrsconv.py @@ -74,7 +74,7 @@ def _mgrs_100k_key_for_zone(lat: float, lon: float, zone: int) -> str: def generate_utm_100k_grid(aoi_utm) -> List[Polygon]: if aoi_utm.is_empty: return [] - xmin, ymin, xmax, ymax = aoi_utm.bounds + xmin, ymin, xmax, ymax = aoi_utm.bounds_from_file e0 = math.floor(xmin / 100000.0) * 100000.0 e1 = math.ceil (xmax / 100000.0) * 100000.0 n0 = math.floor(ymin / 100000.0) * 100000.0 diff --git a/rapida/components/landuse/stac_collection.py b/rapida/components/landuse/stac_collection.py index be5373c4..6b50360c 100644 --- a/rapida/components/landuse/stac_collection.py +++ b/rapida/components/landuse/stac_collection.py @@ -265,7 +265,7 @@ def _merge_or_voronoi(self, df: gpd.GeoDataFrame, scene_size=110000) -> list[Pol return [gpd.GeoSeries([merged], crs=3857).to_crs(original_crs).iloc[0]] # Generate internal grid points spaced equally based on scene size - minx, miny, maxx, maxy = merged.bounds + minx, miny, maxx, maxy = merged.bounds_from_file # create double size of scene to be able to cover the whole scene area spacing = scene_size * 2 nx = int(np.ceil((maxx - minx) / spacing)) diff --git a/rapida/components/landuse/utils.py b/rapida/components/landuse/utils.py index 71505132..787cdbe0 100644 --- a/rapida/components/landuse/utils.py +++ b/rapida/components/landuse/utils.py @@ -13,7 +13,7 @@ def _tile_id_from_center(ds) -> str: # Use dataset center in WGS84 to derive the 100 km MGRS tile id (e.g., "36NYG") - (minx, miny, maxx, maxy) = ds.bounds + (minx, miny, maxx, maxy) = ds.bounds_from_file cx = (minx + maxx) / 2 cy = (miny + maxy) / 2 # to lon/lat diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py index 81e5f4b8..f0d74272 100644 --- a/rapida/ntl/fetch.py +++ b/rapida/ntl/fetch.py @@ -1,4 +1,4 @@ - +from shapely.geometry import box from datetime import datetime import numbers import logging @@ -8,8 +8,9 @@ from rapida.ntl.nasa.search import search from rapida.ntl.nasa import const as nasaconst from rapida.ntl.noaa.search import async_search_granules, VIIRSNavigator +from rapida.ntl.noaa.cmask import select_required_granules -DELIVERABLES = tuple([g.upper() for g in generate_variables() if not 'nrt' in g]) +DELIVERABLES = tuple([g.upper() for g in generate_variables()]) logger = logging.getLogger('rapida') @@ -33,6 +34,9 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, del satellites=None, nominal_date=nominal_date, bbox=bbox, cmask=True, progress=progress) + required_granules = select_required_granules(sorted_granules=granules, bbox=bbox) + + return [g.url for g in required_granules] expected_tiles = get_intersecting_tiles(bbox=bbox) routes = nasaconst.ROUTES stream = nasaconst.ARCHIVE @@ -85,13 +89,6 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, del return urls - # for product, file_urls in all_urls.items(): - # - # - # - # - # return await download_and_extract(urls=urls, stream=stream, route=route, processing_level=processing_level, expected_tiles=expected_tiles, - # bbox=bbox, vsimem=vsimem, deliverable=deliverable, dst_dir=dst_dir, progress=progress) diff --git a/rapida/ntl/nasa/io.py b/rapida/ntl/nasa/io.py index 199f9d0e..e2cd9380 100644 --- a/rapida/ntl/nasa/io.py +++ b/rapida/ntl/nasa/io.py @@ -1,6 +1,9 @@ from pathlib import Path import httpx -from rapida.ntl.nasa.util import get_intersecting_tiles +from osgeo import gdal +import fsspec +import h5py +import secrets import asyncio import os from rich.progress import Progress @@ -11,9 +14,204 @@ from rapida.ntl.nasa.search import stac_search from datetime import datetime import numbers +from rapida.ntl.nasa import const as nasaconst +from rapida.util.geo import gdal_callback +import urllib logger = logging.getLogger(__name__) + + +def h52vrt( + paths: list[str], + sds_name: str, + is_remote: bool = False, + vrt_path: str = None, + bbox: tuple[float, float, float, float] = None +) -> str: + """ + Creates an in-memory/local disk mosaicked VRT from an iterable of local or remote VIIRS HDF5 files. + Bypasses GDAL's internal georeferencing failures by building explicit XML. + """ + if not paths: + raise ValueError("The paths iterable cannot be empty.") + + # 1. Setup Disk-less Auth (RAM only) + storage_options = {} + header_file_path = "/vsimem/gdal_auth.txt" + + if is_remote: + token = os.environ.get('EARTHDATA_TOKEN') + # Add this sanity check + if not token: + raise ValueError("CRITICAL: EARTHDATA_TOKEN environment variable is not set or is empty!") + storage_options = { + "block_size": 1024 * 1024, + "headers": {"Authorization": f"Bearer {token}"} + } + # Inject the header file directly into GDAL's virtual memory + gdal.FileFromMemBuffer(header_file_path, f"Authorization: Bearer {token}\r\n".encode('utf-8')) + + hdf_root = "HDFEOS/GRIDS/VIIRS_Grid_DNB_2d/Data_Fields" + dataset_path = f"{hdf_root}/{sds_name}" + + tile_vrts = [] + global_metadata = {} + # 2. Process each path to create geographically aware sub-VRTs + for i, path in enumerate(paths): + # Use fsspec to natively handle both local OS files and HTTP streams + with fsspec.open(path, "rb", **(storage_options if is_remote else {})) as f: + with h5py.File(f, 'r') as hfile: + + # Extract Extent (Required for EVERY tile to mosaic correctly) + def get_val(key): + v = hfile.attrs[key] + return float(v[0]) if isinstance(v, (list, tuple)) or hasattr(v, '__iter__') else float(v) + + west = get_val('WestBoundingCoord') + north = get_val('NorthBoundingCoord') + east = get_val('EastBoundingCoord') + south = get_val('SouthBoundingCoord') + + # Extract Dimension & Metadata ONLY from the FIRST file + if i == 0: + try: + target_ds = hfile[dataset_path] + except KeyError as ke: + target_ds = hfile[dataset_path.replace('Data_Fields', 'Data Fields')] + + height, width = target_ds.shape[-2:] + + for k, v in target_ds.attrs.items(): + if isinstance(v, bytes): + v = v.decode('utf-8') + elif hasattr(v, '__iter__') and not isinstance(v, str): + v = v[0].decode('utf-8') if isinstance(v[0], bytes) else str(v[0]) + global_metadata[k] = str(v) + + nodata_value = float(global_metadata.get('_FillValue', -999.9)) + + # Calculate Geotransform for THIS specific tile + px_w = (east - west) / width + px_h = (south - north) / height + + # Construct GDAL connection string + if is_remote: + encoded = urllib.parse.quote(path, safe="") + auth_str = f"&header_file={header_file_path}" if token else "" + vsi = f"/vsicurl?empty_dir=yes{auth_str}&url={encoded}" + conn_str = f'HDF5:"{vsi}"://{dataset_path}' + else: + abs_path = os.path.abspath(path) + conn_str = f'HDF5:"{abs_path}"://{dataset_path}' + _, file_name = os.path.split(path) + + # XML Template (EPSG:4326 is the immutable standard for VIIRS L3/L4) + tile_xml = f""" + EPSG:4326 + {west}, {px_w}, 0.0, {north}, 0.0, {px_h} + + {nodata_value} + + {conn_str} + 1 + + + + + """ + + # Save individual tile VRT to memory + tile_vrt_path = f"/vsimem/{file_name}{secrets.token_hex(6)}.vrt" + gdal.FileFromMemBuffer(tile_vrt_path, tile_xml.encode('utf-8')) + tile_vrts.append(tile_vrt_path) + + # 3. Mosaic all virtual tiles into the final master VRT + vrt_opts = gdal.BuildVRTOptions( + outputBounds=bbox, + outputSRS='EPSG:4326', + resolution='highest', + resampleAlg=gdal.GRA_NearestNeighbour, + srcNodata=nodata_value, + VRTNodata=nodata_value + ) + + master_ds = gdal.BuildVRT(vrt_path, tile_vrts, options=vrt_opts) + if master_ds is None: + raise RuntimeError("GDAL failed to build the master VRT.") + + # 4. Inject the unified metadata & explicitly set NoData + band = master_ds.GetRasterBand(1) + band.SetMetadata(global_metadata) + band.SetNoDataValue(nodata_value) + + master_ds.FlushCache() + master_ds = None + + if is_remote: + tile_vrts += [vrt_path, header_file_path] + + else: + tile_vrts += [vrt_path] + return tile_vrts + + +async def download_and_extract(file_urls: list[str] = None, stream: str = None, route=None, processing_level=None, + product: str = None, bbox: tuple[float, float, float, float] = None, vsimem=False, + dst_dir: str = None, progress=None, + ): + timestamps = set(file_urls.values()) + if len(timestamps) > 1: + logger.info( + f'Got more than one timestamp for for {product} stream {stream} and processing level {processing_level} over route {route} ') + return + timestamp, = timestamps + urls = list(file_urls) + sds_name = nasaconst.SUB_DATASETS.get(processing_level) + if not sds_name: + raise ValueError(f"Processing level '{processing_level}' not found in mapping.") + vrt_path = f'/vsimem/{product}_{timestamp}_{secrets.token_hex(6)}.vrt' + to_unlink = [] + if not vsimem: + downloaded_files = await download(timestamp=timestamp, product=product, dst_dir=dst_dir, urls=urls, + progress=progress) + to_unlink += h52vrt(paths=downloaded_files, sds_name=sds_name, is_remote=False, bbox=bbox, + vrt_path=vrt_path) + else: + to_unlink += h52vrt(paths=urls, sds_name=sds_name, is_remote=True, bbox=bbox, vrt_path=vrt_path) + + to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] + _, vrt_name = os.path.split(vrt_path) + output_tif_path: str = os.path.join(dst_dir, vrt_name.replace('.vrt', '.tif')) + + translate_options = dict( + format="GTiff", + creationOptions=[ + "TILED=YES", # Optimizes read performance + "BIGTIFF=IF_SAFER", + "COPY_SRC_MDD=YES" + ], + outputSRS='EPSG:4326', + + ) + + if progress: + task = progress.add_task(f'[red]Extracting data using GDAL') + callback_dict = dict( + callback=gdal_callback, + callback_data=(progress, task, None) + ) + translate_options.update(callback_dict) + + # Pass the dataset object directly instead of the string path + ds = gdal.Translate(destName=output_tif_path, srcDS=vrt_path, **translate_options) + ds = None + + [gdal.Unlink(e) for e in to_unlink] + return output_tif_path + + + async def download(timestamp: str = None, product: str = None, tile:str=None, dst_dir:str=None, urls:list[str]=None, progress:Progress=None): if not urls: diff --git a/rapida/ntl/nasa/outage.py b/rapida/ntl/nasa/outage.py index 0077c3cd..686fcf90 100644 --- a/rapida/ntl/nasa/outage.py +++ b/rapida/ntl/nasa/outage.py @@ -1,215 +1,20 @@ -import os -from osgeo import gdal -import fsspec -import h5py -import secrets + from datetime import datetime, timedelta import numbers import logging from rich.progress import Progress -from rapida.components.ntl.variables import generate_variables from rapida.ntl.fetch import fetch from scipy.ndimage import uniform_filter import numpy as np -from rapida.ntl.nasa import const as nasaconst -from rapida.ntl.nasa.io import download + import rasterio from scipy.ndimage import label -from rapida.util.geo import gdal_callback -from rapida.ntl import vis -import urllib - -logger = logging.getLogger('rapida') - - -def build_viirs_vrt( - paths: list[str], - sds_name: str, - is_remote: bool = False, - vrt_path: str = None, - bbox: tuple[float, float, float, float] = None -) -> str: - """ - Creates an in-memory mosaicked VRT from an iterable of local or remote VIIRS HDF5 files. - Bypasses GDAL's internal georeferencing failures by building explicit XML. - """ - if not paths: - raise ValueError("The paths iterable cannot be empty.") - - # 1. Setup Disk-less Auth (RAM only) - storage_options = {} - header_file_path = "/vsimem/gdal_auth.txt" - - if is_remote: - token = os.environ.get('EARTHDATA_TOKEN') - # Add this sanity check - if not token: - raise ValueError("CRITICAL: EARTHDATA_TOKEN environment variable is not set or is empty!") - storage_options = { - "block_size": 1024 * 1024, - "headers": {"Authorization": f"Bearer {token}"} - } - # Inject the header file directly into GDAL's virtual memory - gdal.FileFromMemBuffer(header_file_path, f"Authorization: Bearer {token}\r\n".encode('utf-8')) - - hdf_root = "HDFEOS/GRIDS/VIIRS_Grid_DNB_2d/Data_Fields" - dataset_path = f"{hdf_root}/{sds_name}" - - tile_vrts = [] - global_metadata = {} - # 2. Process each path to create geographically aware sub-VRTs - for i, path in enumerate(paths): - # Use fsspec to natively handle both local OS files and HTTP streams - with fsspec.open(path, "rb", **(storage_options if is_remote else {})) as f: - with h5py.File(f, 'r') as hfile: - - # Extract Extent (Required for EVERY tile to mosaic correctly) - def get_val(key): - v = hfile.attrs[key] - return float(v[0]) if isinstance(v, (list, tuple)) or hasattr(v, '__iter__') else float(v) - - west = get_val('WestBoundingCoord') - north = get_val('NorthBoundingCoord') - east = get_val('EastBoundingCoord') - south = get_val('SouthBoundingCoord') - - # Extract Dimension & Metadata ONLY from the FIRST file - if i == 0: - try: - target_ds = hfile[dataset_path] - except KeyError as ke: - target_ds = hfile[dataset_path.replace('Data_Fields', 'Data Fields')] - - height, width = target_ds.shape[-2:] - - for k, v in target_ds.attrs.items(): - if isinstance(v, bytes): - v = v.decode('utf-8') - elif hasattr(v, '__iter__') and not isinstance(v, str): - v = v[0].decode('utf-8') if isinstance(v[0], bytes) else str(v[0]) - global_metadata[k] = str(v) - nodata_value = float(global_metadata.get('_FillValue', -999.9)) - - # Calculate Geotransform for THIS specific tile - px_w = (east - west) / width - px_h = (south - north) / height - - # Construct GDAL connection string - if is_remote: - encoded = urllib.parse.quote(path, safe="") - auth_str = f"&header_file={header_file_path}" if token else "" - vsi = f"/vsicurl?empty_dir=yes{auth_str}&url={encoded}" - conn_str = f'HDF5:"{vsi}"://{dataset_path}' - else: - abs_path = os.path.abspath(path) - conn_str = f'HDF5:"{abs_path}"://{dataset_path}' - _, file_name = os.path.split(path) - - # XML Template (EPSG:4326 is the immutable standard for VIIRS L3/L4) - tile_xml = f""" - EPSG:4326 - {west}, {px_w}, 0.0, {north}, 0.0, {px_h} - - {nodata_value} - - {conn_str} - 1 - - - - - """ - - # Save individual tile VRT to memory - tile_vrt_path = f"/vsimem/{file_name}{secrets.token_hex(6)}.vrt" - gdal.FileFromMemBuffer(tile_vrt_path, tile_xml.encode('utf-8')) - tile_vrts.append(tile_vrt_path) - - # 3. Mosaic all virtual tiles into the final master VRT - vrt_opts = gdal.BuildVRTOptions( - outputBounds=bbox, - outputSRS='EPSG:4326', - resolution='highest', - resampleAlg=gdal.GRA_NearestNeighbour, - srcNodata=nodata_value, - VRTNodata=nodata_value - ) - - master_ds = gdal.BuildVRT(vrt_path, tile_vrts, options=vrt_opts) - if master_ds is None: - raise RuntimeError("GDAL failed to build the master VRT.") - - # 4. Inject the unified metadata & explicitly set NoData - band = master_ds.GetRasterBand(1) - band.SetMetadata(global_metadata) - band.SetNoDataValue(nodata_value) - - master_ds.FlushCache() - master_ds = None - - if is_remote: - tile_vrts += [vrt_path, header_file_path] - - else: - tile_vrts += [vrt_path] - return tile_vrts - - -async def download_and_extract(file_urls: list[str] = None, stream: str = None, route=None, processing_level=None, - product: str = None, - bbox: tuple[float, float, float, float] = None, vsimem=False, dst_dir: str = None, - progress=None, - ): - timestamps = set(file_urls.values()) - if len(timestamps) > 1: - logger.info( - f'Got more than one timestamp for for {product} stream {stream} and processing level {processing_level} over route {route} ') - return - timestamp, = timestamps - urls = list(file_urls) - sds_name = nasaconst.SUB_DATASETS.get(processing_level) - if not sds_name: - raise ValueError(f"Processing level '{processing_level}' not found in mapping.") - vrt_path = f'/vsimem/{product}_{timestamp}_{secrets.token_hex(6)}.vrt' - to_unlink = [] - if not vsimem: - downloaded_files = await download(timestamp=timestamp, product=product, dst_dir=dst_dir, urls=urls, - progress=progress) - to_unlink += build_viirs_vrt(paths=downloaded_files, sds_name=sds_name, is_remote=False, bbox=bbox, - vrt_path=vrt_path) - else: - to_unlink += build_viirs_vrt(paths=urls, sds_name=sds_name, is_remote=True, bbox=bbox, vrt_path=vrt_path) - - to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] - _, vrt_name = os.path.split(vrt_path) - output_tif_path: str = os.path.join(dst_dir, vrt_name.replace('.vrt', '.tif')) - - translate_options = dict( - format="GTiff", - creationOptions=[ - "TILED=YES", # Optimizes read performance - "BIGTIFF=IF_SAFER", - "COPY_SRC_MDD=YES" - ], - outputSRS='EPSG:4326', - - ) +from rapida.ntl import vis - if progress: - task = progress.add_task(f'[red]Extracting data using GDAL') - callback_dict = dict( - callback=gdal_callback, - callback_data=(progress, task, None) - ) - translate_options.update(callback_dict) - # Pass the dataset object directly instead of the string path - ds = gdal.Translate(destName=output_tif_path, srcDS=vrt_path, **translate_options) - ds = None +logger = logging.getLogger('rapida') - [gdal.Unlink(e) for e in to_unlink] - return output_tif_path def spatial_filter(outage_map, min_size=2): @@ -277,6 +82,9 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N logger.info(f'Fetching baseline imagery for {bbox} {nominal_date}') baseline_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable='baseline') + print(data_urls) + print(baseline_urls) + # data = {} # with rasterio.open(target_data) as target, rasterio.open(baseline_data) as base: # nrt = np.log1p(target.read(1, masked=True)) diff --git a/rapida/ntl/nasa/search.py b/rapida/ntl/nasa/search.py index a9a51936..1821f777 100644 --- a/rapida/ntl/nasa/search.py +++ b/rapida/ntl/nasa/search.py @@ -233,7 +233,6 @@ def search( key = f'{product}_{timestamp}' for tile in expected_tiles: url = cache.fetch(key=key, tile=tile) - print(url) if url: cached_results.append(url2result(url=url, store=False)) found_tiles_count += 1 diff --git a/rapida/ntl/noaa/cmask.py b/rapida/ntl/noaa/cmask.py index 63443c9e..c01b007c 100644 --- a/rapida/ntl/noaa/cmask.py +++ b/rapida/ntl/noaa/cmask.py @@ -13,6 +13,8 @@ from osgeo import gdal from shapely.ops import transform from rapida.ntl import cache +from shapely.geometry import Polygon +from shapely.ops import unary_union gdal.UseExceptions() @@ -23,15 +25,161 @@ def shift_to_360(lon, lat, z=None): shifted_lon = lon + 360.0 if lon < 0 else lon return (shifted_lon, lat) if z is None else (shifted_lon, lat, z) -def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): - fs = fsspec.filesystem("http") +def bounds_from_url(hdf_url: str): + purl = urllib.parse.urlparse(hdf_url) _, filename = os.path.split(purl.path) + storage_options = {} + if hdf_url.startswith(('http', 'https')): + storage_options.update({'block_size': 1024 * 1024}) + with fsspec.open(hdf_url, **storage_options) as f: + with h5py.File(f, "r") as hfile: + # Now it reads at HTTP speeds without the boto3 overhead + return bounds_from_file(hfile=hfile) + + +def bounds_from_file(hfile:h5py.File) -> Polygon: + + lat = hfile['Latitude'] + lon = hfile['Longitude'] + + # 1. Fetch the full perimeter arrays into memory. + # Thanks to fsspec's 1MB block cache, this resolves in just 1 or 2 HTTP requests. + top_lat = lat[0, :] + top_lon = lon[0, :] + + bot_lat = lat[-1, :] + bot_lon = lon[-1, :] + + left_lat = lat[:, 0] + left_lon = lon[:, 0] + + right_lat = lat[:, -1] + right_lon = lon[:, -1] + + step = 330 + + def sample_edge(arr): + """Samples the array at the given step, strictly ensuring the exact final corner is included.""" + indices = list(range(0, len(arr), step)) + if indices[-1] != len(arr) - 1: + indices.append(len(arr) - 1) + return arr[indices] + + # 2. Top edge (Left to Right) + t_lon = list(sample_edge(top_lon)) + t_lat = list(sample_edge(top_lat)) + + # 3. Right edge (Top to Bottom) + r_lon = list(sample_edge(right_lon)) + r_lat = list(sample_edge(right_lat)) + + # 4. Bottom edge (Right to Left) -> Needs Reversing to maintain the continuous ring + b_lon = list(sample_edge(bot_lon))[::-1] + b_lat = list(sample_edge(bot_lat))[::-1] + + # 5. Left edge (Bottom to Top) -> Needs Reversing + l_lon = list(sample_edge(left_lon))[::-1] + l_lat = list(sample_edge(left_lat))[::-1] + + # 6. Combine all edges into a single continuous boundary ring + ring_lons = t_lon + r_lon + b_lon + l_lon + ring_lats = t_lat + r_lat + b_lat + l_lat + + # 7. Create the dense, curved polygon + poly = Polygon(zip(ring_lons, ring_lats)) + + # Optional but recommended: run a 0-distance buffer to instantly fix any + # micro-intersections or invalid topologies that occur precisely at the corner joints + return poly.buffer(0) +import math +from itertools import combinations +def select_required_granules(sorted_granules: list, bbox: tuple) -> list: + boxpoly = box(*bbox, ccw=True) + + for combo_size in range(2, len(sorted_granules) + 1): + + for combo in combinations(sorted_granules, combo_size): + # Merge the geometries for this specific combination + merged_poly = unary_union([bounds_from_url(g.url) for g in combo]) - with fs.open(hdf_url, block_size=1024 * 1024) as f: + # 3. FLOATING-POINT SAFE COVERAGE CHECK + # We use difference() because .within() can fail on microscopic 1e-15 gap artifacts + uncovered = boxpoly.difference(merged_poly) + + if uncovered.is_empty or uncovered.area < 1e-6: + logger.info(f"Success: BBOX covered perfectly by {combo_size} granule(s).") + return combo # Exits immediately with the absolute minimum required set + + logger.warning("Exhausted all combinations. BBOX cannot be fully covered by available data.") + return tuple() + + + + # for granule in sorted_granules: + # bounds = bounds_from_url(granule.url) + # intersection = bbox.intersection(bounds) + # if intersection.area > 1e-6: + # selected.append(granule) + # coverage = intersection.area / bbox.area * 100 + # if math.isclose(coverage, b=100, abs_tol=1e-2): + # return selected + + +def select_required_granules_old(sorted_granules: list, bbox: tuple) -> list: + """ + Selects the minimum number of high-scoring granules required to fully cover the BBOX. + Assumes `sorted_granules` is already sorted by Score (descending). + """ + target_poly = box(*bbox, ccw=True) + uncovered_area = target_poly + selected_granules = [] + + for granule in sorted_granules: + # 1. Fetch the working_bounds for this granule + + working_bounds = bounds_from_url(granule.url) + + if not working_bounds: + continue + + # 2. Check if this granule covers any REMAINING uncovered area + if working_bounds.intersects(uncovered_area): + intersection = working_bounds.intersection(uncovered_area) + + # Use a tiny area threshold to ignore topological slivers/floating point artifacts + if intersection.area > 1e-6: + selected_granules.append(granule) + _, filename = os.path.split(granule.url) + logger.info(f"Selected {granule.sat} at {granule.timestamp} - Added coverage.") + with open(os.path.join('/tmp/noaa/', filename.replace('.nc', '.geojson')), "w") as f: + f.write(to_geojson(working_bounds)) + # 3. Punch a hole in the target area + uncovered_area = uncovered_area.difference(working_bounds) + + # 4. Check if we achieved 100% coverage + if uncovered_area.is_empty or uncovered_area.area < 1e-6: + logger.info("BBOX fully covered.") + break + + if not uncovered_area.is_empty and uncovered_area.area > 1e-6: + logger.warning(f"Exhausted all granules. BBOX still has uncovered regions.") + + return selected_granules + + +def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): + #fs = fsspec.filesystem("http") + purl = urllib.parse.urlparse(hdf_url) + dst_dir, filename = os.path.split(purl.path) + storage_options = {} + if hdf_url.startswith(('http', 'https')): + storage_options.update({'block_size':1024*1024}) + with fsspec.open(hdf_url, **storage_options) as f: with h5py.File(f, "r") as hfile: # Now it reads at HTTP speeds without the boto3 overhead - bounds_poly = wkt.loads(hfile.attrs['geospatial_bounds'].decode('utf-8')) + #bounds_poly = wkt.loads(hfile.attrs['geospatial_bounds'].decode('utf-8')) + bounds_poly = bounds_from_file(hfile) bbox_poly = box(*bbox, ccw=True) # 2. The Kiribati Ghost Detection minx, miny, maxx, maxy = bounds_poly.bounds @@ -50,10 +198,9 @@ def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): return False intersection_poly = working_bbox.intersection(working_bounds) perc_intersection = round(intersection_poly.area/working_bbox.area * 100) - # with open("/tmp/bbox.geojson", "w") as ff: + # with open(os.path.join(dst_dir, 'bbox.geojson'), "w") as ff: # ff.write(to_geojson(bbox_poly)) - # n = filename.split('_')[3] - # with open(f"/tmp/granule_{n}.geojson", "w") as f: + # with open(os.path.join(dst_dir, filename.replace('.nc', '.geojson')), "w") as f: # f.write(to_geojson(bounds_poly)) return True, perc_intersection @@ -161,7 +308,10 @@ def cloud_coverage_fast(hdf_url: str, bbox: Iterable[float], def cloud_coverage(hdf_url: str, bbox: list) -> int: - + # 1. Initialize GDAL environment INSIDE the worker process + gdal.UseExceptions() + gdal.PushErrorHandler('CPLQuietErrorHandler') + gdal.SetConfigOption('GDAL_HTTP_TIMEOUT', '60') # Prevents hanging vsicurl requests _, file_name = os.path.split(hdf_url) cc = cache.fetch(key=file_name) @@ -202,7 +352,7 @@ def cloud_coverage_batch(urls: list[str], bbox: Iterable[float], max_threads: in if progress: master_task = progress.add_task( description=f"[cyan]Computing cloud coverage .... ", - total=None + total=len(urls) ) then = datetime.datetime.now() @@ -231,3 +381,6 @@ def cloud_coverage_batch(urls: list[str], bbox: Iterable[float], max_threads: in return results + +url = '/tmp/noaa/JRR-CloudMask_v3r2_n21_s202605262338220_e202605262339467_c202605270028035.nc' +bbox_in_hdf(hdf_url=url, bbox=(34.4,47,38.5,51)) \ No newline at end of file diff --git a/rapida/ntl/noaa/search.py b/rapida/ntl/noaa/search.py index 7d2650df..809a0d06 100644 --- a/rapida/ntl/noaa/search.py +++ b/rapida/ntl/noaa/search.py @@ -79,7 +79,7 @@ def __hash__(self): return hash(str(self)) def __str__(self): - ddict = dict(satellite=self.sat, timestamp=self.timestamp,offset_km=self.offset,elevation=self.elevation, cloud_coverage=self.cloud_cover, rank=self.rank) + ddict = dict(satellite=self.sat, timestamp=self.timestamp,offset_km=self.offset,elevation=self.elevation, cloud_coverage=self.cloud_cover, rank=self.rank, bbox_perc_intersection=self.pint) return json.dumps(ddict, separators=(',', ':'),) def __repr__(self): return f'{self.sat} granule {self.id} with sat rank {self.sat_rank:0d} and offset {self.offset} km from SSP featuring elevation of {self.elevation:.0f} degrees ' @@ -622,7 +622,7 @@ async def async_search_granules( if isinstance(cloud_cover, Exception): continue g.cloud_cover = cloud_cover - g.url = os.path.split(cm_url)[-1] + g.url = cm_url selected_granules.append(g) selected_granules.sort(key=lambda g: g.rank, reverse=True) diff --git a/rapida/util/gpd_overlay.py b/rapida/util/gpd_overlay.py index 0b11064c..b1e20b32 100644 --- a/rapida/util/gpd_overlay.py +++ b/rapida/util/gpd_overlay.py @@ -55,7 +55,7 @@ def process_chunk(chunk_rows, overlay_df, overlay_df_sindex, crs): single_row_gdf = gpd.GeoDataFrame([row], geometry='geometry', crs=crs) poly_geom = single_row_gdf.geometry.iloc[0] - possible_matches_index = list(overlay_df_sindex.intersection(poly_geom.bounds)) + possible_matches_index = list(overlay_df_sindex.intersection(poly_geom.bounds_from_file)) possible_matches = overlay_df.iloc[possible_matches_index] precise_matches = possible_matches[possible_matches.intersects(poly_geom)].copy() precise_matches.loc[:, "geometry"] = precise_matches.geometry.intersection(poly_geom) From 33f699525ec15d090768a70352b6ee0fa5887e69 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Thu, 4 Jun 2026 23:21:23 +0200 Subject: [PATCH 09/20] finish fetch that included download --- rapida/cli/ntl.py | 18 ++++++- rapida/ntl/fetch.py | 89 +++++++++++++++++++++++++----- rapida/ntl/nasa/io.py | 39 ++++++++++++++ rapida/ntl/nasa/outage.py | 44 ++++++++++++--- rapida/ntl/noaa/cmask.py | 111 ++++++++++++-------------------------- rapida/ntl/noaa/io.py | 6 +-- 6 files changed, 204 insertions(+), 103 deletions(-) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index 9cfd1eba..9249ef2e 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -425,12 +425,26 @@ async def bulk_download(ctx, bbox:tuple[numbers.Number]=None, start_date:datetim help=f'One or more of the RAPIDA NTL deliverables.' ) +@click.option( + "--dst-dir", + "dst_dir", # Function argument name + type=click.Path( + exists=False, # Set to True if you want Click to fail if the dir doesn't exist yet + file_okay=False, # Strictly enforce that this is a directory, not a file + dir_okay=True, + resolve_path=True # Resolves relative paths (like '.') to absolute paths automatically + ), + default=tempfile.gettempdir(), # Defaults to the current working directory + show_default=True, # Tells the user what the default is in the --help menu + help="Destination directory to save the downloaded the images." +) + @click.pass_context -async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None): +async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None): progress = ctx.obj.get('progress') - return await fetch_ntl(bbox=bbox,nominal_date=nominal_date, deliverable=deliverable, progress=progress, ) + return await fetch_ntl(bbox=bbox,nominal_date=nominal_date, deliverable=deliverable, progress=progress, dst_dir=dst_dir ) @ntl.command(short_help=f'Execute crisis impact detection (48h Alerts / 72h Assessments)') diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py index f0d74272..4148d67b 100644 --- a/rapida/ntl/fetch.py +++ b/rapida/ntl/fetch.py @@ -1,4 +1,4 @@ -from shapely.geometry import box + from datetime import datetime import numbers import logging @@ -9,17 +9,28 @@ from rapida.ntl.nasa import const as nasaconst from rapida.ntl.noaa.search import async_search_granules, VIIRSNavigator from rapida.ntl.noaa.cmask import select_required_granules - +from rapida.ntl.nasa.io import download as download_from_nasa +from rapida.ntl.noaa.io import locate_file, download as download_from_noaa +import asyncio DELIVERABLES = tuple([g.upper() for g in generate_variables()]) logger = logging.getLogger('rapida') - - -async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - progress:Progress=None): +async def download_and_track(granule, dest_dir, prog_bar): + # Run the actual download + result_dict = await download_from_noaa( + satellite=granule.sat, + timestamp=granule.timestamp, + dst_dir=dest_dir, + progress=prog_bar + ) + # Return both the timestamp AND the result + return granule.timestamp, result_dict + +async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, + deliverable:str=None, dst_dir:str=None, progress:Progress=None): """ - Indentify and download the BEST quality available data suitable to detect outages. + Indentify and download the BEST available data suitable to detect outages. :param bbox: :param nominal_date: :param progress: @@ -30,13 +41,49 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, del if 'noaa' in deliverable: # operational real time data + logger.info(f'Going to predict VIIRS satellite passes for {nominal_date.date()} over target area: {bbox}') granules = await async_search_granules( satellites=None, nominal_date=nominal_date, bbox=bbox, cmask=True, progress=progress) + logger.info(f'Found {len(granules)} descending granules') + selected_granules = select_required_granules(sorted_granules=granules, bbox=bbox, progress=progress) + logger.info(f'Selected {len(selected_granules)} granule(s) that cover(s) bbox {bbox}') + tasks = [] + progress_task = None + + for granule in selected_granules: + # We no longer need a dictionary, just a simple list of tasks + task = asyncio.create_task( + download_and_track(granule, dst_dir, progress) + ) + tasks.append(task) + + downloaded_files = {} + if progress: + progress_task = progress.add_task(description=f'[red]Downloading VIIRS images...', total=len(tasks)) + for coro in asyncio.as_completed(tasks, timeout=100 * 3 * len(tasks)): + try: + # Unpack the tuple we returned from our wrapper + timestamp, downloaded_files_dict = await coro + + downloaded_files[timestamp] = downloaded_files_dict + if progress and progress_task is not None: + progress.update(progress_task, description=f'[green]🡇 Downloaded images for timestamp {timestamp}', advance=1) + logger.info(f'Downloaded operational VIIRS images for timestamp {timestamp}') + except Exception as e: + logger.error(e) + + except asyncio.CancelledError as ce: + for atask in tasks: + if not atask.done(): + atask.cancel() + if progress and progress_task is not None: + progress.remove_task(progress_task) + await asyncio.gather(*tasks, return_exceptions=True) + raise + + return downloaded_files - required_granules = select_required_granules(sorted_granules=granules, bbox=bbox) - - return [g.url for g in required_granules] expected_tiles = get_intersecting_tiles(bbox=bbox) routes = nasaconst.ROUTES stream = nasaconst.ARCHIVE @@ -47,7 +94,7 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, del if 'nrt' in deliverable: stream = nasaconst.OPERATIONAL - urls = {} + downloaded_files = {} for processing_level in processing_levels: for route in routes: @@ -79,15 +126,29 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, del continue logger.info( - f'Selected {len(selected_urls)} images for deliverable {deliverable} at processing level {processing_level} {route}') + f'Selected {len(selected_urls)} images for deliverable {deliverable} for processing level {processing_level} route {route}') + + timestamps = set(selected_urls.values()) + if len(timestamps) > 1: + logger.info( + f'Got more than one timestamp for for {selected_product} stream {stream} and processing level {processing_level} over route {route} ') + continue + + timestamp, *_ = timestamps + urls = list(selected_urls) + + downloaded = await download_from_nasa(timestamp=timestamp, product=selected_product, dst_dir=dst_dir, urls=urls, + progress=progress) + logger.info(f'Successfully downloaded {len(downloaded)} selected images ') # Save the successful URLs - urls[selected_product] = selected_urls + downloaded_files[selected_product] = downloaded # Break out of the 'routes' loop to stop searching for this processing_level break - return urls + + return downloaded_files diff --git a/rapida/ntl/nasa/io.py b/rapida/ntl/nasa/io.py index e2cd9380..274169f1 100644 --- a/rapida/ntl/nasa/io.py +++ b/rapida/ntl/nasa/io.py @@ -155,6 +155,45 @@ def get_val(key): tile_vrts += [vrt_path] return tile_vrts +async def extract(image_files: list[str] = None, dst_tif_path:str=None, sds_name:str=None, + bbox: tuple[float, float, float, float] = None, progress=None, + ): + _, tif_name = os.path.split(dst_tif_path) + + vrt_path = f'/vsimem/{tif_name}.vrt' + to_unlink = [] + + to_unlink += h52vrt(paths=image_files, sds_name=sds_name, is_remote=False, bbox=bbox,vrt_path=vrt_path) + + to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] + + + + translate_options = dict( + format="GTiff", + creationOptions=[ + "TILED=YES", # Optimizes read performance + "BIGTIFF=IF_SAFER", + "COPY_SRC_MDD=YES" + ], + outputSRS='EPSG:4326', + + ) + + if progress: + task = progress.add_task(f'[red]Extracting data using GDAL') + callback_dict = dict( + callback=gdal_callback, + callback_data=(progress, task, None) + ) + translate_options.update(callback_dict) + + # Pass the dataset object directly instead of the string path + ds = gdal.Translate(destName=output_tif_path, srcDS=vrt_path, **translate_options) + ds = None + + [gdal.Unlink(e) for e in to_unlink] + return output_tif_path async def download_and_extract(file_urls: list[str] = None, stream: str = None, route=None, processing_level=None, product: str = None, bbox: tuple[float, float, float, float] = None, vsimem=False, diff --git a/rapida/ntl/nasa/outage.py b/rapida/ntl/nasa/outage.py index 686fcf90..1d6526b6 100644 --- a/rapida/ntl/nasa/outage.py +++ b/rapida/ntl/nasa/outage.py @@ -4,12 +4,13 @@ import logging from rich.progress import Progress from rapida.ntl.fetch import fetch + from scipy.ndimage import uniform_filter import numpy as np - +import asyncio import rasterio from scipy.ndimage import label - +from pathlib import Path from rapida.ntl import vis @@ -77,13 +78,42 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N dst_dir:str=None, progress:Progress=None): logger.info(f'Fetching best imagery for {bbox} {nominal_date}') - data_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable=deliverable) - logger.info(f'Fetching baseline imagery for {bbox} {nominal_date}') - baseline_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable='baseline') + if 'noaa' in deliverable: + pass + else: + # fetch from NASA tries to return A1 and A2 products + image_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable=deliverable) + + downloaded_files = [] + tasks = [] + progress_task = None + + + + # + # for task in asyncio.as_completed(tasks, timeout=20 * len(tasks)): + # try: + # downloaded_files = await task + # print(downloaded_files) + # if progress and progress_task is not None: + # progress.update(progress_task, description=f'[green]🡇 {downloaded_file.name}', advance=1) + # downloaded_files.append(str(downloaded_file)) + # except Exception as e: + # logger.error(e) + # + # except asyncio.CancelledError as ce: + # for atask in tasks: + # if not atask.done(): + # atask.cancel() + # await asyncio.gather(*tasks, return_exceptions=True) + # raise + + #logger.info(f'Fetching baseline imagery for {bbox} {nominal_date}') + #baseline_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable='baseline') + + - print(data_urls) - print(baseline_urls) # data = {} # with rasterio.open(target_data) as target, rasterio.open(baseline_data) as base: diff --git a/rapida/ntl/noaa/cmask.py b/rapida/ntl/noaa/cmask.py index c01b007c..3074da80 100644 --- a/rapida/ntl/noaa/cmask.py +++ b/rapida/ntl/noaa/cmask.py @@ -15,6 +15,7 @@ from rapida.ntl import cache from shapely.geometry import Polygon from shapely.ops import unary_union +from itertools import combinations gdal.UseExceptions() @@ -92,81 +93,39 @@ def sample_edge(arr): # Optional but recommended: run a 0-distance buffer to instantly fix any # micro-intersections or invalid topologies that occur precisely at the corner joints return poly.buffer(0) -import math -from itertools import combinations -def select_required_granules(sorted_granules: list, bbox: tuple) -> list: - boxpoly = box(*bbox, ccw=True) - - for combo_size in range(2, len(sorted_granules) + 1): - - for combo in combinations(sorted_granules, combo_size): - # Merge the geometries for this specific combination - merged_poly = unary_union([bounds_from_url(g.url) for g in combo]) - - # 3. FLOATING-POINT SAFE COVERAGE CHECK - # We use difference() because .within() can fail on microscopic 1e-15 gap artifacts - uncovered = boxpoly.difference(merged_poly) - - if uncovered.is_empty or uncovered.area < 1e-6: - logger.info(f"Success: BBOX covered perfectly by {combo_size} granule(s).") - return combo # Exits immediately with the absolute minimum required set - - logger.warning("Exhausted all combinations. BBOX cannot be fully covered by available data.") - return tuple() - - - - # for granule in sorted_granules: - # bounds = bounds_from_url(granule.url) - # intersection = bbox.intersection(bounds) - # if intersection.area > 1e-6: - # selected.append(granule) - # coverage = intersection.area / bbox.area * 100 - # if math.isclose(coverage, b=100, abs_tol=1e-2): - # return selected - - -def select_required_granules_old(sorted_granules: list, bbox: tuple) -> list: - """ - Selects the minimum number of high-scoring granules required to fully cover the BBOX. - Assumes `sorted_granules` is already sorted by Score (descending). - """ - target_poly = box(*bbox, ccw=True) - uncovered_area = target_poly - selected_granules = [] - - for granule in sorted_granules: - # 1. Fetch the working_bounds for this granule - - working_bounds = bounds_from_url(granule.url) - - if not working_bounds: - continue - - # 2. Check if this granule covers any REMAINING uncovered area - if working_bounds.intersects(uncovered_area): - intersection = working_bounds.intersection(uncovered_area) - - # Use a tiny area threshold to ignore topological slivers/floating point artifacts - if intersection.area > 1e-6: - selected_granules.append(granule) - _, filename = os.path.split(granule.url) - logger.info(f"Selected {granule.sat} at {granule.timestamp} - Added coverage.") - with open(os.path.join('/tmp/noaa/', filename.replace('.nc', '.geojson')), "w") as f: - f.write(to_geojson(working_bounds)) - # 3. Punch a hole in the target area - uncovered_area = uncovered_area.difference(working_bounds) - - # 4. Check if we achieved 100% coverage - if uncovered_area.is_empty or uncovered_area.area < 1e-6: - logger.info("BBOX fully covered.") - break - - if not uncovered_area.is_empty and uncovered_area.area > 1e-6: - logger.warning(f"Exhausted all granules. BBOX still has uncovered regions.") - - return selected_granules +def select_required_granules(sorted_granules: list, bbox: tuple, progress:Progress=None) -> list: + boxpoly = box(*bbox, ccw=True) + best_poly = bounds_from_url(sorted_granules[0].url) + uncovered = boxpoly.difference(best_poly) + if uncovered.is_empty or uncovered.area < 1e-6: + logger.debug(f"BBOX {bbox} is covered by first granule.") + return [sorted_granules[0]] + progress_task = None + try: + if progress: + progress_task = progress.add_task(description=f'Selecting best granules tha cover {bbox}', total=None) + for combo_size in range(2, len(sorted_granules) + 1): + if progress and progress_task: + progress.update(progress_task, description=f'Evaluating granules by pairs of {combo_size} ') + for combo in combinations(sorted_granules, combo_size): + + # Merge the geometries for this specific combination + merged_poly = unary_union([bounds_from_url(g.url) for g in combo]) + + # 3. FLOATING-POINT SAFE COVERAGE CHECK + # We use difference() because .within() can fail on microscopic 1e-15 gap artifacts + uncovered = boxpoly.difference(merged_poly) + + if uncovered.is_empty or uncovered.area < 1e-6: + logger.debug(f"Success: BBOX covered perfectly by {combo_size} granule(s).") + return combo # Exits immediately with the absolute minimum required set + + logger.warning("Exhausted all combinations. BBOX cannot be fully covered by available data.") + return tuple() + finally: + if progress and progress_task: + progress.remove_task(progress_task) def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): #fs = fsspec.filesystem("http") @@ -311,7 +270,7 @@ def cloud_coverage(hdf_url: str, bbox: list) -> int: # 1. Initialize GDAL environment INSIDE the worker process gdal.UseExceptions() gdal.PushErrorHandler('CPLQuietErrorHandler') - gdal.SetConfigOption('GDAL_HTTP_TIMEOUT', '60') # Prevents hanging vsicurl requests + gdal.SetConfigOption('GDAL_HTTP_TIMEOUT', '300') # Prevents hanging vsicurl requests _, file_name = os.path.split(hdf_url) cc = cache.fetch(key=file_name) @@ -382,5 +341,3 @@ def cloud_coverage_batch(urls: list[str], bbox: Iterable[float], max_threads: in return results -url = '/tmp/noaa/JRR-CloudMask_v3r2_n21_s202605262338220_e202605262339467_c202605270028035.nc' -bbox_in_hdf(hdf_url=url, bbox=(34.4,47,38.5,51)) \ No newline at end of file diff --git a/rapida/ntl/noaa/io.py b/rapida/ntl/noaa/io.py index b13aefbe..848520aa 100644 --- a/rapida/ntl/noaa/io.py +++ b/rapida/ntl/noaa/io.py @@ -220,7 +220,7 @@ async def fetch_file(satellite:str=None, provider:str=None, path:str=None, size: if down_task:progress.remove_task(down_task) -async def fetch_ntl(found_paths:dict[str, list]=None, satellite:str=None, dst_dir='/tmp', progress=None): +async def fetch_ntl(found_paths:dict[str, list]=None, satellite:str=None, dst_dir:str=None, progress=None): # Download logic (Surgical io to local SSD) tasks = [] @@ -250,11 +250,11 @@ async def fetch_ntl(found_paths:dict[str, list]=None, satellite:str=None, dst_di async def download(satellite:str=None, timestamp:str=None, source:str=None, - products:Iterable[str]=PRODUCT_NAMES, dest_dir='/tmp', progress=None): + products:Iterable[str]=PRODUCT_NAMES, dst_dir:str=None, progress=None): dt = datetime.strptime(timestamp, '%Y%m%d%H%M') logger.info(f'Locating files for satellite {satellite} timestamp {timestamp} ') found_files = await locate_file(satellite=satellite, dt=dt, source=source, products=products) - return await fetch_ntl(found_paths=found_files, dst_dir=dest_dir, satellite=satellite, progress=progress) + return await fetch_ntl(found_paths=found_files, dst_dir=dst_dir, satellite=satellite, progress=progress) def bytesto(bytes, to, bsize=1024): From 116c93d2ba887de8557cd63506cb0055928ba326 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Sun, 7 Jun 2026 12:36:04 +0200 Subject: [PATCH 10/20] nasa ourage implementation --- rapida/cli/ntl.py | 59 +++++++-- rapida/ntl/fetch.py | 22 ++-- rapida/ntl/nasa/io.py | 69 +++++++++-- rapida/ntl/nasa/outage.py | 220 +++++++++++++++++++++++++--------- rapida/ntl/nasa/search.py | 153 ++++++++++++++++++++++- rapida/ntl/noaa/io.py | 10 +- rapida/ntl/{nasa => }/util.py | 24 ++++ 7 files changed, 453 insertions(+), 104 deletions(-) rename rapida/ntl/{nasa => }/util.py (63%) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index 9249ef2e..2fa443bf 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -5,6 +5,8 @@ from typing import Iterable import click import tempfile + + from rapida.cli import RapidaCommandGroup from rapida.ntl.nasa.const import ARCHIVE, OPERATIONAL, PROCESSING_LEVEL_NAMES, PRODUCT_NAMES, PRODUCTS, \ NTL_FILENAME_PATTERN, ROUTES, COLLECTIONS @@ -232,6 +234,12 @@ def download(): help='A specific tile number conforming to NASA BalckMarble 10x10 degrres tile numbering. Ex: h21v03 ' ) +@click.option('-b', '--bbox', + required=False, + type=BboxParamType(), + help='Bounding box xmin/west, ymin/south, xmax/east, ymax/north' + ) + @click.option( "--dst-dir", "dst_dir", # Function argument name @@ -248,10 +256,16 @@ def download(): @click.pass_context -async def download_nasa(ctx, timestamp:str = None, product:str=None, tile:str=None, dst_dir:str=None): +async def download_nasa(ctx, timestamp:str = None, product:str=None, tile:str=None, + bbox:tuple[float, float, float, float]=None,dst_dir:str=None): progress = ctx.obj.get('progress') + if tile and bbox: + raise click.UsageError("Illegal usage: `--tile` and `--bbox` are mutually exclusive.") + + downloaded_files = await download_from_nasa(timestamp=timestamp, product=product, + tile=tile, bbox=bbox, dst_dir=dst_dir,progress=progress) + - downloaded_files = await download_from_nasa(timestamp=timestamp, product=product, tile=tile, dst_dir=dst_dir,progress=progress) if downloaded_files: table = Table(title=f"Downloaded files for {product.upper()} {timestamp} ", title_style="bold yellow") @@ -260,13 +274,14 @@ async def download_nasa(ctx, timestamp:str = None, product:str=None, tile:str=No table.add_column("Timestamp", style="red", justify='center') table.add_column("Tile", style="red", justify='center') table.add_column("Size", style="green", justify='center') - for local_file_path in downloaded_files: - _, file_name = os.path.split(local_file_path) - file_size = os.path.getsize(local_file_path) - m = NTL_FILENAME_PATTERN.match(file_name) - meta = m.groupdict() - tile = meta['tile'] - table.add_row(local_file_path, timestamp, tile, f'{file_size}') + for timestamp, files in downloaded_files.items(): + for local_file_path in files: + _, file_name = os.path.split(local_file_path) + file_size = os.path.getsize(local_file_path) + m = NTL_FILENAME_PATTERN.match(file_name) + meta = m.groupdict() + tile = meta['tile'] + table.add_row(local_file_path, timestamp, tile, f'{file_size}') progress.console.print(table) @@ -483,12 +498,34 @@ async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None ) + +@click.option( + '--cmask', '-cm', "cmask", + is_flag=True, + help=( + "Enable strict Cloud Masking (ignores pixels with NASA quality flags of 3). " + "Disable this flag during major storm events to prevent atmospheric noise " + "from erroneously masking out legitimate blackout signals." + ), + default=False +) +@click.option( + '--display', "display", + is_flag=True, + help=( + "Show a graphic visualization of the outage analysis." + "Useful to inspect the input imagery and debug/understand the outage results " + ), + default=False +) + @click.pass_context -async def detect(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None): +async def detect(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, + cmask:bool=True, dst_dir:str=None, display:bool=False): progress = ctx.obj.get('progress') return await detect_outage( bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, dst_dir=dst_dir, - progress=progress + progress=progress, cmask=cmask, display=display ) diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py index 4148d67b..c231e309 100644 --- a/rapida/ntl/fetch.py +++ b/rapida/ntl/fetch.py @@ -4,13 +4,13 @@ import logging from rich.progress import Progress from rapida.components.ntl.variables import generate_variables -from rapida.ntl.nasa.util import get_intersecting_tiles +from rapida.ntl.util import get_intersecting_tiles from rapida.ntl.nasa.search import search from rapida.ntl.nasa import const as nasaconst -from rapida.ntl.noaa.search import async_search_granules, VIIRSNavigator +from rapida.ntl.noaa.search import async_search_granules from rapida.ntl.noaa.cmask import select_required_granules from rapida.ntl.nasa.io import download as download_from_nasa -from rapida.ntl.noaa.io import locate_file, download as download_from_noaa +from rapida.ntl.noaa.io import download as download_from_noaa import asyncio DELIVERABLES = tuple([g.upper() for g in generate_variables()]) @@ -31,6 +31,7 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, dst_dir:str=None, progress:Progress=None): """ Indentify and download the BEST available data suitable to detect outages. + :param dst_dir: :param bbox: :param nominal_date: :param progress: @@ -49,8 +50,6 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, selected_granules = select_required_granules(sorted_granules=granules, bbox=bbox, progress=progress) logger.info(f'Selected {len(selected_granules)} granule(s) that cover(s) bbox {bbox}') tasks = [] - progress_task = None - for granule in selected_granules: # We no longer need a dictionary, just a simple list of tasks task = asyncio.create_task( @@ -59,16 +58,12 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, tasks.append(task) downloaded_files = {} - if progress: - progress_task = progress.add_task(description=f'[red]Downloading VIIRS images...', total=len(tasks)) + for coro in asyncio.as_completed(tasks, timeout=100 * 3 * len(tasks)): try: # Unpack the tuple we returned from our wrapper timestamp, downloaded_files_dict = await coro - downloaded_files[timestamp] = downloaded_files_dict - if progress and progress_task is not None: - progress.update(progress_task, description=f'[green]🡇 Downloaded images for timestamp {timestamp}', advance=1) logger.info(f'Downloaded operational VIIRS images for timestamp {timestamp}') except Exception as e: logger.error(e) @@ -77,8 +72,6 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, for atask in tasks: if not atask.done(): atask.cancel() - if progress and progress_task is not None: - progress.remove_task(progress_task) await asyncio.gather(*tasks, return_exceptions=True) raise @@ -128,7 +121,6 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, logger.info( f'Selected {len(selected_urls)} images for deliverable {deliverable} for processing level {processing_level} route {route}') - timestamps = set(selected_urls.values()) if len(timestamps) > 1: logger.info( @@ -140,7 +132,9 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, downloaded = await download_from_nasa(timestamp=timestamp, product=selected_product, dst_dir=dst_dir, urls=urls, progress=progress) - logger.info(f'Successfully downloaded {len(downloaded)} selected images ') + + + logger.info(f'Successfully downloaded {len(downloaded[timestamp])} selected images ') # Save the successful URLs downloaded_files[selected_product] = downloaded diff --git a/rapida/ntl/nasa/io.py b/rapida/ntl/nasa/io.py index 274169f1..161ff9ca 100644 --- a/rapida/ntl/nasa/io.py +++ b/rapida/ntl/nasa/io.py @@ -12,6 +12,7 @@ import logging from urllib.parse import urlparse from rapida.ntl.nasa.search import stac_search +from rapida.ntl.util import get_intersecting_tiles from datetime import datetime import numbers from rapida.ntl.nasa import const as nasaconst @@ -155,20 +156,48 @@ def get_val(key): tile_vrts += [vrt_path] return tile_vrts -async def extract(image_files: list[str] = None, dst_tif_path:str=None, sds_name:str=None, - bbox: tuple[float, float, float, float] = None, progress=None, - ): - _, tif_name = os.path.split(dst_tif_path) - vrt_path = f'/vsimem/{tif_name}.vrt' +def extract_bb(image_files: list[str] = None, sds_name:str=None, return_gt=False, + bbox: tuple[float, float, float, float] = None, progress=None + ): + vrt_path = f'/vsimem/{secrets.token_hex(10)}.vrt' to_unlink = [] - to_unlink += h52vrt(paths=image_files, sds_name=sds_name, is_remote=False, bbox=bbox,vrt_path=vrt_path) - to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] + translate_options = dict( + format="MEM", + outputSRS='EPSG:4326', + ) + if progress: + task = progress.add_task(f'[red]Extracting data using GDAL') + callback_dict = dict( + callback=gdal_callback, + callback_data=(progress, task, None) + ) + translate_options.update(callback_dict) + # Pass the dataset object directly instead of the string path + ds = gdal.Translate(destName='', srcDS=vrt_path, **translate_options) + gt = ds.GetGeoTransform() + [gdal.Unlink(e) for e in to_unlink] + array = ds.GetRasterBand(1).ReadAsArray() + ds = None + if return_gt: + return array, gt + return array + +async def extract(image_files: list[str] = None, sds_name:str=None, product:str=None, dst_dir:str=None, + nominal_date:datetime=None, bbox: tuple[float, float, float, float] = None, progress=None + ): + product_tif_path = os.path.join(dst_dir, f'{product}_{nominal_date:%Y%m%d}_{nominal_date.timestamp()}.tif') + _, tif_name = os.path.split(product_tif_path) + + vrt_path = f'/vsimem/{tif_name}.vrt' + to_unlink = [] + to_unlink += h52vrt(paths=image_files, sds_name=sds_name, is_remote=False, bbox=bbox,vrt_path=vrt_path) + to_unlink = [e for e in to_unlink if gdal.VSIStatL(e) is not None] translate_options = dict( format="GTiff", creationOptions=[ @@ -189,11 +218,11 @@ async def extract(image_files: list[str] = None, dst_tif_path:str=None, sds_nam translate_options.update(callback_dict) # Pass the dataset object directly instead of the string path - ds = gdal.Translate(destName=output_tif_path, srcDS=vrt_path, **translate_options) + ds = gdal.Translate(destName=product_tif_path, srcDS=vrt_path, **translate_options) ds = None [gdal.Unlink(e) for e in to_unlink] - return output_tif_path + return Path(product_tif_path) async def download_and_extract(file_urls: list[str] = None, stream: str = None, route=None, processing_level=None, product: str = None, bbox: tuple[float, float, float, float] = None, vsimem=False, @@ -251,12 +280,25 @@ async def download_and_extract(file_urls: list[str] = None, stream: str = None, -async def download(timestamp: str = None, product: str = None, tile:str=None, dst_dir:str=None, urls:list[str]=None, progress:Progress=None): +async def download(timestamp: str = None, product: str = None, tile:str=None, + bbox:tuple[float, float, float, float]=None,dst_dir:str=None, urls:list[str]=[], progress:Progress=None): + + assert timestamp not in [None, ''], f'Invalid timestamp={timestamp}' + assert product not in [None, ''], f'Invalid product={product}' + if not urls: + if tile is None: + assert bbox, f'Invalid bbox={bbox}' + if tile is None and bbox is not None: + tiles = get_intersecting_tiles(bbox=bbox) + else: + tiles = tile, key = f'{product.upper()}_{timestamp}' - urls = cache.fetch(key=key, tile=tile) - if not urls: + for tile in tiles: + urls.append(cache.fetch(key=key, tile=tile)) + + if not len(urls) == len(tiles): logger.info(f'Failed to locate information in {cache.CACHE_PATH} for {product}-{timestamp}-{tile or ""} \n' \ f'Consider searching first.') return @@ -270,9 +312,10 @@ async def download(timestamp: str = None, product: str = None, tile:str=None, ds headers = {"Authorization": f"Bearer {ea_token}"} - return await download_remote_files( + downloaded_files = await download_remote_files( file_urls=urls,dst_folder=dst_dir, progress=progress, headers=headers ) + return {timestamp:downloaded_files} async def download_tile( diff --git a/rapida/ntl/nasa/outage.py b/rapida/ntl/nasa/outage.py index 1d6526b6..66d63689 100644 --- a/rapida/ntl/nasa/outage.py +++ b/rapida/ntl/nasa/outage.py @@ -1,17 +1,21 @@ - +import secrets +from calendar import month from datetime import datetime, timedelta import numbers import logging from rich.progress import Progress +from satpy.resample.base import resample +from rapida.ntl.util import write_outage_tif from rapida.ntl.fetch import fetch - -from scipy.ndimage import uniform_filter +from rapida.ntl.nasa.io import extract, extract_bb +from rapida.ntl.nasa import const as nasa_const +from scipy.ndimage import uniform_filter, gaussian_filter import numpy as np +import os import asyncio -import rasterio from scipy.ndimage import label from pathlib import Path -from rapida.ntl import vis + logger = logging.getLogger('rapida') @@ -33,6 +37,91 @@ def spatial_filter(outage_map, min_size=2): return mask_size[labeled_array] +import numpy as np +from scipy.ndimage import gaussian_filter + + +def calculate_regional_outage_simplified( + baseline_log_array: np.ndarray, + nrt_log_array: np.ndarray, + cloud_mask: np.ndarray, # Boolean array: True where clouds exist + sigma: float = 1.25, + log_drop_threshold: float = -0.69 # approx 50% drop in linear space +) -> np.ndarray: + """ + Calculates outages using direct log-differencing, which mathematically + represents proportional radiance loss. + """ + # 1. Strict Exclusion (No Data is better than Bad Data) + is_valid = ~np.isnan(nrt_log_array) & ~np.isnan(baseline_log_array) & ~cloud_mask + + # 2. Prevent edge-bleed during smoothing (using your fill method) + fill_val = np.nanmean(baseline_log_array[is_valid]) + base_filled = np.where(is_valid, baseline_log_array, fill_val) + nrt_filled = np.where(is_valid, nrt_log_array, fill_val) + + # 3. Apply Gaussian Filter (Handles spatial shifts/jitter) + base_smooth = gaussian_filter(base_filled, sigma=sigma, truncate=3.0) + nrt_smooth = gaussian_filter(nrt_filled, sigma=sigma, truncate=3.0) + + # 4. Direct Log Difference (This IS the ratio!) + # E.g., if NRT is half the Baseline, log1p(NRT) - log1p(Base) is roughly -0.69 + log_diff = nrt_smooth - base_smooth + + # 5. Mask out invalid areas from the final result + log_diff = np.where(is_valid, log_diff, np.nan) + + # 6. Define the Outage Boolean Map + # We only care about areas that dropped by more than our threshold + outage_map = log_diff < log_drop_threshold + + # Optional: You can return log_diff for continuous analysis (severity), + # or outage_map for binary polygons. + return log_diff, outage_map + +def rigorous_ssim(img1, img2, data_range=12.0) -> np.ndarray: + """ + Computes standard Wang et al. (2004) SSIM with safety checks for + MaskedArrays and floating-point catastrophic cancellation. + """ + # 1. Safely extract masked data and force 64-bit precision + img1 = img1.filled(0.0) if hasattr(img1, 'filled') else img1 + img2 = img2.filled(0.0) if hasattr(img2, 'filled') else img2 + + img1 = img1.astype(np.float64) + img2 = img2.astype(np.float64) + + # 2. Lock the stability constants globally + K1, K2 = 0.01, 0.03 + C1 = (K1 * data_range) ** 2 + C2 = (K2 * data_range) ** 2 + + sigma = 1.5 + trunc = 3.5 + + # 3. Compute local means + mu1 = gaussian_filter(img1, sigma=sigma, truncate=trunc) + mu2 = gaussian_filter(img2, sigma=sigma, truncate=trunc) + + mu1_sq = mu1 ** 2 + mu2_sq = mu2 ** 2 + mu1_mu2 = mu1 * mu2 + + # 4. Compute local variances & CLAMP to 0 to prevent float precision collapse + sigma1_sq = gaussian_filter(img1 ** 2, sigma=sigma, truncate=trunc) - mu1_sq + sigma2_sq = gaussian_filter(img2 ** 2, sigma=sigma, truncate=trunc) - mu2_sq + sigma12 = gaussian_filter(img1 * img2, sigma=sigma, truncate=trunc) - mu1_mu2 + + sigma1_sq = np.maximum(0, sigma1_sq) + sigma2_sq = np.maximum(0, sigma2_sq) + + # 5. Compute full SSIM map + num = (2 * mu1_mu2 + C1) * (2 * sigma12 + C2) + den = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2) + ssim_map = num / den + + return ssim_map + def pure_numpy_ssim(nrt_masked: np.ma.MaskedArray, baseline_masked: np.ma.MaskedArray, win_size: int = 7) -> np.ndarray: """ Computes the Structural Similarity Index (SSIM) between two images @@ -75,56 +164,77 @@ def pure_numpy_ssim(nrt_masked: np.ma.MaskedArray, baseline_masked: np.ma.Masked async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - dst_dir:str=None, progress:Progress=None): + dst_dir:str=None, cmask:bool=True, display:bool=False, progress:Progress=None): + data = {} + logger.info(f'Fetching best imagery for {deliverable} {bbox}-{nominal_date} ') + baseline_nominal_date = nominal_date - timedelta(weeks=6) + downloaded_baseline_files = await fetch(bbox=bbox, nominal_date=baseline_nominal_date, + progress=progress, deliverable='baseline', dst_dir=dst_dir) + baseline_product, baseline_files = next(iter(downloaded_baseline_files.items())) + timestamp, baseline_image_files = next(iter(baseline_files.items())) + baseline_array, gt = extract_bb(image_files=baseline_image_files, sds_name=nasa_const.SUB_DATASETS['A3'], bbox=bbox, + progress=progress, return_gt=True) - logger.info(f'Fetching best imagery for {bbox} {nominal_date}') - if 'noaa' in deliverable: - pass - else: - # fetch from NASA tries to return A1 and A2 products - image_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable=deliverable) - - downloaded_files = [] - tasks = [] - progress_task = None - - - - # - # for task in asyncio.as_completed(tasks, timeout=20 * len(tasks)): - # try: - # downloaded_files = await task - # print(downloaded_files) - # if progress and progress_task is not None: - # progress.update(progress_task, description=f'[green]🡇 {downloaded_file.name}', advance=1) - # downloaded_files.append(str(downloaded_file)) - # except Exception as e: - # logger.error(e) - # - # except asyncio.CancelledError as ce: - # for atask in tasks: - # if not atask.done(): - # atask.cancel() - # await asyncio.gather(*tasks, return_exceptions=True) - # raise - - #logger.info(f'Fetching baseline imagery for {bbox} {nominal_date}') - #baseline_urls = await fetch(bbox=bbox, nominal_date=nominal_date, progress=progress, deliverable='baseline') - - - - - # data = {} - # with rasterio.open(target_data) as target, rasterio.open(baseline_data) as base: - # nrt = np.log1p(target.read(1, masked=True)) - # baseline = np.log1p(base.read(1, masked=True)) - # ssim = pure_numpy_ssim(nrt, baseline, win_size=7) - # anomaly_mask = (ssim < 0.4) & (baseline.filled(0.0) > 1.5) & (~np.ma.getmaskarray(nrt)) - # clusters = spatial_filter(anomaly_mask, min_size=5) - # data[target_data] = nrt - # data[baseline_data] = baseline - # data['ssim'] = ssim - # data['anom'] = clusters - # vis.display1(data=data, title=f'All relevant processing levels imagery for {bbox} on {nominal_date.date()}') + a3 = np.log1p(baseline_array) + data[f'{baseline_product}_{timestamp}'] = a3 + + + downloaded_data_files = await fetch(bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, progress=progress, + dst_dir=dst_dir) + + + if 'NOAA' in deliverable: # {timestamp:(product, file_path, size)} + pass + else: # NASA, {product:(timestamp:(tiles)} + for product, results in downloaded_data_files.items(): + level = product[-2:] + if 'NRT' in deliverable: + level = product.split('_')[0][-2:] + for timestamp, local_image_files in results.items(): + + ntl_array = extract_bb(image_files=local_image_files,bbox=bbox,sds_name=nasa_const.SUB_DATASETS[level], + progress=progress) + ntl_array = ntl_array.astype(np.float32) + if level.lower() == 'a2': + a2 = np.log1p(ntl_array) + data[f'{product}_{timestamp}'] = a2 + if level.lower() == 'a1': + a1 = np.log1p(ntl_array) + + data[f'{product}_{timestamp}'] = a1 + qf_array = extract_bb(image_files=local_image_files, sds_name='QF_Cloud_Mask', bbox=bbox, + progress=progress).astype('u2') + # land water mask 0 = Land & Desert 1 = Land no Desert 2 = Inland Water 3 = Sea Water 5 = Coastal + land_water_bg = (qf_array >> 1) & 0b111 + # Create a mask for all land pixels + m = np.zeros_like(a1).astype(bool) + + if land_water_bg[land_water_bg==3].size > 0: + m = land_water_bg > 1 + if cmask is True: + # Cloud mask + # Shift right by 6 to bring bits 6 and 7 to the start, + # then bitwise AND with 3 (0b11) to isolate just those two bits. + + # 0=Confident Clear 1=Probably Clear 2=Probably Cloudy 3=Confident Cloudy + cloud_confidence = (qf_array >> 6) & 0b11 + # Create a mask for confident clouds + is_cloudy = cloud_confidence == 3 + m |= is_cloudy + + data[f'mask_{timestamp}'] = m + + a1_diff, a1_outage = calculate_regional_outage_simplified(baseline_log_array=a3,nrt_log_array=a1,cloud_mask=m) + a2_diff, a2_outage = calculate_regional_outage_simplified(baseline_log_array=a3,nrt_log_array=a2,cloud_mask=m) + data['a1_diff'] = a1_diff + data['a1_outage'] = a1_outage + data['a2_diff'] = a2_diff + data['a2_outage'] = a2_outage + if display: + from rapida.ntl import vis + vis.display1(data=data, title=f'Outage inputs and results for {bbox} on {nominal_date.date()}') + mword = 'cloud_masked' if cmask else 'landmasked' + outage_tif_path = os.path.join(dst_dir, f'{deliverable.lower()}_{nominal_date:%Y%m%d}_{mword}.tif') + write_outage_tif(src_arrays=data, gt=gt,dst_path=outage_tif_path) \ No newline at end of file diff --git a/rapida/ntl/nasa/search.py b/rapida/ntl/nasa/search.py index 1821f777..a492284c 100644 --- a/rapida/ntl/nasa/search.py +++ b/rapida/ntl/nasa/search.py @@ -1,14 +1,13 @@ -import json import os.path from rapida.ntl import cache from rapida.ntl.nasa import const -from rapida.ntl.nasa.util import timestamp_format +from rapida.ntl.util import timestamp_format import math -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta import logging from pystac_client import Client from rich.progress import Progress -from rapida.ntl.nasa.util import get_intersecting_tiles +from rapida.ntl.util import get_intersecting_tiles import httpx from typing import Optional from rapida.util.http_get_json import http_get_json @@ -59,7 +58,7 @@ def url2result(url:str, store=True): -def calculate_local_utc(stream:str, processing_level:str, nominal_date: datetime, bbox:tuple[float, float, float, float], +def calculate_local_utc_old(stream:str, processing_level:str, nominal_date: datetime, bbox:tuple[float, float, float, float], route:str=None, products:list[str] =None): """ Calculate VIIRS satellites local overpass time in UTC TZ @@ -119,9 +118,144 @@ def calculate_local_utc(stream:str, processing_level:str, nominal_date: datetime dt = nominal_date.replace(month=month, day=1) else: raise ValueError(f'Invalid stream {stream} for NASA NTL data') + return dt +def stac_catalog_has_data(stream: str, processing_level: str, dt: datetime, + bbox: tuple[float, float, float, float], products: list[str] = None) -> bool: + """ + Pure 1:1 clone of stac_search logic. Hardened to expose local import errors. + """ + catalog_name = const.STREAM2CATALOG[stream] + catalog_collections = const.COLLECTIONS[catalog_name] + + if not products: + available_collections = sorted(catalog_collections[processing_level.upper()], reverse=True) + else: + available_collections = list(products) + + stac_url = f'{const.CMR_STAC_ROOT}{catalog_name}' + + try: + # If Client is not imported, this will instantly throw a NameError + catalog = Client.open(url=stac_url) + + search_result = catalog.search( + collections=available_collections, + datetime=dt, # Exact match, identical to your stac_search + bbox=bbox + ) + + if search_result.matched(): + expected_tiles = get_intersecting_tiles(bbox=bbox) + items = search_result.item_collection() + + for itm in items: + for asset_key, asset in itm.assets.items(): + if asset.href.endswith('.h5') and asset.href.startswith(('https', 'http')): + _, file_name = os.path.split(asset.href) + match = const.NTL_FILENAME_PATTERN.match(file_name) + if match: + meta = match.groupdict() + if meta['tile'] in expected_tiles: + return True + + return False + + except Exception as e: + # ELEVATED TO ERROR: This will print the exact Python crash to your console + logger.error(f"CRITICAL STAC PROBE FAILURE for {dt.strftime('%Y-%m')}: {e}", exc_info=True) + return False + + +def calculate_local_utc(stream: str, processing_level: str, nominal_date: datetime, + bbox: tuple[float, float, float, float], + route: str = None, products: list[str] = None): + """ + Calculate VIIRS satellites local overpass time in UTC TZ + """ + minlon, minlat, maxlon, maxlat = bbox + now = datetime.now() + plevel = processing_level.upper() + + if stream == const.OPERATIONAL: + days_difference = abs((now - nominal_date).days) + if days_difference > 7: + raise ValueError(f'Invalid target_date={nominal_date}. {stream} stream holds max 7 days of data.') + + if 'A1' in plevel or 'A2' in plevel: + midlon = (minlon + maxlon) * 0.5 + utc_offset_hours = midlon / 15.0 + local_overpass_utc = nominal_date + timedelta(hours=1.5) - timedelta(hours=utc_offset_hours) + dt = local_overpass_utc + + elif 'A3' in plevel: + months_back = 3 + day = 15 if route == 'STAC' else 1 + current_probe_date = nominal_date + + if now.year == current_probe_date.year and now.month == current_probe_date.month: + current_probe_date = current_probe_date.replace(day=1) - timedelta(days=1) + + found_valid_dt = False + + for attempt in range(months_back + 1): + dt = current_probe_date.replace(day=day) + exists = True + + if route == 'API': + for product in products: + content_url = f'{const.API_CONTENT[stream]}/{product}/{dt.strftime("%Y/%j")}' + try: + content = http_get_json(url=content_url, timeout=10.0) + if not content or len(content) == 0: + exists = False + break + except Exception as e: + logger.debug(f"API Probe failed for {product} on {dt.strftime('%Y-%m')}: {e}") + exists = False + break + + elif route == 'STAC': + # Symmetrical STAC Probe using the dynamic URL & spatial filter helper + exists = stac_catalog_has_data( + stream=stream, + processing_level=plevel, + dt=dt, + bbox=bbox, + products=None # Passing None triggers the automatic const fallback just like stac_search + ) + + if exists: + found_valid_dt = True + break + else: + logger.debug(f"No {route} data found for {current_probe_date.strftime('%Y-%m')}. Stepping back.") + current_probe_date = current_probe_date.replace(day=1) - timedelta(days=1) + + if not found_valid_dt: + logger.warning( + f"Catastrophic latency: Could not find published A3 data via {route} within {months_back} months of {nominal_date.strftime('%Y-%m')}." + ) + dt = nominal_date.replace(day=day) + + elif 'A4' in plevel: + if nominal_date > now: + raise ValueError('Cannot search in the future! Please adjust target date.') + + month = 7 if route == 'STAC' else 1 + + if now.year == nominal_date.year: + dt = nominal_date.replace(year=nominal_date.year - 1, month=month, day=1) + else: + dt = nominal_date.replace(month=month, day=1) + + else: + raise ValueError(f'Invalid stream {stream} for NASA NTL data') + + return dt + def api_search(stream:str, products:str, dt:datetime, bbox:tuple[float, float, float, float], push_to_cache:bool=True)-> list[str]: tiles = get_intersecting_tiles(bbox=bbox) urls = [] @@ -183,14 +317,21 @@ def stac_search(stream:str=None, processing_level:Optional[str]=None, products:l if search_result.matched(): logger.info(f"Found {search_result.matched()} granule(s) at {stac_url}") + expected_tiles = get_intersecting_tiles(bbox=bbox) items = search_result.item_collection() for itm in items: #print(json.dumps(itm.to_dict(), indent=4)) for asset_key, asset in itm.assets.items(): # Look for the .h5 file, but specifically grab the HTTPS link - if asset.href.endswith('.h5') and asset.href.startswith('https'): + if asset.href.endswith('.h5') and asset.href.startswith(('https', 'http')): url = asset.href + _, file_name = os.path.split(url) + match = const.NTL_FILENAME_PATTERN.match(file_name) + meta = match.groupdict() + tile = meta['tile'] + if not tile in expected_tiles: + continue result = url2result(url=url, store=push_to_cache) urls.append(result) diff --git a/rapida/ntl/noaa/io.py b/rapida/ntl/noaa/io.py index 848520aa..4c602f1b 100644 --- a/rapida/ntl/noaa/io.py +++ b/rapida/ntl/noaa/io.py @@ -188,11 +188,11 @@ async def locate_file(satellite:str=None, dt=None, source:str=None, products: It async def fetch_file(satellite:str=None, provider:str=None, path:str=None, size:int=None, dst_dir:str=None, progress=None, progress_task = None): + down_task = None try: adir = os.path.abspath(dst_dir) - if not os.path.exists(adir): - os.mkdir(adir) - down_task = None + os.makedirs(adir, exist_ok=True) + store = VIIRS_STORES[satellite][provider] rel_path, file_name = os.path.split(path) product = rel_path.split('/')[0] @@ -216,8 +216,8 @@ async def fetch_file(satellite:str=None, provider:str=None, path:str=None, size: raise finally: - if progress: - if down_task:progress.remove_task(down_task) + if progress and down_task is not None: + progress.remove_task(down_task) async def fetch_ntl(found_paths:dict[str, list]=None, satellite:str=None, dst_dir:str=None, progress=None): diff --git a/rapida/ntl/nasa/util.py b/rapida/ntl/util.py similarity index 63% rename from rapida/ntl/nasa/util.py rename to rapida/ntl/util.py index 3209d9d0..d4108d8d 100644 --- a/rapida/ntl/nasa/util.py +++ b/rapida/ntl/util.py @@ -1,4 +1,10 @@ import math + +import numpy as np +import rasterio +from rasterio.transform import Affine +from rasterio.enums import ColorInterp + def get_intersecting_tiles(bbox: tuple[float, float, float, float]) -> list[tuple[int, int]]: """ Identifies VIIRS Sinusoidal tiles (h, v) intersecting a geographic bounding box. @@ -35,3 +41,21 @@ def timestamp_format(product_id: str) -> str: for identifier, time_format in TIMESTAMP_FORMATS.items(): if identifier in product_id: return time_format + + +def write_outage_tif(src_arrays:dict[str, np.array]=None, gt:list = None, dst_path:str=None ) -> bool: + transform = Affine.from_gdal(*gt) + label, ar = next(iter(src_arrays.items())) + height, width = ar.shape + with rasterio.open(dst_path, mode='w',driver='GTiff',height=height,width=width, + count=len(src_arrays), + dtype='float32', + crs='EPSG:4326', + transform=transform) as dst: + dst.update_tags(INTERLEAVE='PIXEL') + dst.colorinterp = [ColorInterp.undefined] * len(src_arrays) + + for i, e in enumerate(src_arrays.items(), start=1): + label, array = e + dst.write(array.astype('float32'), i) + dst.set_band_description(i, label) \ No newline at end of file From e513ff970099b6c29914d097e093327d9485719b Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Sun, 7 Jun 2026 12:40:26 +0200 Subject: [PATCH 11/20] refactor ntl utils --- rapida/ntl/nasa/outage.py | 133 +-------------------------- rapida/ntl/util.py | 61 ------------- rapida/ntl/utils.py | 187 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 191 deletions(-) delete mode 100644 rapida/ntl/util.py create mode 100644 rapida/ntl/utils.py diff --git a/rapida/ntl/nasa/outage.py b/rapida/ntl/nasa/outage.py index 66d63689..294407e9 100644 --- a/rapida/ntl/nasa/outage.py +++ b/rapida/ntl/nasa/outage.py @@ -1,20 +1,16 @@ -import secrets -from calendar import month + from datetime import datetime, timedelta import numbers import logging from rich.progress import Progress -from satpy.resample.base import resample from rapida.ntl.util import write_outage_tif from rapida.ntl.fetch import fetch -from rapida.ntl.nasa.io import extract, extract_bb +from rapida.ntl.nasa.io import extract_bb from rapida.ntl.nasa import const as nasa_const -from scipy.ndimage import uniform_filter, gaussian_filter import numpy as np import os -import asyncio from scipy.ndimage import label -from pathlib import Path +from rapida.ntl.util import calculate_regional_outage_simplified @@ -37,129 +33,6 @@ def spatial_filter(outage_map, min_size=2): return mask_size[labeled_array] -import numpy as np -from scipy.ndimage import gaussian_filter - - -def calculate_regional_outage_simplified( - baseline_log_array: np.ndarray, - nrt_log_array: np.ndarray, - cloud_mask: np.ndarray, # Boolean array: True where clouds exist - sigma: float = 1.25, - log_drop_threshold: float = -0.69 # approx 50% drop in linear space -) -> np.ndarray: - """ - Calculates outages using direct log-differencing, which mathematically - represents proportional radiance loss. - """ - # 1. Strict Exclusion (No Data is better than Bad Data) - is_valid = ~np.isnan(nrt_log_array) & ~np.isnan(baseline_log_array) & ~cloud_mask - - # 2. Prevent edge-bleed during smoothing (using your fill method) - fill_val = np.nanmean(baseline_log_array[is_valid]) - base_filled = np.where(is_valid, baseline_log_array, fill_val) - nrt_filled = np.where(is_valid, nrt_log_array, fill_val) - - # 3. Apply Gaussian Filter (Handles spatial shifts/jitter) - base_smooth = gaussian_filter(base_filled, sigma=sigma, truncate=3.0) - nrt_smooth = gaussian_filter(nrt_filled, sigma=sigma, truncate=3.0) - - # 4. Direct Log Difference (This IS the ratio!) - # E.g., if NRT is half the Baseline, log1p(NRT) - log1p(Base) is roughly -0.69 - log_diff = nrt_smooth - base_smooth - - # 5. Mask out invalid areas from the final result - log_diff = np.where(is_valid, log_diff, np.nan) - - # 6. Define the Outage Boolean Map - # We only care about areas that dropped by more than our threshold - outage_map = log_diff < log_drop_threshold - - # Optional: You can return log_diff for continuous analysis (severity), - # or outage_map for binary polygons. - return log_diff, outage_map - -def rigorous_ssim(img1, img2, data_range=12.0) -> np.ndarray: - """ - Computes standard Wang et al. (2004) SSIM with safety checks for - MaskedArrays and floating-point catastrophic cancellation. - """ - # 1. Safely extract masked data and force 64-bit precision - img1 = img1.filled(0.0) if hasattr(img1, 'filled') else img1 - img2 = img2.filled(0.0) if hasattr(img2, 'filled') else img2 - - img1 = img1.astype(np.float64) - img2 = img2.astype(np.float64) - - # 2. Lock the stability constants globally - K1, K2 = 0.01, 0.03 - C1 = (K1 * data_range) ** 2 - C2 = (K2 * data_range) ** 2 - - sigma = 1.5 - trunc = 3.5 - - # 3. Compute local means - mu1 = gaussian_filter(img1, sigma=sigma, truncate=trunc) - mu2 = gaussian_filter(img2, sigma=sigma, truncate=trunc) - - mu1_sq = mu1 ** 2 - mu2_sq = mu2 ** 2 - mu1_mu2 = mu1 * mu2 - - # 4. Compute local variances & CLAMP to 0 to prevent float precision collapse - sigma1_sq = gaussian_filter(img1 ** 2, sigma=sigma, truncate=trunc) - mu1_sq - sigma2_sq = gaussian_filter(img2 ** 2, sigma=sigma, truncate=trunc) - mu2_sq - sigma12 = gaussian_filter(img1 * img2, sigma=sigma, truncate=trunc) - mu1_mu2 - - sigma1_sq = np.maximum(0, sigma1_sq) - sigma2_sq = np.maximum(0, sigma2_sq) - - # 5. Compute full SSIM map - num = (2 * mu1_mu2 + C1) * (2 * sigma12 + C2) - den = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2) - ssim_map = num / den - - return ssim_map - -def pure_numpy_ssim(nrt_masked: np.ma.MaskedArray, baseline_masked: np.ma.MaskedArray, win_size: int = 7) -> np.ndarray: - """ - Computes the Structural Similarity Index (SSIM) between two images - using purely NumPy and SciPy uniform filters. - - Returns a 2D NumPy array matching the input dimensions (values from -1 to 1). - """ - # 1. Fill masked regions with 0.0 so they don't break the rolling windows - img1 = nrt_masked.filled(0.0) - img2 = baseline_masked.filled(0.0) - - # 2. Dynamic range stability constants (C1, C2) - # Based on log1p values, data max is roughly 9.0 to 11.0 - data_range = max(img1.max(), img2.max()) - min(img1.min(), img2.min()) - K1, K2 = 0.01, 0.03 - C1 = (K1 * data_range) ** 2 - C2 = (K2 * data_range) ** 2 - - # 3. Compute local means (mu) using a uniform box filter - mu1 = uniform_filter(img1, size=win_size, mode='reflect') - mu2 = uniform_filter(img2, size=win_size, mode='reflect') - - mu1_sq = mu1 ** 2 - mu2_sq = mu2 ** 2 - mu1_mu2 = mu1 * mu2 - - # 4. Compute local variances (sigma_sq) and covariance (sigma_12) - # Formula: Var(X) = E[X^2] - (E[X])^2 - sigma1_sq = uniform_filter(img1 ** 2, size=win_size) - mu1_sq - sigma2_sq = uniform_filter(img2 ** 2, size=win_size) - mu2_sq - sigma12 = uniform_filter(img1 * img2, size=win_size) - mu1_mu2 - - # 5. Compute full SSIM map - num = (2 * mu1_mu2 + C1) * (2 * sigma12 + C2) - den = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2) - ssim_map = num / den - - return ssim_map diff --git a/rapida/ntl/util.py b/rapida/ntl/util.py deleted file mode 100644 index d4108d8d..00000000 --- a/rapida/ntl/util.py +++ /dev/null @@ -1,61 +0,0 @@ -import math - -import numpy as np -import rasterio -from rasterio.transform import Affine -from rasterio.enums import ColorInterp - -def get_intersecting_tiles(bbox: tuple[float, float, float, float]) -> list[tuple[int, int]]: - """ - Identifies VIIRS Sinusoidal tiles (h, v) intersecting a geographic bounding box. - bbox format: (min_lon, min_lat, max_lon, max_lat) - :return tuple of ints representing pairs of tile coordinates (horizontal, vertical) - """ - min_lon, min_lat, max_lon, max_lat = bbox - - # VIIRS standard sinusoidal grid is approx 10x10 degrees at the equator - # h runs 0 to 35 (180W to 180E) - # v runs 0 to 17 (90N to 90S) - h_min = math.floor((min_lon + 180) / 10) - h_max = math.floor((max_lon + 180) / 10) - v_min = math.floor((90 - max_lat) / 10) - v_max = math.floor((90 - min_lat) / 10) - - tiles = [] - for v in range(max(0, v_min), min(18, v_max + 1)): - for h in range(max(0, h_min), min(36, h_max + 1)): - tiles.append(f'h{h:02d}v{v:02d}') - - return tiles - -TIMESTAMP_FORMATS = { - "A1": "%Y%m%d", # Daily: Year + Julian Day (e.g., 2026134) - "A2": "%Y%m%d", # Daily: Year + Julian Day (e.g., 2026134) - "A3": "%Y%m", # Monthly: Year + Month (e.g., 202605) - "A4": "%Y" # Yearly: Year only (e.g., 2026) -} - -def timestamp_format(product_id: str) -> str: - """Determine the correct temporal format string based on product name.""" - # Check if A1, A2, A3, or A4 is in the product string (e.g., 'VNP46A1') - for identifier, time_format in TIMESTAMP_FORMATS.items(): - if identifier in product_id: - return time_format - - -def write_outage_tif(src_arrays:dict[str, np.array]=None, gt:list = None, dst_path:str=None ) -> bool: - transform = Affine.from_gdal(*gt) - label, ar = next(iter(src_arrays.items())) - height, width = ar.shape - with rasterio.open(dst_path, mode='w',driver='GTiff',height=height,width=width, - count=len(src_arrays), - dtype='float32', - crs='EPSG:4326', - transform=transform) as dst: - dst.update_tags(INTERLEAVE='PIXEL') - dst.colorinterp = [ColorInterp.undefined] * len(src_arrays) - - for i, e in enumerate(src_arrays.items(), start=1): - label, array = e - dst.write(array.astype('float32'), i) - dst.set_band_description(i, label) \ No newline at end of file diff --git a/rapida/ntl/utils.py b/rapida/ntl/utils.py new file mode 100644 index 00000000..db4185d6 --- /dev/null +++ b/rapida/ntl/utils.py @@ -0,0 +1,187 @@ +import math +import numpy as np +import rasterio +from rasterio.transform import Affine +from rasterio.enums import ColorInterp +from scipy.ndimage import uniform_filter, gaussian_filter +import logging + + +logger = logging.getLogger('rapida') + + + +def calculate_regional_outage_simplified( + baseline_log_array: np.ndarray, + nrt_log_array: np.ndarray, + cloud_mask: np.ndarray, # Boolean array: True where clouds exist + sigma: float = 1.25, + log_drop_threshold: float = -0.69 # approx 50% drop in linear space +) -> np.ndarray: + """ + Calculates outages using direct log-differencing, which mathematically + represents proportional radiance loss. + """ + # 1. Strict Exclusion (No Data is better than Bad Data) + is_valid = ~np.isnan(nrt_log_array) & ~np.isnan(baseline_log_array) & ~cloud_mask + + # 2. Prevent edge-bleed during smoothing (using your fill method) + fill_val = np.nanmean(baseline_log_array[is_valid]) + base_filled = np.where(is_valid, baseline_log_array, fill_val) + nrt_filled = np.where(is_valid, nrt_log_array, fill_val) + + # 3. Apply Gaussian Filter (Handles spatial shifts/jitter) + base_smooth = gaussian_filter(base_filled, sigma=sigma, truncate=3.0) + nrt_smooth = gaussian_filter(nrt_filled, sigma=sigma, truncate=3.0) + + # 4. Direct Log Difference (This IS the ratio!) + # E.g., if NRT is half the Baseline, log1p(NRT) - log1p(Base) is roughly -0.69 + log_diff = nrt_smooth - base_smooth + + # 5. Mask out invalid areas from the final result + log_diff = np.where(is_valid, log_diff, np.nan) + + # 6. Define the Outage Boolean Map + # We only care about areas that dropped by more than our threshold + outage_map = log_diff < log_drop_threshold + + # Optional: You can return log_diff for continuous analysis (severity), + # or outage_map for binary polygons. + return log_diff, outage_map + +def rigorous_ssim(img1, img2, data_range=12.0) -> np.ndarray: + """ + Computes standard Wang et al. (2004) SSIM with safety checks for + MaskedArrays and floating-point catastrophic cancellation. + """ + # 1. Safely extract masked data and force 64-bit precision + img1 = img1.filled(0.0) if hasattr(img1, 'filled') else img1 + img2 = img2.filled(0.0) if hasattr(img2, 'filled') else img2 + + img1 = img1.astype(np.float64) + img2 = img2.astype(np.float64) + + # 2. Lock the stability constants globally + K1, K2 = 0.01, 0.03 + C1 = (K1 * data_range) ** 2 + C2 = (K2 * data_range) ** 2 + + sigma = 1.5 + trunc = 3.5 + + # 3. Compute local means + mu1 = gaussian_filter(img1, sigma=sigma, truncate=trunc) + mu2 = gaussian_filter(img2, sigma=sigma, truncate=trunc) + + mu1_sq = mu1 ** 2 + mu2_sq = mu2 ** 2 + mu1_mu2 = mu1 * mu2 + + # 4. Compute local variances & CLAMP to 0 to prevent float precision collapse + sigma1_sq = gaussian_filter(img1 ** 2, sigma=sigma, truncate=trunc) - mu1_sq + sigma2_sq = gaussian_filter(img2 ** 2, sigma=sigma, truncate=trunc) - mu2_sq + sigma12 = gaussian_filter(img1 * img2, sigma=sigma, truncate=trunc) - mu1_mu2 + + sigma1_sq = np.maximum(0, sigma1_sq) + sigma2_sq = np.maximum(0, sigma2_sq) + + # 5. Compute full SSIM map + num = (2 * mu1_mu2 + C1) * (2 * sigma12 + C2) + den = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2) + ssim_map = num / den + + return ssim_map + +def pure_numpy_ssim(nrt_masked: np.ma.MaskedArray, baseline_masked: np.ma.MaskedArray, win_size: int = 7) -> np.ndarray: + """ + Computes the Structural Similarity Index (SSIM) between two images + using purely NumPy and SciPy uniform filters. + + Returns a 2D NumPy array matching the input dimensions (values from -1 to 1). + """ + # 1. Fill masked regions with 0.0 so they don't break the rolling windows + img1 = nrt_masked.filled(0.0) + img2 = baseline_masked.filled(0.0) + + # 2. Dynamic range stability constants (C1, C2) + # Based on log1p values, data max is roughly 9.0 to 11.0 + data_range = max(img1.max(), img2.max()) - min(img1.min(), img2.min()) + K1, K2 = 0.01, 0.03 + C1 = (K1 * data_range) ** 2 + C2 = (K2 * data_range) ** 2 + + # 3. Compute local means (mu) using a uniform box filter + mu1 = uniform_filter(img1, size=win_size, mode='reflect') + mu2 = uniform_filter(img2, size=win_size, mode='reflect') + + mu1_sq = mu1 ** 2 + mu2_sq = mu2 ** 2 + mu1_mu2 = mu1 * mu2 + + # 4. Compute local variances (sigma_sq) and covariance (sigma_12) + # Formula: Var(X) = E[X^2] - (E[X])^2 + sigma1_sq = uniform_filter(img1 ** 2, size=win_size) - mu1_sq + sigma2_sq = uniform_filter(img2 ** 2, size=win_size) - mu2_sq + sigma12 = uniform_filter(img1 * img2, size=win_size) - mu1_mu2 + + # 5. Compute full SSIM map + num = (2 * mu1_mu2 + C1) * (2 * sigma12 + C2) + den = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2) + ssim_map = num / den + + return ssim_map + +def get_intersecting_tiles(bbox: tuple[float, float, float, float]) -> list[tuple[int, int]]: + """ + Identifies VIIRS Sinusoidal tiles (h, v) intersecting a geographic bounding box. + bbox format: (min_lon, min_lat, max_lon, max_lat) + :return tuple of ints representing pairs of tile coordinates (horizontal, vertical) + """ + min_lon, min_lat, max_lon, max_lat = bbox + + # VIIRS standard sinusoidal grid is approx 10x10 degrees at the equator + # h runs 0 to 35 (180W to 180E) + # v runs 0 to 17 (90N to 90S) + h_min = math.floor((min_lon + 180) / 10) + h_max = math.floor((max_lon + 180) / 10) + v_min = math.floor((90 - max_lat) / 10) + v_max = math.floor((90 - min_lat) / 10) + + tiles = [] + for v in range(max(0, v_min), min(18, v_max + 1)): + for h in range(max(0, h_min), min(36, h_max + 1)): + tiles.append(f'h{h:02d}v{v:02d}') + + return tiles + +TIMESTAMP_FORMATS = { + "A1": "%Y%m%d", # Daily: Year + Julian Day (e.g., 2026134) + "A2": "%Y%m%d", # Daily: Year + Julian Day (e.g., 2026134) + "A3": "%Y%m", # Monthly: Year + Month (e.g., 202605) + "A4": "%Y" # Yearly: Year only (e.g., 2026) +} + +def timestamp_format(product_id: str) -> str: + """Determine the correct temporal format string based on product name.""" + # Check if A1, A2, A3, or A4 is in the product string (e.g., 'VNP46A1') + for identifier, time_format in TIMESTAMP_FORMATS.items(): + if identifier in product_id: + return time_format + + +def write_outage_tif(src_arrays:dict[str, np.array]=None, gt:list = None, dst_path:str=None ) -> bool: + transform = Affine.from_gdal(*gt) + label, ar = next(iter(src_arrays.items())) + height, width = ar.shape + with rasterio.open(dst_path, mode='w',driver='GTiff',height=height,width=width, + count=len(src_arrays), + dtype='float32', + crs='EPSG:4326', + transform=transform) as dst: + dst.update_tags(INTERLEAVE='PIXEL') + dst.colorinterp = [ColorInterp.undefined] * len(src_arrays) + + for i, e in enumerate(src_arrays.items(), start=1): + label, array = e + dst.write(array.astype('float32'), i) + dst.set_band_description(i, label) \ No newline at end of file From 2902869668b98e67f034ea2a89e4120cdc47e2c7 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Sun, 7 Jun 2026 12:42:11 +0200 Subject: [PATCH 12/20] refactor ntl outage --- rapida/cli/ntl.py | 5 ++--- rapida/ntl/{nasa => }/outage.py | 21 ++------------------- rapida/ntl/utils.py | 22 ++++++++++++++++++++-- 3 files changed, 24 insertions(+), 24 deletions(-) rename rapida/ntl/{nasa => }/outage.py (87%) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index 2fa443bf..c9ac7c49 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -8,8 +8,7 @@ from rapida.cli import RapidaCommandGroup -from rapida.ntl.nasa.const import ARCHIVE, OPERATIONAL, PROCESSING_LEVEL_NAMES, PRODUCT_NAMES, PRODUCTS, \ - NTL_FILENAME_PATTERN, ROUTES, COLLECTIONS +from rapida.ntl.nasa.const import ARCHIVE, OPERATIONAL, PROCESSING_LEVEL_NAMES, PRODUCT_NAMES, NTL_FILENAME_PATTERN, ROUTES, COLLECTIONS 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 @@ -18,7 +17,7 @@ from rapida.ntl.noaa.io import download as download_from_noaa, bytesto from rich.table import Table from rapida.ntl.fetch import DELIVERABLES, fetch as fetch_ntl -from rapida.ntl.nasa.outage import detect_outage +from rapida.ntl.outage import detect_outage logger = logging.getLogger(__name__) diff --git a/rapida/ntl/nasa/outage.py b/rapida/ntl/outage.py similarity index 87% rename from rapida/ntl/nasa/outage.py rename to rapida/ntl/outage.py index 294407e9..57581621 100644 --- a/rapida/ntl/nasa/outage.py +++ b/rapida/ntl/outage.py @@ -3,14 +3,13 @@ import numbers import logging from rich.progress import Progress -from rapida.ntl.util import write_outage_tif +from rapida.ntl.utils import write_outage_tif from rapida.ntl.fetch import fetch from rapida.ntl.nasa.io import extract_bb from rapida.ntl.nasa import const as nasa_const import numpy as np import os -from scipy.ndimage import label -from rapida.ntl.util import calculate_regional_outage_simplified +from rapida.ntl.utils import calculate_regional_outage_simplified @@ -18,22 +17,6 @@ -def spatial_filter(outage_map, min_size=2): - # 1. Group connected pixels into "clumps" - labeled_array, num_features = label(outage_map) - - # 2. Count how many pixels are in each clump - clump_sizes = np.bincount(labeled_array.ravel()) - - # 3. Create a mask of clumps that meet your size requirement - mask_size = clump_sizes >= min_size - - # 4. Filter the original map (clump 0 is the background, so we ignore it) - mask_size[0] = 0 - return mask_size[labeled_array] - - - async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, diff --git a/rapida/ntl/utils.py b/rapida/ntl/utils.py index db4185d6..675b2565 100644 --- a/rapida/ntl/utils.py +++ b/rapida/ntl/utils.py @@ -5,7 +5,7 @@ from rasterio.enums import ColorInterp from scipy.ndimage import uniform_filter, gaussian_filter import logging - +from scipy.ndimage import label logger = logging.getLogger('rapida') @@ -184,4 +184,22 @@ def write_outage_tif(src_arrays:dict[str, np.array]=None, gt:list = None, dst_pa for i, e in enumerate(src_arrays.items(), start=1): label, array = e dst.write(array.astype('float32'), i) - dst.set_band_description(i, label) \ No newline at end of file + dst.set_band_description(i, label) + + + +def spatial_filter(outage_map, min_size=2): + # 1. Group connected pixels into "clumps" + labeled_array, num_features = label(outage_map) + + # 2. Count how many pixels are in each clump + clump_sizes = np.bincount(labeled_array.ravel()) + + # 3. Create a mask of clumps that meet your size requirement + mask_size = clump_sizes >= min_size + + # 4. Filter the original map (clump 0 is the background, so we ignore it) + mask_size[0] = 0 + return mask_size[labeled_array] + + From e5665194cde5ccd9299cff672008025d1cde8e9c Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Mon, 8 Jun 2026 12:48:44 +0200 Subject: [PATCH 13/20] finish ntl nasa outage --- rapida/cli/ntl.py | 12 +- rapida/ntl/fetch.py | 7 +- rapida/ntl/nasa/io.py | 2 +- rapida/ntl/nasa/search.py | 4 +- rapida/ntl/noaa/cmask.py | 2 +- rapida/ntl/noaa/const.py | 16 +- rapida/ntl/noaa/io.py | 135 ++++++++++++++- rapida/ntl/outage.py | 93 +++++++---- rapida/ntl/utils.py | 315 ++++++++++++++++++++++++++++++++++- rapida/ntl/vis.py | 80 +++++++++ rapida/util/http_get_json.py | 4 +- 11 files changed, 608 insertions(+), 62 deletions(-) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index c9ac7c49..c2d49879 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -186,7 +186,7 @@ async def search_noaa(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetim ) @click.pass_context -def search_nasa(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, stream:str = None, processing_level:str=None, route:str=None): +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): progress = ctx.obj.get('progress') @@ -497,6 +497,12 @@ async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None ) +@click.option('-ot', "--outage-drop-threshold", + type=int, + default=80, + required=False, + help="Specify the outage drop threshold that wil determine the spatial structure of an outage event, " +) @click.option( '--cmask', '-cm', "cmask", @@ -520,11 +526,11 @@ async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None @click.pass_context async def detect(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - cmask:bool=True, dst_dir:str=None, display:bool=False): + cmask:bool=True, dst_dir:str=None, outage_drop_threshold:int=None, display:bool=False): progress = ctx.obj.get('progress') return await detect_outage( bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, dst_dir=dst_dir, - progress=progress, cmask=cmask, display=display + progress=progress, cmask=cmask, outage_drop_threshold=outage_drop_threshold, display=display ) diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py index c231e309..e3bcb7f7 100644 --- a/rapida/ntl/fetch.py +++ b/rapida/ntl/fetch.py @@ -4,7 +4,7 @@ import logging from rich.progress import Progress from rapida.components.ntl.variables import generate_variables -from rapida.ntl.util import get_intersecting_tiles +from rapida.ntl.utils import get_intersecting_tiles from rapida.ntl.nasa.search import search from rapida.ntl.nasa import const as nasaconst from rapida.ntl.noaa.search import async_search_granules @@ -18,13 +18,16 @@ async def download_and_track(granule, dest_dir, prog_bar): # Run the actual download - result_dict = await download_from_noaa( + results = await download_from_noaa( satellite=granule.sat, timestamp=granule.timestamp, dst_dir=dest_dir, progress=prog_bar ) + result_dict = {} # Return both the timestamp AND the result + for e in results: + result_dict[e[0]] = e[1] return granule.timestamp, result_dict async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, diff --git a/rapida/ntl/nasa/io.py b/rapida/ntl/nasa/io.py index 161ff9ca..a3a17a54 100644 --- a/rapida/ntl/nasa/io.py +++ b/rapida/ntl/nasa/io.py @@ -12,7 +12,7 @@ import logging from urllib.parse import urlparse from rapida.ntl.nasa.search import stac_search -from rapida.ntl.util import get_intersecting_tiles +from rapida.ntl.utils import get_intersecting_tiles from datetime import datetime import numbers from rapida.ntl.nasa import const as nasaconst diff --git a/rapida/ntl/nasa/search.py b/rapida/ntl/nasa/search.py index a492284c..5ea3d6fe 100644 --- a/rapida/ntl/nasa/search.py +++ b/rapida/ntl/nasa/search.py @@ -1,13 +1,13 @@ import os.path from rapida.ntl import cache from rapida.ntl.nasa import const -from rapida.ntl.util import timestamp_format +from rapida.ntl.utils import timestamp_format import math from datetime import datetime, timedelta import logging from pystac_client import Client from rich.progress import Progress -from rapida.ntl.util import get_intersecting_tiles +from rapida.ntl.utils import get_intersecting_tiles import httpx from typing import Optional from rapida.util.http_get_json import http_get_json diff --git a/rapida/ntl/noaa/cmask.py b/rapida/ntl/noaa/cmask.py index 3074da80..d14f5bdf 100644 --- a/rapida/ntl/noaa/cmask.py +++ b/rapida/ntl/noaa/cmask.py @@ -154,7 +154,7 @@ def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): working_bbox = bbox_poly #if not bbox_poly.within(bounds_poly): if not working_bbox.intersects(working_bounds): - return False + return False, 0 intersection_poly = working_bbox.intersection(working_bounds) perc_intersection = round(intersection_poly.area/working_bbox.area * 100) # with open(os.path.join(dst_dir, 'bbox.geojson'), "w") as ff: diff --git a/rapida/ntl/noaa/const.py b/rapida/ntl/noaa/const.py index 47f7d701..b5e4533a 100644 --- a/rapida/ntl/noaa/const.py +++ b/rapida/ntl/noaa/const.py @@ -1,15 +1,19 @@ import re +CM = 'CM' +GEO = 'GEO' +SDR = 'SDR' + PRODUCTS_RE = { - 'CM': re.compile(r'^(?P[\w-]+)_(?Pv\d+r\d+)_(?P\w+)_s(?P\d+)_e(?P\d+)_c(?P\d+)\.(?P\w+)$'), - 'GEO': re.compile(r'^(?P[^_]+)_(?P[^_]+)_d(?P\d{8})_t(?P\d+)_e(?P\d+)_b(?P\d+)_c(?P\d+)_(?P[^_]+)_(?P[^_]+)\.(?P\w+)$'), - 'SDR': re.compile(r'^(?P[^_]+)_(?P[^_]+)_d(?P\d{8})_t(?P\d+)_e(?P\d+)_b(?P\d+)_c(?P\d+)_(?P[^_]+)_(?P[^_]+)\.(?P\w+)$') + CM: re.compile(r'^(?P[\w-]+)_(?Pv\d+r\d+)_(?P\w+)_s(?P\d+)_e(?P\d+)_c(?P\d+)\.(?P\w+)$'), + GEO: re.compile(r'^(?P[^_]+)_(?P[^_]+)_d(?P\d{8})_t(?P\d+)_e(?P\d+)_b(?P\d+)_c(?P\d+)_(?P[^_]+)_(?P[^_]+)\.(?P\w+)$'), + SDR: re.compile(r'^(?P[^_]+)_(?P[^_]+)_d(?P\d{8})_t(?P\d+)_e(?P\d+)_b(?P\d+)_c(?P\d+)_(?P[^_]+)_(?P[^_]+)\.(?P\w+)$') } PRODUCTS={ - 'SDR':"VIIRS-DNB-SDR", - 'GEO':"VIIRS-DNB-GEO", - 'CM':"VIIRS-JRR-CloudMask" + SDR:"VIIRS-DNB-SDR", + GEO:"VIIRS-DNB-GEO", + CM:"VIIRS-JRR-CloudMask" } PRODUCT2NAME = dict((v, k) for k, v in PRODUCTS.items()) diff --git a/rapida/ntl/noaa/io.py b/rapida/ntl/noaa/io.py index 4c602f1b..11f07bac 100644 --- a/rapida/ntl/noaa/io.py +++ b/rapida/ntl/noaa/io.py @@ -10,7 +10,9 @@ from urllib.parse import urlparse from rapida.ntl.noaa.const import PRODUCT2NAME import aiofiles - +from pyresample import geometry +from satpy import Scene +import numpy as np logger = logging.getLogger(__name__) @@ -242,7 +244,6 @@ async def fetch_ntl(found_paths:dict[str, list]=None, satellite:str=None, dst_di finally: results = [t.result() for t in tasks] - return results @@ -260,4 +261,132 @@ async def download(satellite:str=None, timestamp:str=None, source:str=None, def bytesto(bytes, to, bsize=1024): a = {'k' : 1, 'm': 2, 'g' : 3, 't' : 4, 'p' : 5, 'e' : 6 } r = float(bytes) - return bytes / (bsize ** a[to]) \ No newline at end of file + return bytes / (bsize ** a[to]) + + + + + +def create_area_from_geotransform(gt, array_shape): + """ + Creates a Pyresample AreaDefinition exactly matching a GDAL array. + + Args: + gt (tuple): GDAL GeoTransform (TopLeftX, PixelWidth, Rot, TopLeftY, Rot, PixelHeight) + array_shape (tuple): (height, width) of the target array + """ + height, width = array_shape + + # Calculate exact bounding box edges based on pixels + ll_x = gt[0] + ur_y = gt[3] + ur_x = gt[0] + (width * gt[1]) + ll_y = gt[3] + (height * gt[5]) # gt[5] is usually negative + + area_extent = [ll_x, ll_y, ur_x, ur_y] + + # Build the EPSG:4326 definition + area_def = geometry.AreaDefinition( + area_id='gdal_matched_grid', + description='Grid mapped directly from Level-3 Baseline GeoTransform', + proj_id='EPSG:4326', + projection={'proj': 'longlat', 'datum': 'WGS84'}, + width=width, + height=height, + area_extent=area_extent + ) + + return area_def + + + + + +def read_and_align_sdr(sdr_path, geo_path, target_area): + # 1. Calculate a slightly buffered crop box for Satpy + lon_min, lat_min, lon_max, lat_max = target_area.area_extent_ll + pad = 0.2 # Add a 0.2 degree buffer so EWA has data for edge pixels + crop_bbox = (lon_min - pad, lat_min - pad, lon_max + pad, lat_max + pad) + + # 2. Load the scene + scn = Scene(filenames=[sdr_path, geo_path], reader='viirs_sdr') + scn.load(['DNB']) + + # 3. Crop in memory BEFORE resampling (Massive speed/RAM boost) + cropped_scn = scn.crop(ll_bbox=crop_bbox) + + # 4. Resample directly to the baseline's grid footprint + resampled_scn = cropped_scn.resample( + target_area, + resampler='ewa', + rows_per_scan=16, + fill_value=-999.0 # Explicitly manage fill values + ) + + dnb_array = resampled_scn['DNB'].values + dnb_array = np.where(dnb_array < -900, np.nan, dnb_array) + + return dnb_array * 1e5 # Scale to match Black Marble nW/cm2/sr + + + + +def read_and_align_sdr_and_cmask(sdr_path, geo_path, cmask_path, target_area): + """ + Loads, crops, and resamples VIIRS SDR (Radiance) and EDR (Cloud Mask) + using the native fill values extracted dynamically from the file metadata. + """ + # 1. Calculate a slightly buffered crop box for Satpy + lon_min, lat_min, lon_max, lat_max = target_area.area_extent_ll + pad = 0.2 + crop_bbox = (lon_min - pad, lat_min - pad, lon_max + pad, lat_max + pad) + + # 2. Load the Scenes + sdr_scn = Scene(filenames=[sdr_path, geo_path], reader='viirs_sdr') + sdr_scn.load(['DNB']) + + cmask_scn = Scene(filenames=[cmask_path], reader='viirs_edr') + cmask_scn.load(['CloudMask']) + + + # --------------------------------------------------------- + # 4. DYNAMIC FILL VALUE EXTRACTION + # Pull the exact _FillValue from the file attributes. + # We provide a safe fallback just in case the attribute is missing. + # --------------------------------------------------------- + dnb_fill = sdr_scn['DNB'].attrs.get('_FillValue', np.nan) + + # EDR categorical data usually defaults to 255 or -127 for unsigned/signed bytes + cmask_fill = cmask_scn['CloudMask'].attrs.get('_FillValue', 255) + + # 5. Resample SDR using the native fill value + resampled_sdr = sdr_scn.resample( + target_area, + resampler='ewa', + rows_per_scan=16, + fill_value=dnb_fill + ) + + # 6. Resample Cloud Mask + resampled_cmask = cmask_scn.resample( + target_area, + resampler='nearest', + radius_of_influence=1000, + fill_value=cmask_fill + ) + + # 7. Extract raw NumPy arrays and mask + dnb_array = resampled_sdr['DNB'].values + + # Safely convert the dynamic fill value to np.nan for math operations + if np.isnan(dnb_fill): + # Data might already be masked as NaN by Satpy during load + dnb_array = np.where(np.isnan(dnb_array), np.nan, dnb_array) + else: + # Mask the explicit file-defined integer/float fill value + dnb_array = np.where(dnb_array == dnb_fill, np.nan, dnb_array) + + dnb_scaled = dnb_array * 1e5 # Scale to match Black Marble nW/cm2/sr + cmask_array = resampled_cmask['CloudMask'].values + + return dnb_scaled, cmask_array \ No newline at end of file diff --git a/rapida/ntl/outage.py b/rapida/ntl/outage.py index 57581621..47140d20 100644 --- a/rapida/ntl/outage.py +++ b/rapida/ntl/outage.py @@ -6,12 +6,12 @@ from rapida.ntl.utils import write_outage_tif from rapida.ntl.fetch import fetch from rapida.ntl.nasa.io import extract_bb +from rapida.ntl.noaa.io import create_area_from_geotransform, read_and_align_sdr_and_cmask from rapida.ntl.nasa import const as nasa_const +from rapida.ntl.noaa import const as noaa_const import numpy as np import os -from rapida.ntl.utils import calculate_regional_outage_simplified - - +from rapida.ntl import utils logger = logging.getLogger('rapida') @@ -20,21 +20,31 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - dst_dir:str=None, cmask:bool=True, display:bool=False, progress:Progress=None): + dst_dir:str=None, cmask:bool=True, display:bool=False, outage_drop_threshold:int=None, progress:Progress=None): data = {} logger.info(f'Fetching best imagery for {deliverable} {bbox}-{nominal_date} ') baseline_nominal_date = nominal_date - timedelta(weeks=6) downloaded_baseline_files = await fetch(bbox=bbox, nominal_date=baseline_nominal_date, progress=progress, deliverable='baseline', dst_dir=dst_dir) baseline_product, baseline_files = next(iter(downloaded_baseline_files.items())) - timestamp, baseline_image_files = next(iter(baseline_files.items())) + baseline_timestamp, baseline_image_files = next(iter(baseline_files.items())) baseline_array, gt = extract_bb(image_files=baseline_image_files, sds_name=nasa_const.SUB_DATASETS['A3'], bbox=bbox, progress=progress, return_gt=True) - a3 = np.log1p(baseline_array) - data[f'{baseline_product}_{timestamp}'] = a3 + data[f'{baseline_product}_{baseline_timestamp}'] = a3 + # baseline_std = extract_bb(image_files=baseline_image_files, sds_name='AllAngle_Composite_Snow_Free_Std', bbox=bbox, + # progress=progress) + # baseline_num = extract_bb(image_files=baseline_image_files, sds_name='AllAngle_Composite_Snow_Free_Num', bbox=bbox, + # progress=progress) + lw_bg = extract_bb(image_files=baseline_image_files, sds_name='Land_Water_Mask', bbox=bbox, + progress=progress) + # land water mask 0 = Land & Desert 1 = Land no Desert 2 = Inland Water 3 = Sea Water 5 = Coastal + # Create a mask for all land pixels including the coastal + m = (lw_bg == 2) & (lw_bg == 3) + + data['mask'] = m downloaded_data_files = await fetch(bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, progress=progress, @@ -42,7 +52,15 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N if 'NOAA' in deliverable: # {timestamp:(product, file_path, size)} - pass + target_area = create_area_from_geotransform(gt, baseline_array.shape) + for timestamp, entry in downloaded_data_files.items(): + a1, cm = read_and_align_sdr_and_cmask(sdr_path=entry[noaa_const.SDR],geo_path=entry[noaa_const.GEO], + cmask_path=entry[noaa_const.CM], target_area=target_area) + + print(a1.shape, a3.shape, cm.shape) + + + else: # NASA, {product:(timestamp:(tiles)} for product, results in downloaded_data_files.items(): level = product[-2:] @@ -58,39 +76,42 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N data[f'{product}_{timestamp}'] = a2 if level.lower() == 'a1': a1 = np.log1p(ntl_array) - data[f'{product}_{timestamp}'] = a1 qf_array = extract_bb(image_files=local_image_files, sds_name='QF_Cloud_Mask', bbox=bbox, progress=progress).astype('u2') - # land water mask 0 = Land & Desert 1 = Land no Desert 2 = Inland Water 3 = Sea Water 5 = Coastal - land_water_bg = (qf_array >> 1) & 0b111 - # Create a mask for all land pixels - m = np.zeros_like(a1).astype(bool) - - if land_water_bg[land_water_bg==3].size > 0: - m = land_water_bg > 1 + # Cloud mask + # Shift right by 6 to bring bits 6 and 7 to the start, + # then bitwise AND with 3 (0b11) to isolate just those two bits. + + # 0=Confident Clear 1=Probably Clear 2=Probably Cloudy 3=Confident Cloudy + cloud_confidence = (qf_array >> 6) & 0b11 + #clear = cloud_confidence == 0 + #cm = cloud_confidence >= 2 if cmask is True: - # Cloud mask - # Shift right by 6 to bring bits 6 and 7 to the start, - # then bitwise AND with 3 (0b11) to isolate just those two bits. - - # 0=Confident Clear 1=Probably Clear 2=Probably Cloudy 3=Confident Cloudy - cloud_confidence = (qf_array >> 6) & 0b11 # Create a mask for confident clouds is_cloudy = cloud_confidence == 3 m |= is_cloudy - data[f'mask_{timestamp}'] = m - - a1_diff, a1_outage = calculate_regional_outage_simplified(baseline_log_array=a3,nrt_log_array=a1,cloud_mask=m) - a2_diff, a2_outage = calculate_regional_outage_simplified(baseline_log_array=a3,nrt_log_array=a2,cloud_mask=m) - data['a1_diff'] = a1_diff - data['a1_outage'] = a1_outage - data['a2_diff'] = a2_diff - data['a2_outage'] = a2_outage - if display: - from rapida.ntl import vis - vis.display1(data=data, title=f'Outage inputs and results for {bbox} on {nominal_date.date()}') - mword = 'cloud_masked' if cmask else 'landmasked' - outage_tif_path = os.path.join(dst_dir, f'{deliverable.lower()}_{nominal_date:%Y%m%d}_{mword}.tif') - write_outage_tif(src_arrays=data, gt=gt,dst_path=outage_tif_path) \ No newline at end of file + + + + a1_diff, a1_outage = utils.logdiff_outage(baseline_log_array=a3, nrt_log_array=a1, cloud_mask=m,percentage_drop=outage_drop_threshold) + a2_diff, a2_outage = utils.logdiff_outage(baseline_log_array=a3, nrt_log_array=a2, cloud_mask=m,percentage_drop=outage_drop_threshold) + # log_diff,outage_mask, grid_health, confidence = utils.nasa_outage(ntl_log=a1, ntl_baseline_log=a3, + # baseline_std=baseline_std, + # baseline_num=baseline_num, + # no_cloud=clear, + # mask=m) + + data['a2_diff'] = a2_diff + data['a1_diff'] = a1_diff + data['a2_outage'] = a2_outage + data['a1_outage'] = a1_outage + + if display: + from rapida.ntl import vis + vis.display2(data=data, title=f'Outage inputs and results for {bbox} on {nominal_date.date()}') + + file_name = utils.get_custom_bbox_label(bbox) + outage_tif_path = os.path.join(dst_dir, f'{deliverable}_{file_name}.tif') + write_outage_tif(src_arrays=data, gt=gt,dst_path=outage_tif_path) \ No newline at end of file diff --git a/rapida/ntl/utils.py b/rapida/ntl/utils.py index 675b2565..94ed2ca6 100644 --- a/rapida/ntl/utils.py +++ b/rapida/ntl/utils.py @@ -6,22 +6,320 @@ from scipy.ndimage import uniform_filter, gaussian_filter import logging from scipy.ndimage import label - +from scipy.ndimage import convolve +from rapida.util.http_get_json import http_get_json logger = logging.getLogger('rapida') +def get_custom_bbox_label(bbox: tuple[float, float, float, float]) -> str: + minlon, minlat, maxlon, maxlat = bbox + + lon = (minlon + maxlon) * .5 + lat = (minlat + maxlat) * .5 + + # 1. Calculate the longest span of the bbox in degrees + max_span = max(maxlon - minlon, maxlat - minlat) + + # 2. Map the size to a Nominatim zoom level (Adjusted for real-world sizes) + if max_span > 15.0: + zoom = 3 # Country level (Massive areas like the whole USA or Europe) + elif max_span > 1.0: + zoom = 5 # State/Region level (Catches Puerto Rico's 2.6 span perfectly) + elif max_span > 0.2: + zoom = 8 # County/District level (Large metro areas) + else: + zoom = 12 # City/Town/Village level (Small localized bboxes) + + # 3. Add the zoom parameter to the URL + url = f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&zoom={zoom}&format=json" + + headers = {'User-Agent': 'UNDP/RAPIDA'} + + try: + data = http_get_json(url=url, timeout=30, headers=headers) + address = data.get('address', {}) + + country_iso2 = address.get('country_code', 'XX').upper() + admin1 = address.get('state', address.get('state_district')) + admin2 = address.get('county', address.get('district')) + admin3 = address.get('city', address.get('town', address.get('village'))) + + # 4. Clean up the string formatting to avoid "US_-" + names = [] + for e in [admin1, admin2, admin3]: + if e not in ['', None]: + names.append(e) + + # Join the admins with dashes, then attach the country code + admin_str = "-".join(names) + if admin_str: + return f"{country_iso2}_{admin_str}" + else: + return country_iso2 + + except Exception as e: + return f"Error_{e}" + + +def disk_filter(image: np.ndarray, radius: int = 1) -> np.ndarray: + """ + Applies a uniform circular (pillbox) blur. + A radius of 1 creates a 3x3 cross. A radius of 2 creates a 5x5 circle. + """ + # 1. Generate a circular mask using an orthogonal grid + y, x = np.ogrid[-radius:radius + 1, -radius:radius + 1] + mask = x ** 2 + y ** 2 <= radius ** 2 + + # 2. Create the kernel and normalize it so it sums to 1.0 + kernel = mask.astype(np.float64) + kernel /= kernel.sum() + + # 3. Convolve the image with the circular kernel + return convolve(image, kernel, mode='reflect') + + +def noaa_outage( + nrt_array: np.ndarray, + baseline_array: np.ndarray, + baseline_std: np.ndarray, + cmask: np.ndarray, + lwm: np.ndarray, + baseline_is_log: bool = True, + relative_noise_floor: float = 0.15 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Detects outages using pixel-specific Z-scores and global atmospheric shift. + + Args: + nrt_array: Near Real-Time VIIRS array (linear scale). + baseline_array: Level-3 baseline array. + baseline_std: Level-3 baseline standard deviation array. + cmask: Resampled Cloud Mask (0=Clear, 1=Probably Clear). + lwm: Resampled Land Water Mask (0, 1, 5 = Land). + baseline_is_log: True if baseline_array is already np.log1p(). + relative_noise_floor: Minimum standard deviation limit. + + Returns: + final_outages (np.ndarray): Boolean mask of confirmed outages. + z_map_display (np.ndarray): Continuous array of Z-scores for visualization. + grid_health (np.ndarray): Categorical array (0=Healthy, 1=Dimming, 2=Outage). + """ + # 1. Align Data Spaces + a3_log = baseline_array if baseline_is_log else np.log1p(baseline_array) + nrt_log = np.log1p(nrt_array) + + # 2. Master Validity Mask + is_land = np.isin(lwm, [0, 1, 5]) + is_clear = (cmask <= 1) + has_signal = a3_log > np.log1p(0.5) + + master_mask = is_land & is_clear & has_signal & ~np.isnan(a3_log) & ~np.isnan(nrt_log) + + if not np.any(master_mask): + logger.warning("No valid clear land pixels found in the target area.") + return np.zeros_like(nrt_array, dtype=bool), np.full_like(nrt_array, np.nan), np.zeros_like(nrt_array, + dtype=np.uint8) + + # 3. Atmospheric Normalization + log_diff = nrt_log - a3_log + atmos_shift = np.nanmedian(log_diff[master_mask]) + + log_pred = a3_log + atmos_shift + log_res = nrt_log - log_pred + + # 4. Pixel-Specific Z-Score + a3_linear = np.expm1(a3_log) + log_std = np.maximum(baseline_std / (a3_linear + 1.0), relative_noise_floor) + z_map_log = log_res / log_std + + # 5. Multi-Tiered Classification Logic + is_dimming = (log_res < -0.3) | (z_map_log < -2.0) + is_outage = (log_res < -0.7) | (z_map_log < -3.0) + + # 6. Apply the Mask to Build the Heatmap + grid_health = np.zeros_like(log_res, dtype=np.uint8) + grid_health[is_dimming & master_mask] = 1 + grid_health[is_outage & master_mask] = 2 + + # 7. Spatial Cleanup + final_outages = spatial_filter((grid_health == 2), min_size=2) + + # 8. Clean visualization map + z_map_display = np.where(master_mask, z_map_log, np.nan) + + return final_outages, z_map_display, grid_health + + +def nasa_outage_old( + ntl_log: np.ndarray, + ntl_baseline_log: np.ndarray, + mask: np.ndarray, # Your 'm' mask: True = Cloud/Water (Ignore) + dimming_threshold: float = -0.69, # ~ 25 percent + outage_threshold: float = -1 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Calculates outages using spatial smoothing and direct log-differencing. + + Args: + ntl_log: Near Real-Time VIIRS array (linear scale). + ntl_baseline_log: Level-3 baseline array. + mask: Boolean array where True means the pixel should be ignored. + baseline_is_log: True if baseline_array is already np.log1p(). + sigma: Gaussian blur radius to handle registration jitter. + dimming_threshold: Log-ratio threshold for dimming events. default e**-0.3= 0.74; then 1 - .74 = .26 26% loss of light + outage_threshold: Log-ratio threshold for severe outages. default = e**-0.69 = -.5 ; 1-.5 = .5 50% loss of light + + Returns: + final_outages (np.ndarray): Boolean mask of confirmed outages. + diff_display (np.ndarray): Continuous array of log-differences for visualization. + grid_health (np.ndarray): Categorical array (0=Healthy, 1=Dimming, 2=Outage). + """ + # 1. Align Data Spaces + a3_log = ntl_baseline_log + nrt_log = ntl_log + + # 2. Master Validity Mask + # We invert your invalid_mask (~invalid_mask) so True = Good Pixel + has_signal = a3_log > np.log1p(0.5) + is_valid = ~mask & has_signal & ~np.isnan(a3_log) & ~np.isnan(nrt_log) + + if not np.any(is_valid): + logger.warning("No valid pixels found after applying the invalid mask.") + return np.zeros_like(ntl_log, dtype=bool), np.full_like(ntl_log, np.nan), np.zeros_like(ntl_log, dtype=np.uint8) + + # 3. Prevent edge-bleed during smoothing + fill_val = np.nanmean(a3_log[is_valid]) + base_filled = np.where(is_valid, a3_log, fill_val) + nrt_filled = np.where(is_valid, nrt_log, fill_val) + + # 4. Apply Gaussian Filter (Handles spatial shifts/jitter) + # base_smooth = gaussian_filter(base_filled, sigma=sigma, truncate=3.0) + # nrt_smooth = gaussian_filter(nrt_filled, sigma=sigma, truncate=3.0) + + # 4. Apply Disk Filter (The "Smooth but not Squary" method) + # radius=1 gives a tight circle (5 pixels). radius=2 gives a wider circle (13 pixels). + base_smooth = disk_filter(base_filled, radius=1) + nrt_smooth = disk_filter(nrt_filled, radius=1) + + # 5. Direct Log Difference + log_diff = nrt_smooth - base_smooth + + is_dimming = (log_diff < dimming_threshold) + is_outage = (log_diff < outage_threshold) -def calculate_regional_outage_simplified( + # 7. Apply the Mask to Build the Heatmap + grid_health = np.zeros_like(log_diff, dtype=np.uint8) + grid_health[is_dimming & is_valid] = 1 + grid_health[is_outage & is_valid] = 2 + + # Since Gaussian blur inherently mitigates spatial jitter, + # we pull the boolean mask directly from the grid_health array. + final_outages = (grid_health == 2) + + # 8. Clean visualization map + diff_display = np.where(is_valid, log_diff, np.nan) + + return diff_display, final_outages, grid_health + + + + + +def nasa_outage( + ntl_log: np.ndarray, + ntl_baseline_log: np.ndarray, + baseline_std: np.ndarray, # <-- NEW: A3 _Std band required here + baseline_num: np.ndarray, # <-- NEW: A3 _Num band required here + no_cloud:np.ndarray, + mask: np.ndarray, + dimm_perc_drop = 50, + outage_perc_drop = 80 +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Calculates outages using spatial smoothing and variance-scaled thresholding. + """ + a3_log = ntl_baseline_log + nrt_log = ntl_log + + # 1. Gate + #is_valid = ~mask & ~np.isnan(a3_log) & ~np.isnan(nrt_log)# & (a3_log>0) + is_valid = ~np.isnan(nrt_log) & ~np.isnan(a3_log) & ~mask + if not np.any(is_valid): + logger.warning("No valid pixels found.") + return np.full_like(ntl_log, np.nan), np.zeros_like(ntl_log, dtype=bool), np.zeros_like(ntl_log, + dtype=np.uint8), np.zeros_like( + ntl_log, dtype=np.float32) + + # 2. Prevent edge-bleed during smoothing + fill_val = np.nanmean(a3_log[is_valid]) + base_filled = np.where(is_valid, a3_log, fill_val) + nrt_filled = np.where(is_valid, nrt_log, fill_val) + + # 3. Spatial Smoothing + base_smooth = disk_filter(base_filled, radius=1) + nrt_smooth = disk_filter(nrt_filled, radius=1) + + # 4. Direct Log Difference + log_diff = np.zeros_like(ntl_log, dtype=np.float32) + log_diff[is_valid] = nrt_smooth[is_valid] - base_smooth[is_valid] + + + log_outage_threshold = np.log(1.0 - (outage_perc_drop / 100.0)) + log_dimm_threshold = np.log(1.0 - (dimm_perc_drop / 100.0)) + + # 6. Apply Dynamic Classification + # We are now comparing the log_diff array against the dynamic threshold arrays + grid_health = np.zeros_like(ntl_log, dtype=np.uint8) + grid_health[is_valid & (log_diff < log_dimm_threshold)] = 1 + grid_health[is_valid & (log_diff < log_outage_threshold)] = 2 + + final_outages = (grid_health == 2) + + # --- PHYSICAL CONFIDENCE SCORE --- + + # 7. Compute Confidence adapted for dynamic thresholds + confidence = np.zeros_like(log_diff, dtype=np.float32) + + outage_pixels = (grid_health == 2) & is_valid + + # 1. Evidence: Signal-to-Noise Ratio (Is the drop larger than local noise?) + # We use a 3-sigma rule (SNR=3.0 is 100% certainty) + std_safe = np.where(baseline_std < 0.05, 0.05, baseline_std) # Floor to avoid div by zero + snr = np.abs(log_diff[outage_pixels]) / std_safe[outage_pixels] + conf_snr = np.clip(snr / 3.0, 0.0, 1.0) + + # 2. Evidence: Baseline Robustness (Do we have enough history?) + # Assume 'a3_num' is the count of clear days from your VNP46A3 product. + # 15 days is high confidence, 1 day is low. + conf_history = np.clip(baseline_num[outage_pixels] / 15.0, 0.0, 1.0) + + # 3. Evidence: Observability (Was the NTL pixel actually observed clearly?) + # 1.0 if Clear, 0.0 if Cloud-Contaminated/Shadow + conf_obs = np.where(no_cloud[outage_pixels] == 0, 1.0, 0.0) + + # FINAL CONFIDENCE: The product of all evidences + # If any evidence is weak, the total confidence drops. + confidence[outage_pixels] = conf_snr * conf_history * conf_obs + + # 8. Clean visualization map + diff_display = np.where(is_valid, log_diff, np.nan) + + return diff_display, final_outages, grid_health, confidence + +def logdiff_outage( baseline_log_array: np.ndarray, nrt_log_array: np.ndarray, cloud_mask: np.ndarray, # Boolean array: True where clouds exist sigma: float = 1.25, - log_drop_threshold: float = -0.69 # approx 50% drop in linear space + percentage_drop: float = 50 ) -> np.ndarray: """ Calculates outages using direct log-differencing, which mathematically represents proportional radiance loss. """ + remaining_fraction = 1.0 - (percentage_drop / 100.0) + log_drop_threshold = np.log(remaining_fraction) + # 1. Strict Exclusion (No Data is better than Bad Data) is_valid = ~np.isnan(nrt_log_array) & ~np.isnan(baseline_log_array) & ~cloud_mask @@ -31,13 +329,16 @@ def calculate_regional_outage_simplified( nrt_filled = np.where(is_valid, nrt_log_array, fill_val) # 3. Apply Gaussian Filter (Handles spatial shifts/jitter) - base_smooth = gaussian_filter(base_filled, sigma=sigma, truncate=3.0) - nrt_smooth = gaussian_filter(nrt_filled, sigma=sigma, truncate=3.0) + # base_smooth = gaussian_filter(base_filled, sigma=sigma, truncate=3.0) + # nrt_smooth = gaussian_filter(nrt_filled, sigma=sigma, truncate=3.0) + + # 3. Spatial Smoothing + base_smooth = disk_filter(base_filled, radius=1) + nrt_smooth = disk_filter(nrt_filled, radius=1) # 4. Direct Log Difference (This IS the ratio!) # E.g., if NRT is half the Baseline, log1p(NRT) - log1p(Base) is roughly -0.69 log_diff = nrt_smooth - base_smooth - # 5. Mask out invalid areas from the final result log_diff = np.where(is_valid, log_diff, np.nan) @@ -49,6 +350,8 @@ def calculate_regional_outage_simplified( # or outage_map for binary polygons. return log_diff, outage_map + + def rigorous_ssim(img1, img2, data_range=12.0) -> np.ndarray: """ Computes standard Wang et al. (2004) SSIM with safety checks for diff --git a/rapida/ntl/vis.py b/rapida/ntl/vis.py index c1d87934..e40e70be 100644 --- a/rapida/ntl/vis.py +++ b/rapida/ntl/vis.py @@ -1,5 +1,6 @@ from matplotlib import pyplot as plt from matplotlib import gridspec +from matplotlib.colors import ListedColormap, BoundaryNorm from mpl_toolkits.axes_grid1 import make_axes_locatable import numpy as np @@ -110,4 +111,83 @@ def plot(array): plt.colorbar(img, label='') plt.title("Night Lights - Zero Drama Edition") + plt.show() + + +def display2(data=dict(), interpolation='nearest', title='', max_discrete_vals=5): + """ + Improved display function that maximizes screen real estate, + ensures perfectly aligned subplots, and automatically detects + discrete classification maps to build custom legends. + """ + n = len(data) + if n == 0: return + + # 1. Calculate a better figure size + ncols = 2 + nrows = int(np.ceil(n / ncols)) + + fig, axes = plt.subplots(nrows=nrows, ncols=ncols, + figsize=(16, 5 * nrows), + constrained_layout=True, + squeeze=False) + + axes_flat = axes.flatten() + fax = None + + for i, (iname, a) in enumerate(data.items()): + ax = axes_flat[i] + + # Share axis logic to keep zoom synced + if i == 0: + fax = ax + else: + ax.sharex(fax) + ax.sharey(fax) + + ax.set_title(iname, fontsize=14, fontweight='bold') + ax.set_aspect('equal') + + # --- THE NEW DYNAMIC LOGIC --- + # Filter out NaNs to safely check unique values + valid_vals = a[~np.isnan(a)] + unique_vals = np.unique(valid_vals) + + # Check if this is a discrete/classification map (e.g., grid_health or masks) + if len(unique_vals) <= max_discrete_vals: + # It's discrete! Use specific colors and a custom legend. + # (Matches your Black/Yellow/Red layout for 3 values) + discrete_palette = ['black', 'yellow', 'red', 'cyan', 'magenta'] + colors = discrete_palette[:len(unique_vals)] + cmap = ListedColormap(colors) + + # BoundaryNorm ensures the colorbar is split perfectly by the number of classes + bounds = np.arange(len(unique_vals) + 1) - 0.5 + norm = BoundaryNorm(bounds, cmap.N) + + # Force interpolation='none' so classes don't blur at the edges + im = ax.imshow(a, interpolation='none', cmap=cmap, norm=norm) + + divider = make_axes_locatable(ax) + cax = divider.append_axes("right", size="5%", pad=0.1) + + # Build a discrete colorbar with centered ticks + cbar = plt.colorbar(im, cax=cax, ticks=np.arange(len(unique_vals))) + # Label the ticks with their actual array values + cbar.ax.set_yticklabels([f'Val: {v}' for v in unique_vals]) + + else: + # It's continuous! (e.g., raw radiance or log_diff) + cmap = 'viridis' if 'Mask' in iname else 'magma' + im = ax.imshow(a, interpolation=interpolation, cmap=cmap) + + divider = make_axes_locatable(ax) + cax = divider.append_axes("right", size="5%", pad=0.1) + plt.colorbar(im, cax=cax) + + # Hide any unused subplots + for j in range(i + 1, len(axes_flat)): + axes_flat[j].axis('off') + + fig.suptitle(title, fontsize=20) plt.show() \ No newline at end of file diff --git a/rapida/util/http_get_json.py b/rapida/util/http_get_json.py index 9099a7ef..33cff2a6 100644 --- a/rapida/util/http_get_json.py +++ b/rapida/util/http_get_json.py @@ -4,7 +4,7 @@ logger = logging.getLogger(__name__) -def http_get_json(url=None, timeout=None): +def http_get_json(url=None, timeout=None, **kwargs): """ Generic HTTP get function using httpx :param url: str, the url to be fetched @@ -12,7 +12,7 @@ def http_get_json(url=None, timeout=None): :return: python dict representing the result as parsed json """ assert timeout is not None, f'Invalid timeout={timeout}' - with httpx.Client(timeout=timeout) as client: + with httpx.Client(timeout=timeout, **kwargs) as client: response = client.get(url) response.raise_for_status() if response.status_code == 200: From 18fbd739586724e4b9e8ebd4cd71dab6c31154e6 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Mon, 8 Jun 2026 12:55:09 +0200 Subject: [PATCH 14/20] add missing deps --- pyproject.toml | 5 +- uv.lock | 956 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 810 insertions(+), 151 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6a358ce0..a554e2d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,6 @@ dependencies = [ "geopandas", "azure-identity", "rich", - #"morecantile", - #"mapbox_vector_tile", "sympy", "pydantic", "pmtiles", @@ -62,6 +60,9 @@ dependencies = [ "h5py>=3.16.0", "fsspec>=2026.4.0", "satpy>=0.59.0", + "matplotlib>=3.10.9", + "netcdf4>=1.7.3", + "h5netcdf>=1.8.1", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 58fa1c8d..ea30426a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,13 +2,16 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -17,14 +20,16 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] [[package]] @@ -239,8 +244,8 @@ name = "astunparse" version = "1.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, - { name = "wheel" }, + { name = "six", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "wheel", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/af/4182184d3c338792894f34a62672919db7ca008c89abee9b564dd34d8029/astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", size = 18290, upload-time = "2019-12-22T18:12:13.129Z" } wheels = [ @@ -263,7 +268,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, @@ -278,13 +284,16 @@ name = "asyncclick" version = "8.3.0.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -293,7 +302,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -466,6 +476,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cftime" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/dc/470ffebac2eb8c54151eb893055024fe81b1606e7c6ff8449a588e9cd17f/cftime-1.6.5.tar.gz", hash = "sha256:8225fed6b9b43fb87683ebab52130450fc1730011150d3092096a90e54d1e81e", size = 326605, upload-time = "2025-10-13T18:56:26.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/45/dcc38d7b293107d3e33b3d94b2619687eb414a4f16880e2e841cdb6ac49a/cftime-1.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ad81e8cb0eb873b33c3d1e22c6168163fdc64daa8f7aeb4da8092f272575f4d", size = 510221, upload-time = "2025-10-13T18:55:52.976Z" }, + { url = "https://files.pythonhosted.org/packages/68/63/2875341516fcfe80f1a16f86b420aec9441223ab5381d554441c9fdae56e/cftime-1.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12d95c6af852114a13301c5a61e41afdbd1542e72939c1083796f8418b9b8b0e", size = 490684, upload-time = "2025-10-13T18:55:54.685Z" }, + { url = "https://files.pythonhosted.org/packages/80/7f/85f2c4c7ae8300b7871af7d7d144ad06f71dc0dd6258f0d18fd966067d1b/cftime-1.6.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2659b7df700e27d9e3671f686ce474dfb5fc274966961edf996acc148dfa094a", size = 1592268, upload-time = "2025-10-13T19:39:10.992Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9a/72dbd72498e958edf41a770bbd05e68141774325a945092059f4eb9c653d/cftime-1.6.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:94cebdfcda6a985b8e69aed22d00d6b8aa1f421495adbdcff1d59b3e896d81e2", size = 1624716, upload-time = "2025-10-13T18:55:55.848Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/2c4c720ad8bbe87994ca62a0e3c09d3786b984af664a91a6f3a668aa0b13/cftime-1.6.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:179681b023349a2fe277ceccc89d4fc52c0dd105cb59b7187b5bc5d442875133", size = 1705927, upload-time = "2025-10-13T18:55:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/da/77/66484061dee5fbcb2fdcfa6a491d4efb880725117f4a339d20a5323105df/cftime-1.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:d8b9fdecb466879cfe8ca4472b229b6f8d0bb65e4ffd44266ae17484bac2cf38", size = 472435, upload-time = "2025-10-13T18:55:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f6/9da7aba9548ede62d25936b8b448acd7e53e5dcc710896f66863dcc9a318/cftime-1.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:474e728f5a387299418f8d7cb9c52248dcd5d977b2a01de7ec06bba572e26b02", size = 512733, upload-time = "2025-10-13T18:56:00.189Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d5/d86ad95fc1fd89947c34b495ff6487b6d361cf77500217423b4ebcb1f0c2/cftime-1.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9e80d4de815cac2e2d88a2335231254980e545d0196eb34ee8f7ed612645f1", size = 492946, upload-time = "2025-10-13T18:56:01.262Z" }, + { url = "https://files.pythonhosted.org/packages/4f/93/d7e8dd76b03a9d5be41a3b3185feffc7ea5359228bdffe7aa43ac772a75b/cftime-1.6.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad24a563784e4795cb3d04bd985895b5db49ace2cbb71fcf1321fd80141f9a52", size = 1689856, upload-time = "2025-10-13T19:39:12.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8d/86586c0d75110f774e46e2bd6d134e2d1cca1dedc9bb08c388fa3df76acd/cftime-1.6.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3cda6fd12c7fb25eff40a6a857a2bf4d03e8cc71f80485d8ddc65ccbd80f16a", size = 1718573, upload-time = "2025-10-13T18:56:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/7956914cfc135992e89098ebbc67d683c51ace5366ba4b114fef1de89b21/cftime-1.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:28cda78d685397ba23d06273b9c916c3938d8d9e6872a537e76b8408a321369b", size = 1788563, upload-time = "2025-10-13T18:56:04.075Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c7/6669708fcfe1bb7b2a7ce693b8cc67165eac00d3ac5a5e8f6ce1be551ff9/cftime-1.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:93ead088e3a216bdeb9368733a0ef89a7451dfc1d2de310c1c0366a56ad60dc8", size = 473631, upload-time = "2025-10-13T18:56:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/82/c5/d70cb1ab533ca790d7c9b69f98215fa4fead17f05547e928c8f2b8f96e54/cftime-1.6.5-cp311-cp311-win_arm64.whl", hash = "sha256:3384d69a0a7f3d45bded21a8cbcce66c8ba06c13498eac26c2de41b1b9b6e890", size = 459383, upload-time = "2026-01-02T21:16:47.317Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c1/e8cb7f78a3f87295450e7300ebaecf83076d96a99a76190593d4e1d2be40/cftime-1.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:eef25caed5ebd003a38719bd3ff8847cd52ef2ea56c3ebdb2c9345ba131fc7c5", size = 504175, upload-time = "2025-10-13T18:56:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/50/1a/86e1072b09b2f9049bb7378869f64b6747f96a4f3008142afed8955b52a4/cftime-1.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87d2f3b949e45463e559233c69e6a9cf691b2b378c1f7556166adfabbd1c6b0", size = 485980, upload-time = "2025-10-13T18:56:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/35/28/d3177b60da3f308b60dee2aef2eb69997acfab1e863f0bf0d2a418396ce5/cftime-1.6.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:82cb413973cc51b55642b3a1ca5b28db5b93a294edbef7dc049c074b478b4647", size = 1591166, upload-time = "2025-10-13T19:39:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fd/a7266970312df65e68b5641b86e0540a739182f5e9c62eec6dbd29f18055/cftime-1.6.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85ba8e7356d239cfe56ef7707ac30feaf67964642ac760a82e507ee3c5db4ac4", size = 1642614, upload-time = "2025-10-13T18:56:09.815Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/f0035a4bc2df8885bb7bd5fe63659686ea1ec7d0cc74b4e3d50e447402e5/cftime-1.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:456039af7907a3146689bb80bfd8edabd074c7f3b4eca61f91b9c2670addd7ad", size = 1688090, upload-time = "2025-10-13T18:56:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/8856a0ab76708553ff597dd2e617b088c734ba87dc3fd395e2b2f3efffe8/cftime-1.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:da84534c43699960dc980a9a765c33433c5de1a719a4916748c2d0e97a071e44", size = 464840, upload-time = "2025-10-13T18:56:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/3a/85/451009a986d9273d2208fc0898aa00262275b5773259bf3f942f6716a9e7/cftime-1.6.5-cp312-cp312-win_arm64.whl", hash = "sha256:c62cd8db9ea40131eea7d4523691c5d806d3265d31279e4a58574a42c28acd77", size = 450534, upload-time = "2026-01-02T21:16:48.784Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/74ea344b3b003fada346ed98a6899085d6fd4c777df608992d90c458fda6/cftime-1.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4aba66fd6497711a47c656f3a732c2d1755ad15f80e323c44a8716ebde39ddd5", size = 502453, upload-time = "2025-10-13T18:56:13.545Z" }, + { url = "https://files.pythonhosted.org/packages/1e/14/adb293ac6127079b49ff11c05cf3d5ce5c1f17d097f326dc02d74ddfcb6e/cftime-1.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89e7cba699242366e67d6fb5aee579440e791063f92a93853610c91647167c0d", size = 484541, upload-time = "2025-10-13T18:56:14.612Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/bb8a4566af8d0ef3f045d56c462a9115da4f04b07c7fbbf2b4875223eebd/cftime-1.6.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f1eb43d7a7b919ec99aee709fb62ef87ef1cf0679829ef93d37cc1c725781e9", size = 1591014, upload-time = "2025-10-13T19:39:15.346Z" }, + { url = "https://files.pythonhosted.org/packages/ba/08/52f06ff2f04d376f9cd2c211aefcf2b37f1978e43289341f362fc99f6a0e/cftime-1.6.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e02a1d80ffc33fe469c7db68aa24c4a87f01da0c0c621373e5edadc92964900b", size = 1633625, upload-time = "2025-10-13T18:56:15.745Z" }, + { url = "https://files.pythonhosted.org/packages/cf/33/03e0b23d58ea8fab94ecb4f7c5b721e844a0800c13694876149d98830a73/cftime-1.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18ab754805233cdd889614b2b3b86a642f6d51a57a1ec327c48053f3414f87d8", size = 1684269, upload-time = "2025-10-13T18:56:17.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/60/a0cfba63847b43599ef1cdbbf682e61894994c22b9a79fd9e1e8c7e9de41/cftime-1.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:6c27add8f907f4a4cd400e89438f2ea33e2eb5072541a157a4d013b7dbe93f9c", size = 465364, upload-time = "2025-10-13T18:56:18.05Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e8/ec32f2aef22c15604e6fda39ff8d581a00b5469349f8fba61640d5358d2c/cftime-1.6.5-cp313-cp313-win_arm64.whl", hash = "sha256:31d1ff8f6bbd4ca209099d24459ec16dea4fb4c9ab740fbb66dd057ccbd9b1b9", size = 450468, upload-time = "2026-01-02T21:16:50.193Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6c/a9618f589688358e279720f5c0fe67ef0077fba07334ce26895403ebc260/cftime-1.6.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c69ce3bdae6a322cbb44e9ebc20770d47748002fb9d68846a1e934f1bd5daf0b", size = 502725, upload-time = "2025-10-13T18:56:19.424Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e3/da3c36398bfb730b96248d006cabaceed87e401ff56edafb2a978293e228/cftime-1.6.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e62e9f2943e014c5ef583245bf2e878398af131c97e64f8cd47c1d7baef5c4e2", size = 485445, upload-time = "2025-10-13T18:56:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/32/93/b05939e5abd14bd1ab69538bbe374b4ee2a15467b189ff895e9a8cdaddf6/cftime-1.6.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da5fdaa4360d8cb89b71b8ded9314f2246aa34581e8105c94ad58d6102d9e4f", size = 1584434, upload-time = "2025-10-13T19:39:17.084Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/648397f9936e0b330999c4e776ebf296ec3c6a65f9901687dbca4ab820da/cftime-1.6.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bff865b4ea4304f2744a1ad2b8149b8328b321dd7a2b9746ef926d229bd7cd49", size = 1609812, upload-time = "2025-10-13T18:56:21.971Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0f/901b4835aa67ad3e915605d4e01d0af80a44b114eefab74ae33de6d36933/cftime-1.6.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e552c5d1c8a58f25af7521e49237db7ca52ed2953e974fe9f7c4491e95fdd36c", size = 1669768, upload-time = "2025-10-13T18:56:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/22/d5/e605e4b28363e7a9ae98ed12cabbda5b155b6009270e6a231d8f10182a17/cftime-1.6.5-cp314-cp314-win_amd64.whl", hash = "sha256:e645b095dc50a38ac454b7e7f0742f639e7d7f6b108ad329358544a6ff8c9ba2", size = 463818, upload-time = "2025-10-13T18:56:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/a8f85ae697ff10206ec401c2621f5ca9f327554f586d62f244739ceeb347/cftime-1.6.5-cp314-cp314-win_arm64.whl", hash = "sha256:b9044d7ac82d3d8af189df1032fdc871bbd3f3dd41a6ec79edceb5029b71e6e0", size = 459862, upload-time = "2026-01-02T20:45:02.625Z" }, + { url = "https://files.pythonhosted.org/packages/ab/05/7410e12fd03a0c52717e74e6a1b49958810807dda212e23b65d43ea99676/cftime-1.6.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ef56460cb0576e1a9161e1428c9e1a633f809a23fa9d598f313748c1ae5064e", size = 533781, upload-time = "2026-01-02T20:45:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/44/ba/10e3546426d3ed9f9cc82e4a99836bb6fac1642c7830f7bdd0ac1c3f0805/cftime-1.6.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f4873d38b10032f9f3111c547a1d485519ae64eee6a7a2d091f1f8b08e1ba50", size = 515218, upload-time = "2026-01-02T20:45:06.788Z" }, + { url = "https://files.pythonhosted.org/packages/bd/68/efa11eae867749e921bfec6a865afdba8166e96188112dde70bb8bb49254/cftime-1.6.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccce0f4c9d3f38dd948a117e578b50d0e0db11e2ca9435fb358fd524813e4b61", size = 1579932, upload-time = "2026-01-02T20:45:11.194Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6c/0971e602c1390a423e6621dfbad9f1d375186bdaf9c9c7f75e06f1fbf355/cftime-1.6.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19cbfc5152fb0b34ce03acf9668229af388d7baa63a78f936239cb011ccbe6b1", size = 1555894, upload-time = "2026-01-02T20:45:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fc/8475a15b7c3209a4a68b563dfc5e01ce74f2d8b9822372c3d30c68ab7f39/cftime-1.6.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4470cd5ef3c2514566f53efbcbb64dd924fa0584637d90285b2f983bd4ee7d97", size = 513027, upload-time = "2026-01-02T20:45:20.023Z" }, + { url = "https://files.pythonhosted.org/packages/f7/80/4ecbda8318fbf40ad4e005a4a93aebba69e81382e5b4c6086251cd5d0ee8/cftime-1.6.5-cp314-cp314t-win_arm64.whl", hash = "sha256:034c15a67144a0a5590ef150c99f844897618b148b87131ed34fda7072614662", size = 469065, upload-time = "2026-01-02T20:45:23.398Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -634,6 +695,187 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/c4/0679472c60052c27efa612b4cd3ddd2a23e885dcdc73461781d2c802d39e/configobj-5.0.9-py2.py3-none-any.whl", hash = "sha256:1ba10c5b6ee16229c79a05047aeda2b55eb4e80d7c7d8ecf17ec1ca600c79882", size = 35615, upload-time = "2024-11-26T14:03:32.972Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + [[package]] name = "country-converter" version = "1.3.2" @@ -707,6 +949,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "dask" version = "2026.3.0" @@ -872,6 +1123,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1070,7 +1378,7 @@ name = "google-pasta" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/4a/0bd53b36ff0323d10d5f24ebd67af2de10a1117f5cf4d7add90df92756f1/google-pasta-0.2.0.tar.gz", hash = "sha256:c9f2c8dfc8f96d0d5808299920721be30c9eec37f2389f28904f454565c8a16e", size = 40430, upload-time = "2020-03-13T18:57:50.34Z" } wheels = [ @@ -1148,7 +1456,7 @@ name = "grpcio" version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } wheels = [ @@ -1238,6 +1546,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/5f/ee7d49f219522a9235152cfd5968c9ca13cb7c15e9b827b39fb7640aed8a/h3-4.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7767f82d383f4e605b9e79690ddcfaf6264edbf9046396117fdd7ee74473c839", size = 898314, upload-time = "2026-01-29T19:22:41.423Z" }, ] +[[package]] +name = "h5netcdf" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/03/92d6cc02c0055158167255980461155d6e17f1c4143c03f8bcc18d3e3f3a/h5netcdf-1.8.1.tar.gz", hash = "sha256:9b396a4cc346050fc1a4df8523bc1853681ec3544e0449027ae397cb953c7a16", size = 78679, upload-time = "2026-01-23T07:35:31.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/8b/88f16936a8e8070a83d36239555227ecd91728f9ef222c5382cda07e0fd6/h5netcdf-1.8.1-py3-none-any.whl", hash = "sha256:a76ed7cfc9b8a8908ea7057c4e57e27307acff1049b7f5ed52db6c2247636879", size = 62915, upload-time = "2026-01-23T07:35:30.195Z" }, +] + [[package]] name = "h5py" version = "3.16.0" @@ -1397,17 +1718,17 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "absl-py", marker = "python_full_version < '3.11'" }, - { name = "h5py", marker = "python_full_version < '3.11'" }, - { name = "ml-dtypes", marker = "python_full_version < '3.11'" }, - { name = "namex", marker = "python_full_version < '3.11'" }, - { name = "numpy", marker = "python_full_version < '3.11'" }, - { name = "optree", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "rich", marker = "python_full_version < '3.11'" }, + { name = "absl-py", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "h5py", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "ml-dtypes", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "namex", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "numpy", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "optree", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "packaging", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "rich", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/73/19e057f7a2a6d641246bacca21e0bbcb2be341afca98ea461a0f2a9ab92d/keras-3.12.2.tar.gz", hash = "sha256:e19c7c7f8f2a81e44d4f203e567731a15a270d8ef351060982b45a1fafdf3fce", size = 1129833, upload-time = "2026-05-07T21:48:18.396Z" } wheels = [ @@ -1419,13 +1740,13 @@ name = "keras" version = "3.14.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -1434,27 +1755,151 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "absl-py", marker = "python_full_version >= '3.11'" }, - { name = "h5py", marker = "python_full_version >= '3.11'" }, - { name = "ml-dtypes", marker = "python_full_version >= '3.11'" }, - { name = "namex", marker = "python_full_version >= '3.11'" }, - { name = "numpy", marker = "python_full_version >= '3.11'" }, - { name = "optree", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "rich", marker = "python_full_version >= '3.11'" }, + { name = "absl-py", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "h5py", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "ml-dtypes", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "namex", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "numpy", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "optree", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "packaging", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "rich", marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/e7/97a7664581b73e4f9ff1d3a767a493b6ac5d3e0ed1926bd2b6b2c8bbccd7/keras-3.14.1.tar.gz", hash = "sha256:ef479173102ad29db89b53c232efdc3fb5ad57c28bc27ead59f3e78a1eecd05b", size = 1263647, upload-time = "2026-05-07T21:43:35.112Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/03/184267c1d09783dd070f1ddfd0d4beb7503139dfc7bd75b422867cf282fd/keras-3.14.1-py3-none-any.whl", hash = "sha256:ebd2c14d2af3c9de18083604d408483996407fc7d2f9ebd1d565961f96608c29", size = 1628606, upload-time = "2026-05-07T21:43:32.737Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + [[package]] name = "libclang" version = "18.1.1" @@ -1610,6 +2055,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1624,7 +2143,7 @@ name = "ml-dtypes" version = "0.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/7d/8d85fcba868758b3a546e6914e727abd8f29ea6918079f816975c9eecd63/ml_dtypes-0.3.2.tar.gz", hash = "sha256:533059bc5f1764fac071ef54598db358c167c51a718f68f5bb55e3dee79d2967", size = 692014, upload-time = "2024-01-03T19:21:23.615Z" } wheels = [ @@ -1649,7 +2168,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "attrs", marker = "python_full_version < '3.11'" }, @@ -1666,13 +2186,16 @@ name = "morecantile" version = "7.0.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -1681,7 +2204,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -1898,6 +2422,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "netcdf4" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", +] +dependencies = [ + { name = "certifi", marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "cftime", marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "numpy", marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/76/7bc801796dee752c1ce9cd6935564a6ee79d5c9d9ef9192f57b156495a35/netcdf4-1.7.3.tar.gz", hash = "sha256:83f122fc3415e92b1d4904fd6a0898468b5404c09432c34beb6b16c533884673", size = 836095, upload-time = "2025-10-13T18:38:00.76Z" } + +[[package]] +name = "netcdf4" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", +] +dependencies = [ + { name = "certifi", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "cftime", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "numpy", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/b6/0370bb3af66a12098da06dc5843f3b349b7c83ccbdf7306e7afa6248b533/netcdf4-1.7.4.tar.gz", hash = "sha256:cdbfdc92d6f4d7192ca8506c9b3d4c1d9892969ff28d8e8e1fc97ca08bf12164", size = 838352, upload-time = "2026-01-05T02:27:38.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/07/dfdd017641e82fadaf4e043d91fa179d34940c7d69175a3034dea877df9c/netcdf4-1.7.4-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:b1c1a7ea3678db76bf33d14f7e202385d634db38c5e70d8cf4895971023eebb9", size = 23499427, upload-time = "2026-01-05T02:26:54.13Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/8cd98d166f30d378488c5235457d6af7df09f9925ab5ad03d6840543f42e/netcdf4-1.7.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:d3f9497873454207f9480847d02b1b19a4bc81ad6e9166e1c17d4e2f8f3555d1", size = 22886591, upload-time = "2026-01-05T02:26:57.113Z" }, + { url = "https://files.pythonhosted.org/packages/08/1c/ab31713a95160ebc6b4ec495cd4f03f38b235188a7e955bf33703c5039ca/netcdf4-1.7.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8e18294af803e80f8c0339f791901942e268c334c099bbd5f7ea8325a49801a", size = 10336881, upload-time = "2026-01-05T02:26:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/26/d7/bb16993af267acda23fe3de4ead2528cbe49043e391f732a1a4a15beec20/netcdf4-1.7.4-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b06c0b93fd0ecc1ec67a582f3ba98b7db9da1fa843c8f83fd75990e3701771e", size = 10182772, upload-time = "2026-01-05T02:27:01.545Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a6/e6fca338488a896c5e1f661ba3007e83f46700e1a59552b05013d501bc45/netcdf4-1.7.4-cp310-cp310-win_amd64.whl", hash = "sha256:889ba77f084504aebaba9c6f9a88ac213431fef0e897f887cd35aef351ff7740", size = 21363337, upload-time = "2026-01-05T02:27:04.21Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/38ed7e1956943d28e8ea74161e97c3a00fb98d6d08943b4fd21bae32c240/netcdf4-1.7.4-cp311-abi3-macosx_13_0_x86_64.whl", hash = "sha256:dec70e809cc65b04ebe95113ee9c85ba46a51c3a37c058d2b2b0cadc4d3052d8", size = 23427499, upload-time = "2026-01-05T02:27:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/2f73c133b71709c412bc81d8b721e28dc6237ba9d7dad861b7bfbb70408a/netcdf4-1.7.4-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:75cf59100f0775bc4d6b9d4aca7cbabd12e2b8cf3b9a4fb16d810b92743a315a", size = 22847667, upload-time = "2026-01-05T02:27:09.421Z" }, + { url = "https://files.pythonhosted.org/packages/77/ce/43a3c0c41a6e2e940d87feea79d29aa88302211ac122604838f8a5a48de6/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddfc7e9d261125c74708119440c85ea288b5fee41db676d2ba1ce9be11f96932", size = 10274769, upload-time = "2026-01-05T21:31:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7a/a8d32501bb95ecff342004a674720164f95ad616f269450b3bc13dc88ae3/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72c9f58767779ec14cb7451c3b56bdd8fdc027a792fac2062b14e090c5617f3", size = 10123122, upload-time = "2026-01-05T21:31:22.773Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/e89b4fa9242e59326c849c39ce0f49eb68499603c639405a8449900a4f15/netcdf4-1.7.4-cp311-abi3-win_amd64.whl", hash = "sha256:9476e1f23161ae5159cd1548c50c8a37922e77d76583e247133f256ef7b825fc", size = 21299637, upload-time = "2026-01-05T02:27:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/edd41a3607241027aa4533e7f18e0cd647e74dde10a63274c65350f59967/netcdf4-1.7.4-cp311-abi3-win_arm64.whl", hash = "sha256:876ad9d58f09c98741c066c726164c45a098a58fb90e5fac9e74de4bb8a793fd", size = 2386377, upload-time = "2026-01-05T02:27:13.808Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/1e83534ba68459bc5ae39df46fa71003984df58aabf31f7dcd6e22ecddb0/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56688c03444fffe0d0c7512cb45245e650389cd841c955b30e4552fa681c4cd9", size = 10519821, upload-time = "2026-01-05T02:27:15.413Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/a15d6fe97f81d6d5202b17838a9a298b5955b3e9971e20609195112829b5/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ecf471ba8a6ddb2200121949bedfa0095db228822f38227d5da680694a38358", size = 10371133, upload-time = "2026-01-05T02:27:17.224Z" }, + { url = "https://files.pythonhosted.org/packages/d8/2b/684b15dd4791f8be295b2f6fa97377bbc07a768478a63b7d3c4951712e36/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5841de0735e8e4875b367c668e81d334287858d64dd9f3e3e2261e808c84922", size = 10395635, upload-time = "2026-01-05T02:27:19.655Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/44d21524cf1b1c64254f92e22395a7a10f70c18f3a13a18ac9db258760f7/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fac03a8c5b250d57866e7d98918a64742e4b0de1681c5c86bac5726bab8aee", size = 10237725, upload-time = "2026-01-05T02:27:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9d/c3ddf54296ad8f18f02f77f23452bdb0971aece1b87e84bab9d734bf72cc/netcdf4-1.7.4-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ad083d260301b5add74b1669c75ab0df03bdf986decfcc092cb45eec2615b5f1", size = 23515258, upload-time = "2026-01-05T02:27:24.837Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/bc0346e995d436d03fab682b7fbd2a9adcf0db6a05790b8f24853bf08170/netcdf4-1.7.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f22014092cc9da3f056b0368e2e38c42afd5725c87ad4843eb2f467e16dd4f6", size = 22910171, upload-time = "2026-01-05T02:27:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/6b/f9bc3f43c55e2dac72ee9f98d77860789bdd5d50c29adf164a6bdb303078/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224a15434c165a5e0225e5831f591edf62533044b1ce62fdfee815195bbd077d", size = 10567579, upload-time = "2026-01-05T02:27:29.382Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/e7685c66b7f011c73cd746127f986358a26c642a4e4a1aa5ab51481b6586/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31a2318305de6831a18df25ad0df9f03b6d68666af0356d4f6057d66c02ffeb6", size = 10255032, upload-time = "2026-01-05T02:27:31.744Z" }, + { url = "https://files.pythonhosted.org/packages/a6/14/7506738bb6c8bc373b01e5af8f3b727f83f4f496c6b108490ea2609dc2cf/netcdf4-1.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:6c4a0aa9446c3a616ef3be015b629dc6173643f8b09546de26a4e40e272cd1ed", size = 22289653, upload-time = "2026-01-05T02:27:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/2e/39d5e9179c543f2e6e149a65908f83afd9b6d64379a90789b323111761db/netcdf4-1.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:034220887d48da032cb2db5958f69759dbb04eb33e279ec6390571d4aea734fe", size = 2531682, upload-time = "2026-01-05T02:27:37.062Z" }, +] + [[package]] name = "numcodecs" version = "0.13.1" @@ -1905,7 +2506,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11'" }, @@ -1935,13 +2537,16 @@ name = "numcodecs" version = "0.16.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -1950,7 +2555,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -2131,7 +2737,7 @@ name = "optree" version = "0.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/63/92328a17ab7836562fe0129e605f685a88db35ce98427c34ff48ee4ec157/optree-0.19.1.tar.gz", hash = "sha256:4497d1c9197b8c6842e511368163d318ce536521ebdcff8bebb7551dcdfac532", size = 177531, upload-time = "2026-05-06T02:32:39.704Z" } wheels = [ @@ -2337,14 +2943,16 @@ name = "pandas" version = "2.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, @@ -2408,8 +3016,10 @@ name = "pandas" version = "3.0.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -2418,7 +3028,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -3225,7 +3836,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "certifi", marker = "python_full_version < '3.11'" }, @@ -3271,13 +3883,16 @@ name = "pyproj" version = "3.7.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -3286,7 +3901,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -3360,7 +3976,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "configobj", marker = "python_full_version < '3.11'" }, @@ -3399,13 +4016,16 @@ name = "pyresample" version = "1.35.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -3414,7 +4034,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -3623,10 +4244,14 @@ dependencies = [ { name = "fsspec" }, { name = "geopandas" }, { name = "h3" }, + { name = "h5netcdf" }, { name = "h5py" }, { name = "httpx" }, + { name = "matplotlib" }, { name = "msal" }, { name = "nest-asyncio" }, + { name = "netcdf4", version = "1.7.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "netcdf4", version = "1.7.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, { name = "obstore" }, { name = "osm2geojson" }, { name = "overturemaps" }, @@ -3677,10 +4302,13 @@ requires-dist = [ { name = "fsspec", specifier = ">=2026.4.0" }, { name = "geopandas" }, { name = "h3" }, + { name = "h5netcdf", specifier = ">=1.8.1" }, { name = "h5py", specifier = ">=3.16.0" }, { name = "httpx" }, + { name = "matplotlib", specifier = ">=3.10.9" }, { name = "msal" }, { name = "nest-asyncio" }, + { name = "netcdf4", specifier = ">=1.7.3" }, { name = "obstore", specifier = ">=0.9.5" }, { name = "osm2geojson" }, { name = "overturemaps" }, @@ -3838,7 +4466,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "click", marker = "python_full_version < '3.11'" }, @@ -3856,13 +4485,16 @@ name = "rio-cogeo" version = "7.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -3871,7 +4503,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -4066,7 +4699,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11'" }, @@ -4125,13 +4759,16 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -4140,7 +4777,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -4345,15 +4983,15 @@ name = "tensorboard" version = "2.16.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "absl-py" }, - { name = "grpcio" }, - { name = "markdown" }, - { name = "numpy" }, - { name = "protobuf" }, - { name = "setuptools" }, - { name = "six" }, - { name = "tensorboard-data-server" }, - { name = "werkzeug" }, + { name = "absl-py", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "grpcio", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "markdown", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "numpy", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "protobuf", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "setuptools", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "six", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "tensorboard-data-server", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "werkzeug", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/3a/d0/b97889ffa769e2d1fdebb632084d5e8b53fc299d43a537acee7ec0c021a3/tensorboard-2.16.2-py3-none-any.whl", hash = "sha256:9f2b4e7dad86667615c0e5cd072f1ea8403fc032a299f0072d6f74855775cc45", size = 5490335, upload-time = "2024-02-16T19:56:55.912Z" }, @@ -4374,29 +5012,29 @@ name = "tensorflow" version = "2.16.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "absl-py" }, - { name = "astunparse" }, - { name = "flatbuffers" }, - { name = "gast" }, - { name = "google-pasta" }, - { name = "grpcio" }, - { name = "h5py" }, - { name = "keras", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "libclang" }, - { name = "ml-dtypes" }, - { name = "numpy" }, - { name = "opt-einsum" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "requests" }, - { name = "setuptools" }, - { name = "six" }, - { name = "tensorboard" }, - { name = "tensorflow-io-gcs-filesystem", marker = "python_full_version < '3.12'" }, - { name = "termcolor" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "absl-py", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "astunparse", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "flatbuffers", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "gast", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "google-pasta", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "grpcio", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "h5py", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "keras", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and sys_platform != 'win32')" }, + { name = "libclang", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "ml-dtypes", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "numpy", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "opt-einsum", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "packaging", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "protobuf", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "requests", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "setuptools", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "six", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "tensorboard", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "tensorflow-io-gcs-filesystem", marker = "(python_full_version < '3.12' and platform_machine != 'ARM64') or (python_full_version < '3.12' and sys_platform != 'win32')" }, + { name = "termcolor", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "typing-extensions", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "wrapt", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ea/49/0ba9a26146b93666d9d0a1207b0dbdff24caf96a2102dc1124aa1bcc0c5f/tensorflow-2.16.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:72c84f0e0f8ad0e7cb7b4b3fe9d1c899e6cbebc51c0e64df42a2a32a904aacd7", size = 226983658, upload-time = "2024-06-28T18:50:27.278Z" }, @@ -4412,29 +5050,29 @@ name = "tensorflow-cpu" version = "2.16.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "absl-py", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "astunparse", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "flatbuffers", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "gast", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "google-pasta", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "grpcio", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "h5py", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "keras", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, - { name = "libclang", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "ml-dtypes", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "opt-einsum", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "packaging", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "protobuf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "requests", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "setuptools", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "six", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "tensorboard", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "tensorflow-io-gcs-filesystem", marker = "(python_full_version < '3.12' and platform_machine != 'aarch64') or (python_full_version < '3.12' and sys_platform != 'linux')" }, - { name = "termcolor", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "wrapt", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "absl-py", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "astunparse", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "flatbuffers", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "gast", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "google-pasta", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "grpcio", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "h5py", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "keras", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version >= '3.11' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "libclang", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "ml-dtypes", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "opt-einsum", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "packaging", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "protobuf", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "requests", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "setuptools", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "six", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "tensorboard", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "tensorflow-io-gcs-filesystem", marker = "(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "termcolor", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "typing-extensions", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "wrapt", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'linux' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/96/c7/514f64b6e446b1161c30722aec00be1e5b4ae15ecdcb9ec4e3f6966a4314/tensorflow_cpu-2.16.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:fe1a52b0303486fc20b9832592f336c3851c41ca4a4cf9dfce1616f0c60a40c2", size = 259544880, upload-time = "2024-06-28T18:53:46.157Z" }, @@ -4483,7 +5121,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11'" }, @@ -4498,13 +5137,16 @@ name = "tifffile" version = "2026.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -4513,7 +5155,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -4618,7 +5261,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11'" }, @@ -4661,13 +5305,16 @@ name = "trollimage" version = "1.28.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -4676,7 +5323,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -4864,7 +5512,7 @@ name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ @@ -4876,7 +5524,7 @@ name = "wheel" version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } wheels = [ @@ -4969,7 +5617,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11'" }, @@ -4986,13 +5635,16 @@ name = "xarray" version = "2026.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -5001,7 +5653,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -5165,7 +5818,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "asciitree", marker = "python_full_version < '3.11'" }, @@ -5183,13 +5837,16 @@ name = "zarr" version = "3.1.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", @@ -5198,7 +5855,8 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", - "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", From 6750a833e15e12e86f4735caf0f8d9efc6548ad9 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Tue, 9 Jun 2026 09:39:52 +0200 Subject: [PATCH 15/20] nooa oatage adjustments --- rapida/ntl/fetch.py | 3 ++ rapida/ntl/noaa/cmask.py | 80 ++++++++++++++------------------------- rapida/ntl/noaa/io.py | 29 ++++++++------ rapida/ntl/noaa/search.py | 12 ++++-- rapida/ntl/outage.py | 16 +++++++- 5 files changed, 71 insertions(+), 69 deletions(-) diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py index e3bcb7f7..0022ac5e 100644 --- a/rapida/ntl/fetch.py +++ b/rapida/ntl/fetch.py @@ -49,6 +49,9 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, granules = await async_search_granules( satellites=None, nominal_date=nominal_date, bbox=bbox, cmask=True, progress=progress) + if not granules: + logger.info(f'No descending granules for found for {nominal_date.date()} over target area {bbox}') + return logger.info(f'Found {len(granules)} descending granules') selected_granules = select_required_granules(sorted_granules=granules, bbox=bbox, progress=progress) logger.info(f'Selected {len(selected_granules)} granule(s) that cover(s) bbox {bbox}') diff --git a/rapida/ntl/noaa/cmask.py b/rapida/ntl/noaa/cmask.py index d14f5bdf..b3eac4ee 100644 --- a/rapida/ntl/noaa/cmask.py +++ b/rapida/ntl/noaa/cmask.py @@ -13,7 +13,8 @@ from osgeo import gdal from shapely.ops import transform from rapida.ntl import cache -from shapely.geometry import Polygon +from shapely.geometry import MultiPoint, Polygon +from shapely import concave_hull from shapely.ops import unary_union from itertools import combinations gdal.UseExceptions() @@ -39,61 +40,37 @@ def bounds_from_url(hdf_url: str): return bounds_from_file(hfile=hfile) -def bounds_from_file(hfile:h5py.File) -> Polygon: +def bounds_from_file(hfile, step=50) -> Polygon: + # 1. Subsample the entire 2D array directly during the read. + # h5py translates this slice into an optimized partial read, + # making it very fast even over a network (fsspec). + lat_grid = hfile['Latitude'][::step, ::step][()] + lon_grid = hfile['Longitude'][::step, ::step][()] - lat = hfile['Latitude'] - lon = hfile['Longitude'] + # 2. Flatten into 1D arrays + lats = lat_grid.flatten() + lons = lon_grid.flatten() - # 1. Fetch the full perimeter arrays into memory. - # Thanks to fsspec's 1MB block cache, this resolves in just 1 or 2 HTTP requests. - top_lat = lat[0, :] - top_lon = lon[0, :] + # 3. Create a mask to filter out NoData/Fill values globally + valid_mask = (lons >= -180.0) & (lons <= 180.0) & \ + (lats >= -90.0) & (lats <= 90.0) - bot_lat = lat[-1, :] - bot_lon = lon[-1, :] + valid_lons = lons[valid_mask] + valid_lats = lats[valid_mask] - left_lat = lat[:, 0] - left_lon = lon[:, 0] + if len(valid_lons) == 0: + return Polygon() # Return empty if no valid data exists - right_lat = lat[:, -1] - right_lon = lon[:, -1] + # 4. Create a Shapely MultiPoint collection + points = MultiPoint(np.column_stack((valid_lons, valid_lats))) - step = 330 + # 5. Generate the footprint boundary + # convex_hull creates a tight bounding polygon around the outermost points. + poly = concave_hull(points,ratio=0.1) - def sample_edge(arr): - """Samples the array at the given step, strictly ensuring the exact final corner is included.""" - indices = list(range(0, len(arr), step)) - if indices[-1] != len(arr) - 1: - indices.append(len(arr) - 1) - return arr[indices] - - # 2. Top edge (Left to Right) - t_lon = list(sample_edge(top_lon)) - t_lat = list(sample_edge(top_lat)) - - # 3. Right edge (Top to Bottom) - r_lon = list(sample_edge(right_lon)) - r_lat = list(sample_edge(right_lat)) - - # 4. Bottom edge (Right to Left) -> Needs Reversing to maintain the continuous ring - b_lon = list(sample_edge(bot_lon))[::-1] - b_lat = list(sample_edge(bot_lat))[::-1] - - # 5. Left edge (Bottom to Top) -> Needs Reversing - l_lon = list(sample_edge(left_lon))[::-1] - l_lat = list(sample_edge(left_lat))[::-1] - - # 6. Combine all edges into a single continuous boundary ring - ring_lons = t_lon + r_lon + b_lon + l_lon - ring_lats = t_lat + r_lat + b_lat + l_lat - - # 7. Create the dense, curved polygon - poly = Polygon(zip(ring_lons, ring_lats)) - - # Optional but recommended: run a 0-distance buffer to instantly fix any - # micro-intersections or invalid topologies that occur precisely at the corner joints return poly.buffer(0) + def select_required_granules(sorted_granules: list, bbox: tuple, progress:Progress=None) -> list: boxpoly = box(*bbox, ccw=True) best_poly = bounds_from_url(sorted_granules[0].url) @@ -140,6 +117,10 @@ def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): #bounds_poly = wkt.loads(hfile.attrs['geospatial_bounds'].decode('utf-8')) bounds_poly = bounds_from_file(hfile) bbox_poly = box(*bbox, ccw=True) + with open(os.path.join('/tmp', 'bbox.geojson'), "w") as ff: + ff.write(to_geojson(bbox_poly)) + with open(os.path.join('/tmp', filename.replace('.nc', '.geojson')), "w") as f: + f.write(to_geojson(bounds_poly)) # 2. The Kiribati Ghost Detection minx, miny, maxx, maxy = bounds_poly.bounds is_idl_crosser = (maxx - minx) > 300 @@ -157,10 +138,7 @@ def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): return False, 0 intersection_poly = working_bbox.intersection(working_bounds) perc_intersection = round(intersection_poly.area/working_bbox.area * 100) - # with open(os.path.join(dst_dir, 'bbox.geojson'), "w") as ff: - # ff.write(to_geojson(bbox_poly)) - # with open(os.path.join(dst_dir, filename.replace('.nc', '.geojson')), "w") as f: - # f.write(to_geojson(bounds_poly)) + return True, perc_intersection diff --git a/rapida/ntl/noaa/io.py b/rapida/ntl/noaa/io.py index 11f07bac..d323dbce 100644 --- a/rapida/ntl/noaa/io.py +++ b/rapida/ntl/noaa/io.py @@ -13,6 +13,7 @@ from pyresample import geometry from satpy import Scene import numpy as np +from pathlib import Path logger = logging.getLogger(__name__) @@ -106,8 +107,9 @@ async def find_ntl(satellite: str = None, bbox: Iterable[float] = None, dt: date public_file_url = public_url(file_path=file_path, satellite=satellite, source=current_source) is_intersecting, percent = bbox_in_hdf(hdf_url=public_file_url,bbox=bbox) if not is_intersecting: + _, file_name = os.path.split(file_path) logger.info( - f'Skipping {file_path} from {current_source} generated by {satellite} because it does not intersect {bbox} bbox') + f'Skipping {file_name} from {current_source} scanned by {satellite} because it does not intersect bbox') spatial_misses.add(time_pattern) continue @@ -203,6 +205,12 @@ async def fetch_file(satellite:str=None, provider:str=None, path:str=None, size: down_task = progress.add_task(f'[red]Downloading {file_name} from {provider}', total=size) dst_file_path = os.path.join(adir, file_name) response = await obstore.get_async(store, path) + + dst_path = Path(dst_file_path) + if dst_path.exists() and dst_path.stat().st_size == response.meta['size']: + if progress and progress_task is not None: + progress.update(progress_task, description=f'[green]Reused {file_name} from local folder {adir}', advance=1) + return product_name, dst_file_path, dst_path.stat().st_size async with aiofiles.open(dst_file_path, 'wb') as local_file: # The 'get' call is the async request async for chunk in response.stream(): @@ -336,10 +344,7 @@ def read_and_align_sdr_and_cmask(sdr_path, geo_path, cmask_path, target_area): Loads, crops, and resamples VIIRS SDR (Radiance) and EDR (Cloud Mask) using the native fill values extracted dynamically from the file metadata. """ - # 1. Calculate a slightly buffered crop box for Satpy - lon_min, lat_min, lon_max, lat_max = target_area.area_extent_ll - pad = 0.2 - crop_bbox = (lon_min - pad, lat_min - pad, lon_max + pad, lat_max + pad) + # 2. Load the Scenes sdr_scn = Scene(filenames=[sdr_path, geo_path], reader='viirs_sdr') @@ -378,13 +383,13 @@ def read_and_align_sdr_and_cmask(sdr_path, geo_path, cmask_path, target_area): # 7. Extract raw NumPy arrays and mask dnb_array = resampled_sdr['DNB'].values - # Safely convert the dynamic fill value to np.nan for math operations - if np.isnan(dnb_fill): - # Data might already be masked as NaN by Satpy during load - dnb_array = np.where(np.isnan(dnb_array), np.nan, dnb_array) - else: - # Mask the explicit file-defined integer/float fill value - dnb_array = np.where(dnb_array == dnb_fill, np.nan, dnb_array) + # # Safely convert the dynamic fill value to np.nan for math operations + # if np.isnan(dnb_fill): + # # Data might already be masked as NaN by Satpy during load + # dnb_array = np.where(np.isnan(dnb_array), np.nan, dnb_array) + # else: + # # Mask the explicit file-defined integer/float fill value + # dnb_array = np.where(dnb_array == dnb_fill, np.nan, dnb_array) dnb_scaled = dnb_array * 1e5 # Scale to match Black Marble nW/cm2/sr cmask_array = resampled_cmask['CloudMask'].values diff --git a/rapida/ntl/noaa/search.py b/rapida/ntl/noaa/search.py index 809a0d06..ed8ad342 100644 --- a/rapida/ntl/noaa/search.py +++ b/rapida/ntl/noaa/search.py @@ -412,14 +412,14 @@ def night_passes(self, bbox:Iterable[float]=None, nominal_date:date=None): lat_rad = math.radians(midlat) cos_h = -math.tan(lat_rad) * math.tan(declination) night_hrs = int(round(24 - (2 * math.degrees(math.acos(max(-1.0, min(1.0, cos_h)))) / 15))) - + search_duration_hrs = max(10, night_hrs) # 2. THE ANCHOR (01:30 AM Local -> UTC) utc_anchor = datetime.combine(nominal_date, dtime(1, 30)) - timedelta(hours=midlon / 15.0) # 3. THE TRIGGER (Use North-Lat to find when the satellite ENTERS the box) - search_start = utc_anchor - timedelta(hours=night_hrs / 2) - night_passes = self.orb.get_next_passes(search_start, night_hrs, midlon, midlat, 0) # northlat??? + search_start = utc_anchor - timedelta(hours=search_duration_hrs / 2) + night_passes = self.orb.get_next_passes(search_start, search_duration_hrs, midlon, midlat, 0) # northlat??? logger.debug(f'{self.satellite} passes {len(night_passes)} time(s) over {list(bbox)} on the night of {nominal_date:%y-%m-%d}') passes = [] for _pass_ in night_passes: @@ -439,6 +439,7 @@ def night_passes(self, bbox:Iterable[float]=None, nominal_date:date=None): async def night_granules_async(self, bbox:Iterable[float]=None, nominal_date:date=None, cmask=False, progress=None): + midlon, midlat, northlat = self.decompose_bbox(bbox=bbox) passes = self.night_passes(nominal_date=nominal_date, bbox=bbox) @@ -575,7 +576,7 @@ def search_granules(satellites:Optional[Iterable[str]]=None, async def async_search_granules( satellites:Optional[Iterable[str]]=None, nominal_date:date=None, bbox:Iterable[float] = None, - cmask=False, progress=None + cmask=False, progress=None, push_to_cache:bool=False, ): """ @@ -614,6 +615,9 @@ async def async_search_granules( if progress and progress_task is not None: progress.remove_task(progress_task) + + + if cmask: cloud_coverage_results = cloud_coverage_batch(urls=list(found_granules.keys()), bbox=bbox, progress=progress) for cm_url, g in found_granules.items(): diff --git a/rapida/ntl/outage.py b/rapida/ntl/outage.py index 47140d20..0214b9a0 100644 --- a/rapida/ntl/outage.py +++ b/rapida/ntl/outage.py @@ -49,9 +49,10 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N downloaded_data_files = await fetch(bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, progress=progress, dst_dir=dst_dir) + if not downloaded_data_files: + return - - if 'NOAA' in deliverable: # {timestamp:(product, file_path, size)} + if 'NOAA' in deliverable: # {timestamp:{product:file_path}} target_area = create_area_from_geotransform(gt, baseline_array.shape) for timestamp, entry in downloaded_data_files.items(): a1, cm = read_and_align_sdr_and_cmask(sdr_path=entry[noaa_const.SDR],geo_path=entry[noaa_const.GEO], @@ -61,6 +62,17 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N + data[f'{noaa_const.SDR}_{timestamp}'] = a1 + data[f'{noaa_const.CM}_{timestamp}'] = cm + + + # if display: + # from rapida.ntl import vis + # vis.display2(data=data, title=f'Outage inputs and results for {deliverable} at {bbox} on {nominal_date.date()}') + + + + return else: # NASA, {product:(timestamp:(tiles)} for product, results in downloaded_data_files.items(): level = product[-2:] From bdad85f384f3b3c9382c9ba0b171b034855b06ac Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Tue, 9 Jun 2026 16:25:48 +0200 Subject: [PATCH 16/20] switch to daily cached tle from space-track.org --- rapida/ntl/cache.py | 2 +- rapida/ntl/noaa/cmask.py | 8 +- rapida/ntl/noaa/io.py | 11 + rapida/ntl/noaa/search.py | 568 ++++++++++++++++++++------------------ 4 files changed, 313 insertions(+), 276 deletions(-) diff --git a/rapida/ntl/cache.py b/rapida/ntl/cache.py index eff2cc16..c9fa62e0 100644 --- a/rapida/ntl/cache.py +++ b/rapida/ntl/cache.py @@ -8,7 +8,7 @@ MAX_AGE_SECONDS = 6 * 3600 # 6 hours -CACHE_PATH = os.path.join(tempfile.gettempdir(), "ntl_search_cache") +CACHE_PATH = os.path.join(tempfile.gettempdir(), "ntl_cache") diff --git a/rapida/ntl/noaa/cmask.py b/rapida/ntl/noaa/cmask.py index b3eac4ee..ef8a838e 100644 --- a/rapida/ntl/noaa/cmask.py +++ b/rapida/ntl/noaa/cmask.py @@ -117,10 +117,10 @@ def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): #bounds_poly = wkt.loads(hfile.attrs['geospatial_bounds'].decode('utf-8')) bounds_poly = bounds_from_file(hfile) bbox_poly = box(*bbox, ccw=True) - with open(os.path.join('/tmp', 'bbox.geojson'), "w") as ff: - ff.write(to_geojson(bbox_poly)) - with open(os.path.join('/tmp', filename.replace('.nc', '.geojson')), "w") as f: - f.write(to_geojson(bounds_poly)) + # with open(os.path.join('/tmp', 'bbox.geojson'), "w") as ff: + # ff.write(to_geojson(bbox_poly)) + # with open(os.path.join('/tmp', filename.replace('.nc', '.geojson')), "w") as f: + # f.write(to_geojson(bounds_poly)) # 2. The Kiribati Ghost Detection minx, miny, maxx, maxy = bounds_poly.bounds is_idl_crosser = (maxx - minx) > 300 diff --git a/rapida/ntl/noaa/io.py b/rapida/ntl/noaa/io.py index d323dbce..955de552 100644 --- a/rapida/ntl/noaa/io.py +++ b/rapida/ntl/noaa/io.py @@ -26,6 +26,17 @@ for sat, sources in VIIRS_URLS.items() } + +def get_viirs_stores(satellite: str): + """ + Factory function to instantiate obstore clients on demand. + Must only be called from inside an active asyncio event loop. + """ + return { + source: obstore.store.from_url(url, config=PUBLIC_CONFIG) + for source, url in VIIRS_URLS[satellite].items() + } + def parse_noaa_timestamp(time_str: str) -> datetime: """ Converts a NOAA VIIRS string (e.g., '202604010001018') into a timezone-naive UTC datetime. diff --git a/rapida/ntl/noaa/search.py b/rapida/ntl/noaa/search.py index ed8ad342..a6b5dba8 100644 --- a/rapida/ntl/noaa/search.py +++ b/rapida/ntl/noaa/search.py @@ -3,24 +3,23 @@ """ import json import os.path -import asyncio from pyorbital.orbital import Orbital from datetime import datetime, timedelta, date, time as dtime -from pathlib import Path import math -import httpx from dataclasses import dataclass, asdict from rich.progress import Progress -import time import logging from typing import Iterable, Optional +import obstore from rapida.ntl.noaa.io import ( -find_ntl, public_url, parse_noaa_timestamp, locate_file +find_ntl, public_url, parse_noaa_timestamp, VIIRS_STORES, SOURCE_NAMES, PRODUCTS ) - +import asyncio +from spacetrack import SpaceTrackClient from rapida.ntl.noaa.cmask import cloud_coverage_batch from rapida.ntl.noaa.const import PRODUCTS_RE from rapida.ntl import cache +import numpy as np logger = logging.getLogger(__name__) @@ -203,174 +202,235 @@ class VIIRSNavigator: # THE OFFLINE MASTER SEEDS (Locked in April 2026) # Drift is 'Seconds shifted per 24 hours' # - # the drifting was comuted by analyiz the tiomestamp of the first image produced by each satellite + # the drifting was comuted by analyiz the timestamp of the first image produced by each satellite # ex for SNPP using rclone # for i in $(seq 0 30); do T_DATE=$(date -d "2026-04-15 - $i days" +%Y/%m/%d); echo -n "$T_DATE | "; rclone lsf --s3-provider AWS --s3-region us-east-1 --s3-no-check-bucket ":s3:noaa-nesdis-snpp-pds/VIIRS-DNB-SDR/$T_DATE/" --include "*t00*.h5" -q | sort | head -n 1 | grep -o 't[0-9]\{7\}' | sed 's/t//'; done SATELLITES = {'SNPP':'SUOMI NPP', 'N20':'NOAA 20', 'N21':'NOAA 21'} - SAT_CONFIGS = { - "N21": {"ref": date(2026, 4, 14), "phase": 67.2, "drift": -25.80}, - "N20": {"ref": date(2026, 4, 14), "phase": 68.0, "drift": -25.71}, - "SNPP": {"ref": date(2026, 4, 14), "phase": 68.4, "drift": -25.37} - } + MIN_ELEVATION_ANGLE = 20 - def __init__(self, satellite=None, tle_file='/tmp/rapida.tle'): + def __init__(self, satellite=None): self.satellite = satellite - self.tle_file = self.get_tle(tle_file) - - self.orb = Orbital(satellite=self.satellite, tle_file=str(self.tle_file)) - - self.cfg = self.SAT_CONFIGS[self.satellite] - - def fetch_tle(self): + def fetch_cached_tle(self, target_date: date=None, cache=cache ) -> str: """ - Surgically fetches VIIRS TLEs one-by-one to avoid query errors - and IP bans. Merges them into a single in-memory string. + Fetches the TLE for a specific date, checking the shelve cache first. + If missing, hits Space-Track, caches it, and returns the formatted string. """ - # 37849: Suomi-NPP | 43013: NOAA-20 | 54234: NOAA-21 - targets = { - "37849": "SNPP", - "43013": "N20", - "54234": "N21" - } - - merged_tle = "" - - # Using the .org domain directly to avoid the 301 redirect penalty - base_url = "https://celestrak.org/NORAD/elements/gp.php" - - # A professional User-Agent is your best shield against bans - headers = { - 'User-Agent': 'UNDP RAPIDA-Engine)', - 'Accept': 'text/plain' - } - - with Progress(disable=False, console=None, transient=True) as progress: + # Deterministic cache key + key = f"TLE_{self.satellite}_{target_date.strftime('%Y%m%d')}" + tle_data = cache.fetch(key=key) + if not tle_data: + user = os.getenv('SPACETRACK_USER') + if user is None: + raise EnvironmentError("SPACETRACK_USER is missing from environment variables.") + + passwd = os.getenv('SPACETRACK_PASSWORD') + if passwd is None: + raise EnvironmentError("SPACETRACK_PASSWORD is missing from environment variables.") + # 2. Network Fetch (Space-Track) + st = SpaceTrackClient( + identity=user, + password=passwd + ) - total_task = progress.add_task("[cyan]Initializing TLE io...", total=len(targets)) + cat_ids = {"SNPP": "37849", "N20": "43013", "N21": "54234"} + start_date = (target_date - timedelta(days=3)).strftime('%Y-%m-%d') + end_date = (target_date + timedelta(days=1)).strftime('%Y-%m-%d') - with httpx.Client(timeout=15.0, follow_redirects=True) as client: - for catnr, name in targets.items(): - params = { - 'CATNR': catnr, - 'FORMAT': 'TLE' - } + tle_data = st.gp_history( + norad_cat_id=cat_ids[self.satellite], + epoch=f"{start_date}--{end_date}", + orderby='EPOCH desc', + limit=1, + format='tle' + ) - try: - response = client.get(base_url, params=params, headers=headers) + if not tle_data: + raise ValueError(f"No TLE found for {self.satellite} on {target_date}") + cache.store(key=key, value=tle_data) - # RULE: If we hit an error, STOP. Don't hammer the server. - if response.status_code != 200: - progress.console.log(f"🛑 Error {response.status_code} for {name}. Aborting to avoid IP ban.") - break - # Validate that we actually got a TLE (should start with name or '1 ') - data = response.text.strip() - if "1 " in data: - # Split the response into lines - lines = [l.strip() for l in response.text.strip().splitlines() if l.strip()] - - # CelesTrak usually returns 3 lines (Name, L1, L2) - # or 2 lines (L1, L2) if CATNR is used. - # We only care about the last two lines (the TLE data) - if len(lines) >= 2 and lines[-2].startswith("1 "): - tle_l1 = lines[-2] - tle_l2 = lines[-1] - - # We MANUALLY prepend our clean name - merged_tle += f"{name}\n{tle_l1}\n{tle_l2}\n" - progress.advance(total_task) - progress.console.log(f"[green]✅ Successfully fetched TLE for {name}") - - else: - progress.console.log(f"[yellow]⚠️ Received invalid data for {name}.") - - except Exception as e: - progress.console.log(f"[red]❌ Network error on {name}: {e}") - break + return tle_data - # THE "GOOD CITIZEN" DELAY: - # CelesTrak specifically asks for breaks between requests. - # Advance the bar and update description for the "Good Citizen" sleep - - if catnr != list(targets.keys())[-1]: # Don't sleep after the last target - progress.update(total_task, - description=f"[dim white]Respecting CelesTrak rate limits (2s)...") - time.sleep(2.0) + # def fetch_tle(self): + # """ + # Surgically fetches VIIRS TLEs one-by-one to avoid query errors + # and IP bans. Merges them into a single in-memory string. + # """ + # # 37849: Suomi-NPP | 43013: NOAA-20 | 54234: NOAA-21 + # targets = { + # "37849": "SNPP", + # "43013": "N20", + # "54234": "N21" + # } + # + # merged_tle = "" + # + # # Using the .org domain directly to avoid the 301 redirect penalty + # base_url = "https://celestrak.org/NORAD/elements/gp.php" + # + # # A professional User-Agent is your best shield against bans + # headers = { + # 'User-Agent': 'UNDP RAPIDA-Engine)', + # 'Accept': 'text/plain' + # } + # + # with Progress(disable=False, console=None, transient=True) as progress: + # + # total_task = progress.add_task("[cyan]Initializing TLE io...", total=len(targets)) + # + # with httpx.Client(timeout=15.0, follow_redirects=True) as client: + # for catnr, name in targets.items(): + # params = { + # 'CATNR': catnr, + # 'FORMAT': 'TLE' + # } + # + # try: + # response = client.get(base_url, params=params, headers=headers) + # + # # RULE: If we hit an error, STOP. Don't hammer the server. + # if response.status_code != 200: + # progress.console.log(f"🛑 Error {response.status_code} for {name}. Aborting to avoid IP ban.") + # break + # + # # Validate that we actually got a TLE (should start with name or '1 ') + # data = response.text.strip() + # if "1 " in data: + # # Split the response into lines + # lines = [l.strip() for l in response.text.strip().splitlines() if l.strip()] + # + # # CelesTrak usually returns 3 lines (Name, L1, L2) + # # or 2 lines (L1, L2) if CATNR is used. + # # We only care about the last two lines (the TLE data) + # if len(lines) >= 2 and lines[-2].startswith("1 "): + # tle_l1 = lines[-2] + # tle_l2 = lines[-1] + # + # # We MANUALLY prepend our clean name + # merged_tle += f"{name}\n{tle_l1}\n{tle_l2}\n" + # progress.advance(total_task) + # progress.console.log(f"[green]✅ Successfully fetched TLE for {name}") + # + # else: + # progress.console.log(f"[yellow]⚠️ Received invalid data for {name}.") + # + # except Exception as e: + # progress.console.log(f"[red]❌ Network error on {name}: {e}") + # break + # + # # THE "GOOD CITIZEN" DELAY: + # # CelesTrak specifically asks for breaks between requests. + # # Advance the bar and update description for the "Good Citizen" sleep + # + # if catnr != list(targets.keys())[-1]: # Don't sleep after the last target + # progress.update(total_task, + # description=f"[dim white]Respecting CelesTrak rate limits (2s)...") + # time.sleep(2.0) + # + # if not merged_tle: + # raise RuntimeError("🚨 Failed to io any TLE data. Probably IP-blocked. Should reset in two ours.\ + # Alternatively download manually 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather' to /tmp/rapida_tle.txt") + # + # return merged_tle + # + # def get_tle(self, tle_file ): + # # Pathlib handles the '/' vs '\' slash drama automatically + # cache_file = Path(tle_file) + # + # # 1. Does it exist and is it fresh? (7200 seconds = 2 hours) + # if cache_file.exists() and cache_file.stat().st_size > 0: + # age = time.time() - cache_file.stat().st_mtime + # if age < 12*3600: + # return cache_file + # + # # 2. If not, io and save (This only happens once every 2 hours) + # with open(cache_file, 'wt+') as tfile: + # tle_content = self.fetch_tle() + # tfile.write(tle_content) + # return cache_file + # + # + # + # def get_phase_for_date(self, target_date): + # """Calculates exact phase using the continuous spacecraft clock and physical drift.""" + # if isinstance(target_date, datetime): + # target_date = target_date.date() + # + # target_midnight = datetime.combine(target_date, dtime(0, 0, 0)) + # ref_midnight = datetime.combine(self.cfg["ref"], dtime(0, 0, 0)) + # delta_seconds = (target_midnight - ref_midnight).total_seconds() + # + # # FIXED MATH: Apply the daily drift rate to the exact seconds elapsed + # drift_per_second = self.cfg["drift"] / 86400.0 + # accumulated_drift = delta_seconds * drift_per_second + # + # # Add the accumulated drift, NOT the raw delta_seconds + # predicted = (self.cfg["phase"] + accumulated_drift) % self.GRANULE_DUR + # + # return predicted - if not merged_tle: - raise RuntimeError("🚨 Failed to io any TLE data. Probably IP-blocked. Should reset in two ours.\ - Alternatively download manually 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather' to /tmp/rapida_tle.txt") - return merged_tle + def get_orbital(self, target_date:date=None): + tle_text = self.fetch_cached_tle(target_date=target_date) - def get_tle(self, tle_file ): - # Pathlib handles the '/' vs '\' slash drama automatically - cache_file = Path(tle_file) + l1, l2 = (l.strip() for l in tle_text.strip().splitlines() if l.strip()) - # 1. Does it exist and is it fresh? (7200 seconds = 2 hours) - if cache_file.exists() and cache_file.stat().st_size > 0: - age = time.time() - cache_file.stat().st_mtime - if age < 12*3600: - return cache_file + return Orbital(satellite=self.satellite, line1=l1, line2=l2) - # 2. If not, io and save (This only happens once every 2 hours) - with open(cache_file, 'wt+') as tfile: - tle_content = self.fetch_tle() - tfile.write(tle_content) - return cache_file - def get_phase_for_date_1(self, target_date): - """Calculates the 100% offline phase for any day.""" - days_delta = (target_date.date() - self.cfg["ref"]).days - # Predicted Phase = Initial + (Drift * Days) - # Modulo solves the 'Midnight Hiccup' wrap-around automatically - predicted = (self.cfg["phase"] + (days_delta * self.cfg["drift"])) % self.GRANULE_DUR - return predicted + def get_phase_for_date_new(self, target_date:datetime.date=None): + orb = self.get_orbital(target_date=target_date) + t_epoch = orb.orbit_elements.epoch + # FIXED MATH: Force pyorbital's numpy.datetime64 into a standard Python datetime + if isinstance(t_epoch, np.datetime64): + # .item() cleanly converts a scalar numpy datetime back to standard python datetime + t_epoch = t_epoch.astype('datetime64[us]').item() + stores = VIIRS_STORES[self.satellite] + first_file_path = None + for source in SOURCE_NAMES: - def get_phase_for_date_2(self, target_date): - """Calculates exact phase using the continuous spacecraft clock.""" + store = stores[source] + product = PRODUCTS['CM'] + date_path = target_date.strftime('/%Y/%m/%d/') + prefix = f"{product}{date_path}" + stream = obstore.list(store, prefix=prefix) - # 1. Handle both 'date' and 'datetime' inputs safely - if isinstance(target_date, datetime): - target_date = target_date.date() - # 2. Anchor both dates to Midnight UTC - target_midnight = datetime.combine(target_date, dtime(0, 0, 0)) - ref_midnight = datetime.combine(self.cfg["ref"], dtime(0, 0, 0)) + for chunk in stream: + for meta in chunk: + if meta["path"].endswith('.nc'): + first_file_path = meta["path"] + break + if first_file_path: + break + if not first_file_path: # move to another source - # 3. Get exact elapsed seconds (can be negative if target is before ref) - delta_seconds = (target_midnight - ref_midnight).total_seconds() + continue + if first_file_path is None: - # 4. Predict phase using exact modulo arithmetic - # Python's modulo perfectly handles negative time shifts - predicted = (self.cfg["phase"] + delta_seconds) % self.GRANULE_DUR + logger.info(f'No VIIRS operational data was detected for {self.satellite} {target_date.date()} in {" or ".join(SOURCE_NAMES)}. ' + f'Please check manually at https://noaa-nesdis-{self.satellite.lower()}-pds.s3.amazonaws.com/index.html#VIIRS-JRR-CloudMask/ ') + return + _, filename = os.path.split(first_file_path) - return predicted + m = PRODUCTS_RE['CM'].match(filename) + if m: + start_time = parse_noaa_timestamp(m.groupdict()['start']) - def get_phase_for_date(self, target_date): - """Calculates exact phase using the continuous spacecraft clock and physical drift.""" - if isinstance(target_date, datetime): - target_date = target_date.date() + # 5. Calculate physical phase (Delta % Granule Duration) - target_midnight = datetime.combine(target_date, dtime(0, 0, 0)) - ref_midnight = datetime.combine(self.cfg["ref"], dtime(0, 0, 0)) - delta_seconds = (target_midnight - ref_midnight).total_seconds() + delta = (start_time - t_epoch).total_seconds() - # FIXED MATH: Apply the daily drift rate to the exact seconds elapsed - drift_per_second = self.cfg["drift"] / 86400.0 - accumulated_drift = delta_seconds * drift_per_second + return delta % self.GRANULE_DUR - # Add the accumulated drift, NOT the raw delta_seconds - predicted = (self.cfg["phase"] + accumulated_drift) % self.GRANULE_DUR - return predicted def decompose_bbox(self, bbox:Iterable[float]=None): @@ -385,9 +445,30 @@ def decompose_bbox(self, bbox:Iterable[float]=None): return midlon, midlat, maxlat + # def pass2granule(self, p:DescendingPass=None, midlon:float=None, midlat:float=None, elevation:float=None ): + # orb = self.get_orbital() + # phase = self.get_phase_for_date(p.target_date) + # sat_lon, _, _ = self.orb.get_lonlatalt(p.max_elev_time) + # deg_offset = abs(midlon - sat_lon) + # # Physical distance in km at this latitude + # offset_km = int(deg_offset * 111.32 * math.cos(math.radians(midlat))) + # # Anchor to Midnight UTC of the target day + # t_midnight = datetime.combine(p.target_date.date(), dtime(0, 0, 0)) + # delta_seconds = (p.max_elev_time - t_midnight).total_seconds() + # + # # 2. Pulse-Sync Math + # pulse_index = math.floor((delta_seconds - phase) / self.GRANULE_DUR) + # start_time = t_midnight + timedelta(seconds=(pulse_index * self.GRANULE_DUR) + phase) + # + # + # return Granule(sat=self.satellite,start_time=start_time,offset=offset_km, elevation=elevation) + def pass2granule(self, p:DescendingPass=None, midlon:float=None, midlat:float=None, elevation:float=None ): - phase = self.get_phase_for_date(p.target_date) - sat_lon, _, _ = self.orb.get_lonlatalt(p.max_elev_time) + phase = self.get_phase_for_date_new(target_date=p.target_date) + if phase is None: + return + orb = self.get_orbital(p.target_date) + sat_lon, _, _ = orb.get_lonlatalt(p.max_elev_time) deg_offset = abs(midlon - sat_lon) # Physical distance in km at this latitude offset_km = int(deg_offset * 111.32 * math.cos(math.radians(midlat))) @@ -403,7 +484,7 @@ def pass2granule(self, p:DescendingPass=None, midlon:float=None, midlat:float=No return Granule(sat=self.satellite,start_time=start_time,offset=offset_km, elevation=elevation) def night_passes(self, bbox:Iterable[float]=None, nominal_date:date=None): - + orb = self.get_orbital(target_date=nominal_date) midlon, midlat, northlat = self.decompose_bbox(bbox=bbox) # 1. NIGHT DURATION (Use Mid-Lat for 'Average' Night) @@ -419,14 +500,14 @@ def night_passes(self, bbox:Iterable[float]=None, nominal_date:date=None): # 3. THE TRIGGER (Use North-Lat to find when the satellite ENTERS the box) search_start = utc_anchor - timedelta(hours=search_duration_hrs / 2) - night_passes = self.orb.get_next_passes(search_start, search_duration_hrs, midlon, midlat, 0) # northlat??? + night_passes = orb.get_next_passes(search_start, search_duration_hrs, midlon, midlat, 0) # northlat??? logger.debug(f'{self.satellite} passes {len(night_passes)} time(s) over {list(bbox)} on the night of {nominal_date:%y-%m-%d}') passes = [] for _pass_ in night_passes: rise_time, fall_time, max_elev_time = _pass_ # Direction Check - pos_start = self.orb.get_lonlatalt(rise_time) - pos_end = self.orb.get_lonlatalt(fall_time) + pos_start = orb.get_lonlatalt(rise_time) + pos_end = orb.get_lonlatalt(fall_time) if not pos_end[1] < pos_start[1]: # Descending logger.debug(f'Skipping ascending pass {_pass_}') @@ -442,18 +523,21 @@ async def night_granules_async(self, bbox:Iterable[float]=None, nominal_date:dat midlon, midlat, northlat = self.decompose_bbox(bbox=bbox) passes = self.night_passes(nominal_date=nominal_date, bbox=bbox) - + orb = self.get_orbital(target_date=nominal_date) selected_granules = {} granules = [] + for p in passes: - look = self.orb.get_observer_look(p.max_elev_time, midlon, midlat, 0) + look = orb.get_observer_look(p.max_elev_time, midlon, midlat, 0) elevation = look[1] if elevation < self.MIN_ELEVATION_ANGLE: logger.info(f'Skipping {p} because of low elevation angle {elevation:0f}') continue - granule = self.pass2granule(p=p,midlon=midlon, midlat=midlat, elevation=elevation, ) - granules.append(granule) + granule = self.pass2granule(p=p,midlon=midlon, midlat=midlat, elevation=elevation,) + if granule: + granules.append(granule) # return noe in case no data is available with hyper-scalers + geom_granules = await granules2files( @@ -490,89 +574,7 @@ async def night_granules_async(self, bbox:Iterable[float]=None, nominal_date:dat return selected_granules - def night_granules(self, bbox:Iterable[float]=None, target_date:date=None, cmask:bool=False, progress=None ): - midlon, midlat, northlat = self.decompose_bbox(bbox=bbox) - passes = self.night_passes(nominal_date=target_date, bbox=bbox) - - selected_granules = {} - granules = [] - for p in passes: - look = self.orb.get_observer_look(p.max_elev_time, midlon, midlat, 0) - elevation = look[1] - if elevation < self.MIN_ELEVATION_ANGLE: - logger.debug(f'Skipping {p} because of low elevation angle {elevation:0f}') - continue - granule = self.pass2granule(p=p,midlon=midlon, midlat=midlat, elevation=elevation, ) - granules.append(granule) - - - geom_granules = asyncio.run(granules2files( - granules=granules,satellite=self.satellite, bbox=bbox, progress=progress - )) - - for current_granule, found in geom_granules.items(): - if not found: - continue - # Safely get the first source/entry - (source, entries), = found.items() - file_path, _ = entries[0] - - _, file_name = os.path.split(file_path) - if f's{current_granule.timestamp}' not in file_name: - m = PRODUCTS_RE['CM'].match(file_name) - if m: - start_time = parse_noaa_timestamp(m.groupdict()['start']) - old_timestamp = current_granule.timestamp - current_granule.start_time = start_time - logger.debug(f'Replacing granule {old_timestamp} with {current_granule.timestamp}') - - if cmask: - # Use the unique URL as the key (Always unique) - url = public_url(file_path=file_path, satellite=self.satellite, source=source) - selected_granules[url] = current_granule - else: - # Use the unique file_path as the key to prevent SNPP/N20/N21 overwrites - selected_granules[file_path] = current_granule - - - return selected_granules - - - - - -def search_granules(satellites:Optional[Iterable[str]]=None, - target_date:date=None, bbox:Iterable[float] = None, - cmask:bool=False, progress=None): - - satellite_names = list(VIIRSNavigator.SAT_CONFIGS.keys()) - assert isinstance(target_date, date), f'invalid target date {target_date}' - satellites = satellites or satellite_names - selected_granules = [] - found_granules = {} - for sat in satellites: - logger.debug(f'Locating imagery (data granules) for {sat} satellite') - nav = VIIRSNavigator(satellite=sat) - sat_granules = nav.night_granules(bbox=bbox, target_date=target_date, cmask=cmask, progress=progress) - found_granules.update(sat_granules) - if cmask: - cloud_coverage_results = cloud_coverage_batch(urls=list(found_granules.keys()), bbox=bbox, progress=progress) - - for cm_url, g in found_granules.items(): - cloud_cover = cloud_coverage_results[cm_url] - if cloud_cover is None:cloud_cover = 'Not Available' - if isinstance(cloud_cover, Exception): - continue - g.cloud_cover = cloud_cover - g.url = os.path.split(cm_url)[-1] - selected_granules.append(g) - selected_granules.sort(key=lambda g: g.rank, reverse=True) - return selected_granules - else: - selected_granules = list(found_granules.values()) - selected_granules.sort(key=lambda g: g.rank, reverse=True) - return selected_granules async def async_search_granules( satellites:Optional[Iterable[str]]=None, nominal_date:date=None, bbox:Iterable[float] = None, @@ -588,7 +590,7 @@ async def async_search_granules( :return: """ - satellite_names = list(VIIRSNavigator.SAT_CONFIGS.keys()) + satellite_names = list(VIIRSNavigator.SATELLITES.keys()) assert isinstance(nominal_date, date), f'invalid target date {nominal_date}' satellites = satellites or satellite_names selected_granules = [] @@ -637,47 +639,71 @@ async def async_search_granules( return selected_granules -# if __name__ == '__main__': -# import asyncio -# logging.basicConfig() -# logger = logging.getLogger() -# -# logger.setLevel(logging.INFO) -# logging.getLogger('httpx').setLevel(logging.WARNING) -# logger.name = 'ntlcli' -# -# # --- Usage Example --- -# my_lat, my_lon = 49.75, 16.5 -# target_date = datetime(2026, 4, 2) -# czbbox = 14.0, 48.5, 19.0, 51.0 -# -# bboxes = [ -# [51.3337,35.6443,51.4443,35.7341], -# [48.2393,30.2947,48.3433,30.3845], -# [48.1104,30.3926,48.2146,30.4824], -# [51.6147,32.6097,51.7213,32.6995], -# [48.3468,32.3384,48.4532,32.4282], -# [48.618,31.2734,48.7232,31.3632], -# [46.2371,38.0324,46.3513,38.1222], -# [47.0106,34.2693,47.1194,34.3591], -# [52.532,29.5469,52.6354,29.6367], -# [50.8218,34.5952,50.931,34.685] -# ] -# names = 'Tehran,Abadan,Khorramshahr,Isfahan,Dezful,Ahvaz,Tabriz,Kermanshah,Shiraz,Qom' -# names= names.split(',') -# data = list(zip(names, bboxes)) -# -# for sat in VIIRSNavigator.SATELLITES: -# n = VIIRSNavigator(satellite=sat) -# # passes = n.night_passes(bbox=bboxes[-1], target_date=target_date, elevation_filter=False) -# # for p, g in passes.items(): -# # print(p, g) -# -# -# bp, bg = n.best_pass(bbox=bboxes[-1], target_date=target_date, avoid_clouds=False) -# print(f'Best pass: {bp} {bg}') -# -# +if __name__ == '__main__': + import asyncio + from datetime import datetime + # import os + # os.environ['UV_ENV_FILE'] = '/home/work/py/geo-cb-surge/.env' + # from ntl.io.operational import download + # from ntl.search.cmr import fetch + # from ntl.utils import vis + from rich.logging import RichHandler + + logging.basicConfig( + level=logging.DEBUG, # Or whatever level you use + format="%(message)s", # RichHandler handles the timestamps and formatting natively + datefmt="[%X]", + handlers=[RichHandler(rich_tracebacks=True, markup=True)] + ) + logger = logging.getLogger() + logger.setLevel(logging.INFO) + logging.getLogger('httpx').setLevel(logging.WARNING) + logger.name = 'ntloper' + events = { + 'Tehran': ('28-02-2026', (50.8, 35.3, 51.9, 36)), # bombing + 'Abuja': ('23-01-2026', (7, 8.5, 7.8, 9.4)), # grid failure + 'Dominican Rep': ('23-02-2026', (-72.00, 17.50, -68.30, 20.00)), # national grid failure + 'Kharkiv/Dnipro': ('26-01-2026', (34.4, 48.2, 38.3, 50.6)), # Kharkiv/Dnipro grid attacks + 'Porto Rico': ('26-09-2017', (-67.8, 17.6, -65.2, 18.6)), + 'Bahia Blanca': ('18-12-2023', (-62.4, -38.8, -62.2, -38.6)) + } + + site = 'Abuja' + datestr, bbox = events[site] + event_date = datetime.strptime(datestr, '%d-%m-%Y') + target_date = event_date + timedelta(days=1) + + # resolution = 750 + # + # dst_dir = '/tmp' + # files = [e for e in os.scandir(dst_dir) if e.name.endswith('.h5') or e.name.endswith('.nc')] + # print(files) + # cmask_file_path = None + # for e in files: + # if e.name.startswith('SVDNB'): sdr_file_path = e.path + # if e.name.startswith('GDNBO'): geolocation_file_path = e.path + # if e.name.startswith('JRR-Cloud'): cmask_file_path = e.path + # if '46A3' in e.name: baseline_file_path = e.path + # if not cmask_file_path: + with Progress(disable=False, transient=False) as progress: + + for s in VIIRSNavigator.SATELLITES: + n = VIIRSNavigator(satellite=s) + print(n) + phase = n.get_phase_for_date(target_date) + new_phase = n.get_phase_for_date_new(target_date=target_date) + print(s, phase, new_phase) + break + + # granules = asyncio.run(async_search_granules( satellites=['SNPP'], + # nominal_date=target_date, bbox=bbox, cmask=True, progress=progress + # )) + + # for g in granules: + # print(g) + + + From c324ef46f74244ef4b52b23b6786428def42b0e3 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Wed, 10 Jun 2026 17:59:18 +0200 Subject: [PATCH 17/20] nasa outage final form --- rapida/cli/ntl.py | 10 +-- rapida/ntl/fetch.py | 8 +- rapida/ntl/noaa/io.py | 5 +- rapida/ntl/noaa/search.py | 2 +- rapida/ntl/outage.py | 144 +++++++++++++++++++++++++++++------- rapida/ntl/utils.py | 149 +++++++++++++++++++++++++++++++++++++- 6 files changed, 278 insertions(+), 40 deletions(-) diff --git a/rapida/cli/ntl.py b/rapida/cli/ntl.py index c2d49879..0652c173 100644 --- a/rapida/cli/ntl.py +++ b/rapida/cli/ntl.py @@ -497,15 +497,15 @@ async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None ) -@click.option('-ot', "--outage-drop-threshold", +@click.option('-ot', "--percentage_drop", type=int, - default=80, + default=50, required=False, help="Specify the outage drop threshold that wil determine the spatial structure of an outage event, " ) @click.option( - '--cmask', '-cm', "cmask", + '--cmask', '-cm', "mask_clouds", is_flag=True, help=( "Enable strict Cloud Masking (ignores pixels with NASA quality flags of 3). " @@ -526,11 +526,11 @@ async def fetch(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None @click.pass_context async def detect(ctx, bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - cmask:bool=True, dst_dir:str=None, outage_drop_threshold:int=None, display:bool=False): + mask_clouds:bool=True, dst_dir:str=None, percentage_drop:int=None, display:bool=False): progress = ctx.obj.get('progress') return await detect_outage( bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, dst_dir=dst_dir, - progress=progress, cmask=cmask, outage_drop_threshold=outage_drop_threshold, display=display + progress=progress, mask_clouds=mask_clouds, percentage_drop=percentage_drop, display=display ) diff --git a/rapida/ntl/fetch.py b/rapida/ntl/fetch.py index 0022ac5e..37d435d3 100644 --- a/rapida/ntl/fetch.py +++ b/rapida/ntl/fetch.py @@ -141,8 +141,12 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, logger.info(f'Successfully downloaded {len(downloaded[timestamp])} selected images ') - # Save the successful URLs - downloaded_files[selected_product] = downloaded + + if not timestamp in downloaded_files: + downloaded_files[timestamp] = {} + + + downloaded_files[timestamp].update({selected_product:downloaded[timestamp]}) # Break out of the 'routes' loop to stop searching for this processing_level break diff --git a/rapida/ntl/noaa/io.py b/rapida/ntl/noaa/io.py index 955de552..16b385ec 100644 --- a/rapida/ntl/noaa/io.py +++ b/rapida/ntl/noaa/io.py @@ -266,9 +266,6 @@ async def fetch_ntl(found_paths:dict[str, list]=None, satellite:str=None, dst_di return results - - - async def download(satellite:str=None, timestamp:str=None, source:str=None, products:Iterable[str]=PRODUCT_NAMES, dst_dir:str=None, progress=None): dt = datetime.strptime(timestamp, '%Y%m%d%H%M') @@ -387,7 +384,7 @@ def read_and_align_sdr_and_cmask(sdr_path, geo_path, cmask_path, target_area): resampled_cmask = cmask_scn.resample( target_area, resampler='nearest', - radius_of_influence=1000, + radius_of_influence=1500, fill_value=cmask_fill ) diff --git a/rapida/ntl/noaa/search.py b/rapida/ntl/noaa/search.py index a6b5dba8..a1930b29 100644 --- a/rapida/ntl/noaa/search.py +++ b/rapida/ntl/noaa/search.py @@ -70,7 +70,7 @@ def rank(self): if str(self.cloud_cover).isnumeric(): clear_sky_score = 100 - self.cloud_cover # 70% Weather, 30% Geometry - return int((self.sat_rank * 0.3) + (clear_sky_score * 0.7)) + return int((self.sat_rank * 0.4) + (clear_sky_score * 0.6)) return int(self.sat_rank) diff --git a/rapida/ntl/outage.py b/rapida/ntl/outage.py index 0214b9a0..d795444c 100644 --- a/rapida/ntl/outage.py +++ b/rapida/ntl/outage.py @@ -19,10 +19,13 @@ -async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - dst_dir:str=None, cmask:bool=True, display:bool=False, outage_drop_threshold:int=None, progress:Progress=None): - data = {} +async def detect_outage_old( + bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, + dst_dir:str=None, cmask:bool=True, outage_drop_threshold:int=None, display:bool=False, progress:Progress=None): + logger.info(f'Fetching best imagery for {deliverable} {bbox}-{nominal_date} ') + + data = {} baseline_nominal_date = nominal_date - timedelta(weeks=6) downloaded_baseline_files = await fetch(bbox=bbox, nominal_date=baseline_nominal_date, progress=progress, deliverable='baseline', dst_dir=dst_dir) @@ -57,20 +60,30 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N for timestamp, entry in downloaded_data_files.items(): a1, cm = read_and_align_sdr_and_cmask(sdr_path=entry[noaa_const.SDR],geo_path=entry[noaa_const.GEO], cmask_path=entry[noaa_const.CM], target_area=target_area) - - print(a1.shape, a3.shape, cm.shape) + baseline_std = extract_bb(image_files=baseline_image_files, sds_name='AllAngle_Composite_Snow_Free_Std', bbox=bbox, + progress=progress) - data[f'{noaa_const.SDR}_{timestamp}'] = a1 + data[f'{noaa_const.SDR}_{timestamp}'] = np.log1p(a1) data[f'{noaa_const.CM}_{timestamp}'] = cm + if cmask is True: + # Create a mask for confident clouds + is_cloudy = cm > 1 + m |= is_cloudy + a1_diff, a1z, a1_outage = utils.logdiff_outage(log_monthly_data=a3, log_daily_data=np.log1p(a1), analysis_mask=m, + percentage_drop=outage_drop_threshold) + - # if display: - # from rapida.ntl import vis - # vis.display2(data=data, title=f'Outage inputs and results for {deliverable} at {bbox} on {nominal_date.date()}') + data['a1_diff'] = a1_diff + data['a1z'] = a1z + data['a1_outage'] = a1_outage + if display: + from rapida.ntl import vis + vis.display2(data=data, title=f'Outage inputs and results for {deliverable} at {bbox} on {nominal_date.date()}') return else: # NASA, {product:(timestamp:(tiles)} @@ -89,17 +102,15 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N if level.lower() == 'a1': a1 = np.log1p(ntl_array) data[f'{product}_{timestamp}'] = a1 - qf_array = extract_bb(image_files=local_image_files, sds_name='QF_Cloud_Mask', bbox=bbox, - progress=progress).astype('u2') - # Cloud mask - # Shift right by 6 to bring bits 6 and 7 to the start, - # then bitwise AND with 3 (0b11) to isolate just those two bits. - # 0=Confident Clear 1=Probably Clear 2=Probably Cloudy 3=Confident Cloudy - cloud_confidence = (qf_array >> 6) & 0b11 - #clear = cloud_confidence == 0 - #cm = cloud_confidence >= 2 if cmask is True: + qf_array = extract_bb(image_files=local_image_files, sds_name='QF_Cloud_Mask', bbox=bbox, + progress=progress).astype('u2') + # Cloud mask + # Shift right by 6 to bring bits 6 and 7 to the start, + # then bitwise AND with 3 (0b11) to isolate just those two bits. + # 0=Confident Clear 1=Probably Clear 2=Probably Cloudy 3=Confident Cloudy + cloud_confidence = (qf_array >> 6) & 0b11 # Create a mask for confident clouds is_cloudy = cloud_confidence == 3 m |= is_cloudy @@ -107,18 +118,15 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N - a1_diff, a1_outage = utils.logdiff_outage(baseline_log_array=a3, nrt_log_array=a1, cloud_mask=m,percentage_drop=outage_drop_threshold) - a2_diff, a2_outage = utils.logdiff_outage(baseline_log_array=a3, nrt_log_array=a2, cloud_mask=m,percentage_drop=outage_drop_threshold) - # log_diff,outage_mask, grid_health, confidence = utils.nasa_outage(ntl_log=a1, ntl_baseline_log=a3, - # baseline_std=baseline_std, - # baseline_num=baseline_num, - # no_cloud=clear, - # mask=m) + a1_diff, a1z, a1_outage = utils.logdiff_outage(log_monthly_data=a3, log_daily_data=a1, analysis_mask=m, percentage_drop=outage_drop_threshold) + a2_diff, a2z, a2_outage = utils.logdiff_outage(log_monthly_data=a3, log_daily_data=a2, analysis_mask=m, percentage_drop=outage_drop_threshold) data['a2_diff'] = a2_diff data['a1_diff'] = a1_diff + data['a2_outage'] = a2_outage data['a1_outage'] = a1_outage + data['a1z'] = a1z if display: from rapida.ntl import vis @@ -126,4 +134,88 @@ async def detect_outage(bbox:tuple[numbers.Number]=None, nominal_date:datetime=N file_name = utils.get_custom_bbox_label(bbox) outage_tif_path = os.path.join(dst_dir, f'{deliverable}_{file_name}.tif') - write_outage_tif(src_arrays=data, gt=gt,dst_path=outage_tif_path) \ No newline at end of file + write_outage_tif(src_arrays=data, gt=gt,dst_path=outage_tif_path) + + +async def detect_outage( + bbox: tuple[numbers.Number] = None, nominal_date: datetime = None, deliverable: str = None, + dst_dir: str = None, mask_clouds:bool = True, percentage_drop:int = None, + display: bool = False, progress: Progress = None): + + logger.info(f'Fetching best imagery for {deliverable} {bbox}-{nominal_date} ') + + #fetch daily data, source independent + # --- 2. FETCH DAILY TARGET DATA --- + daily_results = await fetch(bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, + progress=progress, dst_dir=dst_dir) + if not daily_results: + logger.info(f'No imagery was found for {nominal_date:"%Y%m%d"} over {bbox} {deliverable.split("_")[0]}') + logger.info(f'Consider adjusting source, date or the bounding box') + return + for timestamp, product_files in daily_results.items(): + print(timestamp, product_files) + + arrays = {} + # --- 1. FETCH & PROCESS MONTHLY BASELINE --- + monthly_nominal_date = nominal_date - timedelta(weeks=6) # the search can handle but its better ot explicit + monthly_results = await fetch(bbox=bbox, nominal_date=monthly_nominal_date, + progress=progress, deliverable='baseline', dst_dir=dst_dir) + + monthly_timestamp, monthly_files = next(iter(monthly_results.items())) + monthly_product, monthly_image_files = next(iter(monthly_files.items())) + + monthly_data, gt = extract_bb(image_files=monthly_image_files, sds_name=nasa_const.SUB_DATASETS['A3'], + bbox=bbox, progress=progress, return_gt=True) + + monthly_data_label = f'{monthly_product}_{monthly_timestamp}' + log_monthly_data = np.log1p(monthly_data) + arrays[monthly_data_label] = log_monthly_data + # land water mask 0 = Land & Desert 1 = Land no Desert 2 = Inland Water 3 = Sea Water 5 = Coastal + # Create a mask for all land pixels including the coastal + land_water_background = extract_bb(image_files=monthly_image_files, sds_name='Land_Water_Mask', bbox=bbox, progress=progress) + analysis_mask = (land_water_background == 2) | (land_water_background == 3) + + + + if 'NOAA' in deliverable: + target_area = create_area_from_geotransform(gt, monthly_data.shape) + for timestamp, product_files in daily_results.items(): + daily_data, cloud_mask = read_and_align_sdr_and_cmask( + sdr_path=product_files[noaa_const.SDR], geo_path=product_files[noaa_const.GEO], + cmask_path=product_files[noaa_const.CM], target_area=target_area + ) + elif 'NASA' in deliverable: + for timestamp, product_files in daily_results.items(): + for product, local_image_files in product_files.items(): + level = product.split('_')[0][-2:] if 'NRT' in deliverable else product[-2:] + sub_dataset_name = nasa_const.SUB_DATASETS[level] + daily_data = extract_bb(image_files=local_image_files, bbox=bbox,sds_name=sub_dataset_name,progress=progress)#.astype(np.float32) + log_daily_data = np.log1p(daily_data) + daily_data_label = f'{product}_{timestamp}' + arrays[daily_data_label] = log_daily_data + + if mask_clouds: + qf_array = extract_bb(image_files=local_image_files, sds_name='QF_Cloud_Mask', + bbox=bbox, progress=progress).astype('u2') + cloud_confidence = (qf_array >> 6) & 0b11 + analysis_mask |= (cloud_confidence == 3) + arrays[f'{daily_data_label}_MASK'] = analysis_mask + log_difference, zscore, outage = utils.logdiff_outage( + log_monthly_data=log_monthly_data,log_daily_data=log_daily_data, + analysis_mask=analysis_mask,percentage_drop=percentage_drop + + ) + arrays[f'{daily_data_label}_LOGDIFF'] = log_difference + arrays[f'{daily_data_label}_ZSCORE'] = zscore + arrays[f'{daily_data_label}_OUTAGE'] = outage + + + + file_name = utils.get_custom_bbox_label(bbox) + outage_tif_path = os.path.join(dst_dir, f'{deliverable}_{file_name}.tif') + write_outage_tif(src_arrays=arrays, gt=gt, dst_path=outage_tif_path) + # --- 5. UNIFIED DISPLAY & EXPORT --- + if display: + from rapida.ntl import vis + vis.display2(data=arrays, + title=f'Outage inputs and results for {deliverable} at {bbox} on {nominal_date.date()}') diff --git a/rapida/ntl/utils.py b/rapida/ntl/utils.py index 94ed2ca6..4d633db6 100644 --- a/rapida/ntl/utils.py +++ b/rapida/ntl/utils.py @@ -48,7 +48,7 @@ def get_custom_bbox_label(bbox: tuple[float, float, float, float]) -> str: names = [] for e in [admin1, admin2, admin3]: if e not in ['', None]: - names.append(e) + names.append(e.replace(' ', '_')) # Join the admins with dashes, then attach the country code admin_str = "-".join(names) @@ -114,6 +114,7 @@ def noaa_outage( has_signal = a3_log > np.log1p(0.5) master_mask = is_land & is_clear & has_signal & ~np.isnan(a3_log) & ~np.isnan(nrt_log) + #master_mask = is_land & ~np.isnan(a3_log) & ~np.isnan(nrt_log) if not np.any(master_mask): logger.warning("No valid clear land pixels found in the target area.") @@ -306,7 +307,7 @@ def nasa_outage( return diff_display, final_outages, grid_health, confidence -def logdiff_outage( +def logdiff_outage_orig( baseline_log_array: np.ndarray, nrt_log_array: np.ndarray, cloud_mask: np.ndarray, # Boolean array: True where clouds exist @@ -351,6 +352,83 @@ def logdiff_outage( return log_diff, outage_map +from scipy.ndimage import generic_filter + + +def logdiff_outage( + log_monthly_data: np.ndarray, + log_daily_data: np.ndarray, + analysis_mask: np.ndarray, # Boolean array: True where mask wil be applied + percentage_drop: float = 50.0, + z_threshold: float = -2.5, + relative_noise_floor: float = 0.15 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Calculates outages using a hybrid approach of direct log-differencing and + localized spatial Z-scores to handle VNP46A1 noise and viewing geometry. + + Args: + log_monthly_data: Log-scaled baseline (np.log1p of radiance). + log_daily_data: Log-scaled Near Real-Time daily observation. + analysis_mask: Boolean mask where True represents invalid/cloudy pixels. + percentage_drop: Physical radiance drop threshold (e.g., 50 means a 50% drop). + z_threshold: Spatial significance threshold (number of local standard deviations). + relative_noise_floor: Minimum standard deviation limit to avoid zero-division in dark regions. + + Returns: + log_diff (np.ndarray): Continuous array of log differences for continuous severity analysis. + z_score (np.ndarray): Localized spatial Z-scores for visualization. + outage_map (np.ndarray): Clean boolean mask of confirmed crisis outages. + """ + # Proportional loss mapping + remaining_fraction = 1.0 - (percentage_drop / 100.0) + log_drop_threshold = np.log(remaining_fraction) + + # 1. Master Validity Mask + is_valid = ~np.isnan(log_daily_data) & ~np.isnan(log_monthly_data) & ~analysis_mask + + if not np.any(is_valid): + return ( + np.full_like(log_daily_data, np.nan), + np.full_like(log_daily_data, np.nan), + np.zeros_like(log_daily_data, dtype=bool) + ) + + # 2. Prevent edge-bleed during spatial smoothing + fill_val = np.nanmean(log_monthly_data[is_valid]) + base_filled = np.where(is_valid, log_monthly_data, fill_val) + nrt_filled = np.where(is_valid, log_daily_data, fill_val) + + # 3. Spatial Smoothing (Using 1-pixel radius disk equivalent / uniform 3x3) + # Using generic_filter or uniform_filter here keeps dependencies clean + from scipy.ndimage import uniform_filter + base_smooth = uniform_filter(base_filled, size=3) + nrt_smooth = uniform_filter(nrt_filled, size=3) + + # 4. Direct Log Difference (Physical Ratio Estimation) + log_diff_raw = nrt_smooth - base_smooth + + # 5. Dynamic Local Spatial Noise Floor Estimation + # Calculates standard deviation inside a 3x3 window on the smoothed baseline + local_baseline_std = generic_filter(base_smooth, np.std, size=3) + + # Convert linear relative noise floor scale to approximate log space scaling + # log_std approx std / (linear_val + 1) + baseline_linear = np.expm1(base_smooth) + log_std = np.maximum(local_baseline_std / (baseline_linear + 1.0), relative_noise_floor) + + # 6. Calculate Spatial Z-Score + z_score_raw = log_diff_raw / log_std + + # 7. Mask out invalid/cloudy/water pixels across arrays + log_diff = np.where(is_valid, log_diff_raw, np.nan) + z_score = np.where(is_valid, z_score_raw, np.nan) + + # 8. Dual-Engine Outage Mapping + # Triggers only if it passes BOTH the absolute physical drop AND the local spatial statistical variance + outage_map = (log_diff < log_drop_threshold) & (z_score < z_threshold) + + return log_diff, z_score, outage_map def rigorous_ssim(img1, img2, data_range=12.0) -> np.ndarray: """ @@ -506,3 +584,70 @@ def spatial_filter(outage_map, min_size=2): return mask_size[labeled_array] + + +def wavelet_continuous_persistence( + monthly: np.ndarray, + daily: np.ndarray, + lw_bg: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + import pywt + # 1. Build the Standard NumPy Exclusion Mask + master_mask = ~np.isin(lw_bg, [0, 1, 5]) | np.isnan(monthly) | np.isnan(daily) + + # 2. Condition and stabilize inputs + noise_floor = 0.2 + monthly_clean = np.where(~master_mask, np.maximum(monthly, 0.0), 0.0) + daily_clean = np.where(~master_mask, np.maximum(daily, 0.0), 0.0) + + max_level = pywt.swt_max_level(min(daily.shape)) + target_level = min(5, max_level) + + # This will hold the continuous sum of all loss fractions across scales + cumulative_loss_map = np.zeros_like(daily, dtype=np.float32) + + # 3. Execute the Multi-Scale Integration + for l in range(1, target_level + 1): + try: + swt_base = pywt.swt2(monthly_clean, 'db4', level=l) + swt_nrt = pywt.swt2(daily_clean, 'db4', level=l) + + cA_base_full = swt_base[0][0] + cA_nrt_full = swt_nrt[0][0] + except ValueError: + break + + lit_mask = cA_base_full > 1.0 + + # Calculate continuous physical fraction (0.0 to 1.0+) + loss_fraction = np.divide( + (cA_base_full - cA_nrt_full), + (cA_base_full + noise_floor), + out=np.zeros_like(cA_base_full), + where=lit_mask + ) + + # Compound the raw physical loss directly! + cumulative_loss_map += np.where(lit_mask & ~master_mask, loss_fraction, 0) + + # 4. Final Cleanup + # We now have a continuous spectrum where high values definitively equal deep structural collapses + continuous_spectrum = np.where(~master_mask, cumulative_loss_map, 0.0) + # ========================================================================= + # THE SELF-ADAPTING GATEKEEPER (Dynamic Statistical Threshold) + # ========================================================================= + # Isolate valid pixels that experienced at least *some* baseline drop + valid_signals = continuous_spectrum[~master_mask & (continuous_spectrum > 0.01)] + + if len(valid_signals) > 0: + # Calculate the ambient structural noise floor of THIS specific image + scene_mean = np.mean(valid_signals) + scene_std = np.std(valid_signals) + + # A blackout is defined mathematically as a 3-sigma anomaly (99.7% confidence) + dynamic_threshold = scene_mean + (1.0 * scene_std) + else: + dynamic_threshold = 0.0 + print(dynamic_threshold) + outage_map = (continuous_spectrum > dynamic_threshold) & ~master_mask + return continuous_spectrum, outage_map, master_mask \ No newline at end of file From 992b58b2ff1a4fa0ebff6f5ce07464edf149d435 Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Thu, 11 Jun 2026 18:22:30 +0200 Subject: [PATCH 18/20] noaa outage final form --- rapida/admin/util.py | 6 +- rapida/ntl/nasa/io.py | 19 +++- rapida/ntl/noaa/cmask.py | 3 +- rapida/ntl/noaa/io.py | 11 +-- rapida/ntl/noaa/search.py | 6 +- rapida/ntl/outage.py | 203 ++++++++++++++------------------------ rapida/ntl/utils.py | 60 +++++++++-- 7 files changed, 151 insertions(+), 157 deletions(-) diff --git a/rapida/admin/util.py b/rapida/admin/util.py index c4e8ffa2..2792c2b3 100644 --- a/rapida/admin/util.py +++ b/rapida/admin/util.py @@ -13,7 +13,7 @@ def is_int(val): else: return False -def bbox_to_geojson_polygon(west, south, east, north): +def bbox_to_geojson_polygon(west, south, east, north, as_string=False): """ Converts a bounding box to a GeoJSON Polygon geometry. @@ -44,7 +44,9 @@ def bbox_to_geojson_polygon(west, south, east, north): "coordinates": coordinates } } - + if as_string: + import json + return json.dumps(geojson, indent=2) return geojson diff --git a/rapida/ntl/nasa/io.py b/rapida/ntl/nasa/io.py index a3a17a54..23f5b499 100644 --- a/rapida/ntl/nasa/io.py +++ b/rapida/ntl/nasa/io.py @@ -1,6 +1,6 @@ from pathlib import Path import httpx -from osgeo import gdal +from osgeo import gdal, gdal_array import fsspec import h5py import secrets @@ -83,14 +83,19 @@ def get_val(key): height, width = target_ds.shape[-2:] + ds_dtype = gdal_array.NumericTypeCodeToGDALTypeCode(target_ds.dtype) + ds_dtype_name = gdal.GetDataTypeName(ds_dtype) + for k, v in target_ds.attrs.items(): if isinstance(v, bytes): v = v.decode('utf-8') elif hasattr(v, '__iter__') and not isinstance(v, str): v = v[0].decode('utf-8') if isinstance(v[0], bytes) else str(v[0]) global_metadata[k] = str(v) + raw_fill = target_ds.attrs.get('_FillValue') + nodata_value = target_ds.dtype.type(raw_fill).item() + - nodata_value = float(global_metadata.get('_FillValue', -999.9)) # Calculate Geotransform for THIS specific tile px_w = (east - west) / width @@ -111,7 +116,7 @@ def get_val(key): tile_xml = f""" EPSG:4326 {west}, {px_w}, 0.0, {north}, 0.0, {px_h} - + {nodata_value} {conn_str} @@ -167,10 +172,11 @@ def extract_bb(image_files: list[str] = None, sds_name:str=None, return_gt=False translate_options = dict( format="MEM", outputSRS='EPSG:4326', + ) if progress: - task = progress.add_task(f'[red]Extracting data using GDAL') + task = progress.add_task(f'[red]Extracting bbox {bbox} using GDAL') callback_dict = dict( callback=gdal_callback, callback_data=(progress, task, None) @@ -182,7 +188,10 @@ def extract_bb(image_files: list[str] = None, sds_name:str=None, return_gt=False gt = ds.GetGeoTransform() [gdal.Unlink(e) for e in to_unlink] - array = ds.GetRasterBand(1).ReadAsArray() + band = ds.GetRasterBand(1) + array = band.ReadAsArray() + + ds = None if return_gt: return array, gt diff --git a/rapida/ntl/noaa/cmask.py b/rapida/ntl/noaa/cmask.py index ef8a838e..2889105b 100644 --- a/rapida/ntl/noaa/cmask.py +++ b/rapida/ntl/noaa/cmask.py @@ -138,7 +138,8 @@ def bbox_in_hdf(hdf_url: str, bbox: Iterable[float]): return False, 0 intersection_poly = working_bbox.intersection(working_bounds) perc_intersection = round(intersection_poly.area/working_bbox.area * 100) - + # with open(os.path.join('/tmp', filename.replace('.nc', '.geojson')), "w") as f: + # f.write(to_geojson(bounds_poly)) return True, perc_intersection diff --git a/rapida/ntl/noaa/io.py b/rapida/ntl/noaa/io.py index 16b385ec..3462e9a0 100644 --- a/rapida/ntl/noaa/io.py +++ b/rapida/ntl/noaa/io.py @@ -346,7 +346,6 @@ def read_and_align_sdr(sdr_path, geo_path, target_area): - def read_and_align_sdr_and_cmask(sdr_path, geo_path, cmask_path, target_area): """ Loads, crops, and resamples VIIRS SDR (Radiance) and EDR (Cloud Mask) @@ -380,24 +379,18 @@ def read_and_align_sdr_and_cmask(sdr_path, geo_path, cmask_path, target_area): fill_value=dnb_fill ) + # 6. Resample Cloud Mask resampled_cmask = cmask_scn.resample( target_area, resampler='nearest', - radius_of_influence=1500, + radius_of_influence=1000, fill_value=cmask_fill ) # 7. Extract raw NumPy arrays and mask dnb_array = resampled_sdr['DNB'].values - # # Safely convert the dynamic fill value to np.nan for math operations - # if np.isnan(dnb_fill): - # # Data might already be masked as NaN by Satpy during load - # dnb_array = np.where(np.isnan(dnb_array), np.nan, dnb_array) - # else: - # # Mask the explicit file-defined integer/float fill value - # dnb_array = np.where(dnb_array == dnb_fill, np.nan, dnb_array) dnb_scaled = dnb_array * 1e5 # Scale to match Black Marble nW/cm2/sr cmask_array = resampled_cmask['CloudMask'].values diff --git a/rapida/ntl/noaa/search.py b/rapida/ntl/noaa/search.py index a1930b29..8e0640fa 100644 --- a/rapida/ntl/noaa/search.py +++ b/rapida/ntl/noaa/search.py @@ -415,19 +415,15 @@ def get_phase_for_date_new(self, target_date:datetime.date=None): continue if first_file_path is None: - logger.info(f'No VIIRS operational data was detected for {self.satellite} {target_date.date()} in {" or ".join(SOURCE_NAMES)}. ' + logger.info(f'No VIIRS operational data was found for {self.satellite} {target_date.date()} in {" or ".join(SOURCE_NAMES)}. ' f'Please check manually at https://noaa-nesdis-{self.satellite.lower()}-pds.s3.amazonaws.com/index.html#VIIRS-JRR-CloudMask/ ') return _, filename = os.path.split(first_file_path) - m = PRODUCTS_RE['CM'].match(filename) if m: start_time = parse_noaa_timestamp(m.groupdict()['start']) - # 5. Calculate physical phase (Delta % Granule Duration) - delta = (start_time - t_epoch).total_seconds() - return delta % self.GRANULE_DUR diff --git a/rapida/ntl/outage.py b/rapida/ntl/outage.py index d795444c..09d332fb 100644 --- a/rapida/ntl/outage.py +++ b/rapida/ntl/outage.py @@ -12,138 +12,19 @@ import numpy as np import os from rapida.ntl import utils - +from rapida.admin.util import bbox_to_geojson_polygon logger = logging.getLogger('rapida') - - -async def detect_outage_old( - bbox:tuple[numbers.Number]=None, nominal_date:datetime=None, deliverable:str=None, - dst_dir:str=None, cmask:bool=True, outage_drop_threshold:int=None, display:bool=False, progress:Progress=None): - - logger.info(f'Fetching best imagery for {deliverable} {bbox}-{nominal_date} ') - - data = {} - baseline_nominal_date = nominal_date - timedelta(weeks=6) - downloaded_baseline_files = await fetch(bbox=bbox, nominal_date=baseline_nominal_date, - progress=progress, deliverable='baseline', dst_dir=dst_dir) - baseline_product, baseline_files = next(iter(downloaded_baseline_files.items())) - baseline_timestamp, baseline_image_files = next(iter(baseline_files.items())) - baseline_array, gt = extract_bb(image_files=baseline_image_files, sds_name=nasa_const.SUB_DATASETS['A3'], bbox=bbox, - progress=progress, return_gt=True) - - a3 = np.log1p(baseline_array) - - data[f'{baseline_product}_{baseline_timestamp}'] = a3 - # baseline_std = extract_bb(image_files=baseline_image_files, sds_name='AllAngle_Composite_Snow_Free_Std', bbox=bbox, - # progress=progress) - # baseline_num = extract_bb(image_files=baseline_image_files, sds_name='AllAngle_Composite_Snow_Free_Num', bbox=bbox, - # progress=progress) - lw_bg = extract_bb(image_files=baseline_image_files, sds_name='Land_Water_Mask', bbox=bbox, - progress=progress) - # land water mask 0 = Land & Desert 1 = Land no Desert 2 = Inland Water 3 = Sea Water 5 = Coastal - # Create a mask for all land pixels including the coastal - m = (lw_bg == 2) & (lw_bg == 3) - - data['mask'] = m - - - downloaded_data_files = await fetch(bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, progress=progress, - dst_dir=dst_dir) - if not downloaded_data_files: - return - - if 'NOAA' in deliverable: # {timestamp:{product:file_path}} - target_area = create_area_from_geotransform(gt, baseline_array.shape) - for timestamp, entry in downloaded_data_files.items(): - a1, cm = read_and_align_sdr_and_cmask(sdr_path=entry[noaa_const.SDR],geo_path=entry[noaa_const.GEO], - cmask_path=entry[noaa_const.CM], target_area=target_area) - baseline_std = extract_bb(image_files=baseline_image_files, sds_name='AllAngle_Composite_Snow_Free_Std', bbox=bbox, - progress=progress) - - - - data[f'{noaa_const.SDR}_{timestamp}'] = np.log1p(a1) - data[f'{noaa_const.CM}_{timestamp}'] = cm - - if cmask is True: - # Create a mask for confident clouds - is_cloudy = cm > 1 - m |= is_cloudy - a1_diff, a1z, a1_outage = utils.logdiff_outage(log_monthly_data=a3, log_daily_data=np.log1p(a1), analysis_mask=m, - percentage_drop=outage_drop_threshold) - - - data['a1_diff'] = a1_diff - data['a1z'] = a1z - data['a1_outage'] = a1_outage - - - if display: - from rapida.ntl import vis - vis.display2(data=data, title=f'Outage inputs and results for {deliverable} at {bbox} on {nominal_date.date()}') - - return - else: # NASA, {product:(timestamp:(tiles)} - for product, results in downloaded_data_files.items(): - level = product[-2:] - if 'NRT' in deliverable: - level = product.split('_')[0][-2:] - for timestamp, local_image_files in results.items(): - - ntl_array = extract_bb(image_files=local_image_files,bbox=bbox,sds_name=nasa_const.SUB_DATASETS[level], - progress=progress) - ntl_array = ntl_array.astype(np.float32) - if level.lower() == 'a2': - a2 = np.log1p(ntl_array) - data[f'{product}_{timestamp}'] = a2 - if level.lower() == 'a1': - a1 = np.log1p(ntl_array) - data[f'{product}_{timestamp}'] = a1 - - if cmask is True: - qf_array = extract_bb(image_files=local_image_files, sds_name='QF_Cloud_Mask', bbox=bbox, - progress=progress).astype('u2') - # Cloud mask - # Shift right by 6 to bring bits 6 and 7 to the start, - # then bitwise AND with 3 (0b11) to isolate just those two bits. - # 0=Confident Clear 1=Probably Clear 2=Probably Cloudy 3=Confident Cloudy - cloud_confidence = (qf_array >> 6) & 0b11 - # Create a mask for confident clouds - is_cloudy = cloud_confidence == 3 - m |= is_cloudy - - - - - a1_diff, a1z, a1_outage = utils.logdiff_outage(log_monthly_data=a3, log_daily_data=a1, analysis_mask=m, percentage_drop=outage_drop_threshold) - a2_diff, a2z, a2_outage = utils.logdiff_outage(log_monthly_data=a3, log_daily_data=a2, analysis_mask=m, percentage_drop=outage_drop_threshold) - - data['a2_diff'] = a2_diff - data['a1_diff'] = a1_diff - - data['a2_outage'] = a2_outage - data['a1_outage'] = a1_outage - data['a1z'] = a1z - - if display: - from rapida.ntl import vis - vis.display2(data=data, title=f'Outage inputs and results for {bbox} on {nominal_date.date()}') - - file_name = utils.get_custom_bbox_label(bbox) - outage_tif_path = os.path.join(dst_dir, f'{deliverable}_{file_name}.tif') - write_outage_tif(src_arrays=data, gt=gt,dst_path=outage_tif_path) - - async def detect_outage( bbox: tuple[numbers.Number] = None, nominal_date: datetime = None, deliverable: str = None, dst_dir: str = None, mask_clouds:bool = True, percentage_drop:int = None, display: bool = False, progress: Progress = None): logger.info(f'Fetching best imagery for {deliverable} {bbox}-{nominal_date} ') - + # with open(os.path.join('/tmp', 'bbox.geojson'), "w") as ff: + # ff.write(bbox_to_geojson_polygon(*bbox, as_string=True)) #fetch daily data, source independent # --- 2. FETCH DAILY TARGET DATA --- daily_results = await fetch(bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, @@ -168,29 +49,94 @@ async def detect_outage( bbox=bbox, progress=progress, return_gt=True) monthly_data_label = f'{monthly_product}_{monthly_timestamp}' - log_monthly_data = np.log1p(monthly_data) + monthly_positive = monthly_data >= 0 + log_monthly_data = np.zeros_like(monthly_data) + log_monthly_data[monthly_positive] = np.log1p(monthly_data[monthly_positive]) arrays[monthly_data_label] = log_monthly_data # land water mask 0 = Land & Desert 1 = Land no Desert 2 = Inland Water 3 = Sea Water 5 = Coastal # Create a mask for all land pixels including the coastal land_water_background = extract_bb(image_files=monthly_image_files, sds_name='Land_Water_Mask', bbox=bbox, progress=progress) analysis_mask = (land_water_background == 2) | (land_water_background == 3) - - + analysis_mask |= ~monthly_positive if 'NOAA' in deliverable: target_area = create_area_from_geotransform(gt, monthly_data.shape) + + # 1. Initialize master COMPOSITE arrays + # Use NaNs so unobserved pixels remain NaN + log_diff = np.full_like(monthly_data, np.nan) + zscore = np.full_like(monthly_data, np.nan) + outage = np.zeros_like(monthly_data, dtype=bool) + + # 2. Master tracking mask (True if a pixel gets AT LEAST ONE clear observation) + valid_mask = np.zeros_like(analysis_mask, dtype=bool) + for timestamp, product_files in daily_results.items(): + daily_data, cloud_mask = read_and_align_sdr_and_cmask( sdr_path=product_files[noaa_const.SDR], geo_path=product_files[noaa_const.GEO], cmask_path=product_files[noaa_const.CM], target_area=target_area ) + + # 3. Create a STRICTLY DAILY invalid mask + # It inherits the base analysis_mask (water/bg), but does NOT inherit yesterday's clouds + daily_invalid_mask = np.isnan(daily_data) | analysis_mask + + if mask_clouds is True: + is_cloudy = cloud_mask > 1 + daily_invalid_mask |= is_cloudy + + log_daily_data = np.zeros_like(daily_data) + log_daily_data[~daily_invalid_mask] = np.log1p(daily_data[~daily_invalid_mask]) + + # Save individual daily raw data if needed + daily_data_label = f'{noaa_const.SDR}_{timestamp}' + arrays[daily_data_label] = log_daily_data + + # Run the NTL statistics engine on today's clear pixels + granule_logdiff, granule_zscore, granule_outage = utils.logdiff_outage( + log_monthly_data=log_monthly_data, log_daily_data=log_daily_data, + analysis_mask=daily_invalid_mask, percentage_drop=percentage_drop + ) + + # 4. Update the Master Composites + # We define what was valid TODAY + daily_valid = ~daily_invalid_mask + + # Isolate pixels that are valid in THIS granule AND haven't been mapped yet today + is_first_time = daily_valid & ~valid_mask + + # Lock in the data ONLY for these first-time pixels + log_diff[is_first_time] = granule_logdiff[is_first_time] + zscore[is_first_time] = granule_zscore[is_first_time] + outage[is_first_time] = granule_outage[is_first_time] + + # Update our tracking mask to protect these pixels from future overlaps + valid_mask |= daily_valid + + # Update our tracking mask to remember we saw these pixels + valid_mask |= daily_valid + + ts = f'{nominal_date:%Y%m%d}' + + # 5. Save the final merged dataset outside the loop + # Invert the valid mask so True = "We never saw this pixel clearly" + arrays[f'{ts}_MASK'] = ~valid_mask + arrays[f'{ts}_LOGDIFF'] = log_diff + arrays[f'{ts}_ZSCORE'] = zscore + arrays[f'{ts}_OUTAGE'] = outage + elif 'NASA' in deliverable: for timestamp, product_files in daily_results.items(): for product, local_image_files in product_files.items(): level = product.split('_')[0][-2:] if 'NRT' in deliverable else product[-2:] sub_dataset_name = nasa_const.SUB_DATASETS[level] - daily_data = extract_bb(image_files=local_image_files, bbox=bbox,sds_name=sub_dataset_name,progress=progress)#.astype(np.float32) + daily_data = extract_bb(image_files=local_image_files, bbox=bbox,sds_name=sub_dataset_name,progress=progress) + positive = daily_data>=0 + log_daily_data = np.zeros_like(daily_data) + log_daily_data[positive] = np.log1p(daily_data[positive]) log_daily_data = np.log1p(daily_data) + daily_data_label = f'{product}_{timestamp}' arrays[daily_data_label] = log_daily_data @@ -198,7 +144,9 @@ async def detect_outage( qf_array = extract_bb(image_files=local_image_files, sds_name='QF_Cloud_Mask', bbox=bbox, progress=progress).astype('u2') cloud_confidence = (qf_array >> 6) & 0b11 - analysis_mask |= (cloud_confidence == 3) + is_cloudy = cloud_confidence == 3 + arrays['CLOUD_MASK'] = is_cloudy + analysis_mask |= is_cloudy arrays[f'{daily_data_label}_MASK'] = analysis_mask log_difference, zscore, outage = utils.logdiff_outage( log_monthly_data=log_monthly_data,log_daily_data=log_daily_data, @@ -210,7 +158,6 @@ async def detect_outage( arrays[f'{daily_data_label}_OUTAGE'] = outage - file_name = utils.get_custom_bbox_label(bbox) outage_tif_path = os.path.join(dst_dir, f'{deliverable}_{file_name}.tif') write_outage_tif(src_arrays=arrays, gt=gt, dst_path=outage_tif_path) diff --git a/rapida/ntl/utils.py b/rapida/ntl/utils.py index 4d633db6..aec68563 100644 --- a/rapida/ntl/utils.py +++ b/rapida/ntl/utils.py @@ -3,7 +3,7 @@ import rasterio from rasterio.transform import Affine from rasterio.enums import ColorInterp -from scipy.ndimage import uniform_filter, gaussian_filter +from scipy.ndimage import uniform_filter, gaussian_filter, generic_filter import logging from scipy.ndimage import label from scipy.ndimage import convolve @@ -11,6 +11,8 @@ logger = logging.getLogger('rapida') + + def get_custom_bbox_label(bbox: tuple[float, float, float, float]) -> str: minlon, minlat, maxlon, maxlat = bbox @@ -31,7 +33,7 @@ def get_custom_bbox_label(bbox: tuple[float, float, float, float]) -> str: zoom = 12 # City/Town/Village level (Small localized bboxes) # 3. Add the zoom parameter to the URL - url = f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&zoom={zoom}&format=json" + url = f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&zoom={zoom}&format=json&accept-language=en" headers = {'User-Agent': 'UNDP/RAPIDA'} @@ -76,6 +78,50 @@ def disk_filter(image: np.ndarray, radius: int = 1) -> np.ndarray: # 3. Convolve the image with the circular kernel return convolve(image, kernel, mode='reflect') +def get_haze_attenuation_mask( + log_diff_raw: np.ndarray, + radius: int = 7, # # Radius 7 creates a 15x15 diameter circle + max_allowed_std: float = 0.35, # Max variance to be considered "uniform weather" + dimming_threshold: float = -0.5 # Minimum regional drop to trigger the check +) -> np.ndarray: + """ + Identifies unmasked clouds and haze by detecting regions + that are uniformly dimming without sharp spatial gradients. + """ + mean_of_sq = disk_filter(log_diff_raw ** 2, radius=radius) + sq_of_mean = disk_filter(log_diff_raw, radius=radius) ** 2 + + # Prevent negative zeros from floating point precision issues + local_variance = np.maximum(mean_of_sq - sq_of_mean, 0.0) + local_std = np.sqrt(local_variance) + + # 2. Regional mean difference + local_mean_diff = disk_filter(log_diff_raw, radius=radius) + + # 3. Haze classification logic + # It must be dropping regionally AND the drop must be extremely uniform + is_dropping = local_mean_diff < dimming_threshold + is_uniform = local_std < max_allowed_std + + + # Haze is true ONLY if it's dropping, uniform, AND NOT catastrophic + haze_mask = is_dropping & is_uniform + + return haze_mask + + +def sobel(image: np.ndarray) -> np.ndarray: + """ + Applies a Sobel filter using numpy arrays and scipy convolution. + """ + kernel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float64) + kernel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float64) + + gx = convolve(image, kernel_x, mode='reflect') + gy = convolve(image, kernel_y, mode='reflect') + + # Calculate hypotenuse: sqrt(gx**2 + gy**2) + return np.hypot(gx, gy) def noaa_outage( @@ -352,7 +398,6 @@ def logdiff_outage_orig( return log_diff, outage_map -from scipy.ndimage import generic_filter def logdiff_outage( @@ -360,7 +405,7 @@ def logdiff_outage( log_daily_data: np.ndarray, analysis_mask: np.ndarray, # Boolean array: True where mask wil be applied percentage_drop: float = 50.0, - z_threshold: float = -2.5, + z_threshold: float = -2, relative_noise_floor: float = 0.15 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ @@ -401,13 +446,13 @@ def logdiff_outage( # 3. Spatial Smoothing (Using 1-pixel radius disk equivalent / uniform 3x3) # Using generic_filter or uniform_filter here keeps dependencies clean - from scipy.ndimage import uniform_filter - base_smooth = uniform_filter(base_filled, size=3) - nrt_smooth = uniform_filter(nrt_filled, size=3) + base_smooth = disk_filter(base_filled, radius=2) + nrt_smooth = disk_filter(nrt_filled, radius=2) # 4. Direct Log Difference (Physical Ratio Estimation) log_diff_raw = nrt_smooth - base_smooth + # 5. Dynamic Local Spatial Noise Floor Estimation # Calculates standard deviation inside a 3x3 window on the smoothed baseline local_baseline_std = generic_filter(base_smooth, np.std, size=3) @@ -416,6 +461,7 @@ def logdiff_outage( # log_std approx std / (linear_val + 1) baseline_linear = np.expm1(base_smooth) log_std = np.maximum(local_baseline_std / (baseline_linear + 1.0), relative_noise_floor) + #log_std = np.maximum(local_baseline_std, relative_noise_floor) # 6. Calculate Spatial Z-Score z_score_raw = log_diff_raw / log_std From 5f62579acf1af418819fcd22749c36567408b19f Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Thu, 11 Jun 2026 19:09:37 +0200 Subject: [PATCH 19/20] log and small improvements --- rapida/ntl/nasa/io.py | 5 ++++- rapida/ntl/noaa/cmask.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/rapida/ntl/nasa/io.py b/rapida/ntl/nasa/io.py index 23f5b499..dedda70b 100644 --- a/rapida/ntl/nasa/io.py +++ b/rapida/ntl/nasa/io.py @@ -1,4 +1,6 @@ from pathlib import Path + +import click.exceptions import httpx from osgeo import gdal, gdal_array import fsspec @@ -294,7 +296,8 @@ async def download(timestamp: str = None, product: str = None, tile:str=None, assert timestamp not in [None, ''], f'Invalid timestamp={timestamp}' assert product not in [None, ''], f'Invalid product={product}' - + if bbox is None and tile is None and not urls: + raise click.exceptions.BadOptionUsage(message=f'Either bbox ot tile is required for download.') if not urls: if tile is None: diff --git a/rapida/ntl/noaa/cmask.py b/rapida/ntl/noaa/cmask.py index 2889105b..36bf7b75 100644 --- a/rapida/ntl/noaa/cmask.py +++ b/rapida/ntl/noaa/cmask.py @@ -78,6 +78,7 @@ def select_required_granules(sorted_granules: list, bbox: tuple, progress:Progre if uncovered.is_empty or uncovered.area < 1e-6: logger.debug(f"BBOX {bbox} is covered by first granule.") return [sorted_granules[0]] + logger.info(f'bbox {bbox} is not covered completely by best swath...going to select more for complete coverage.') progress_task = None try: if progress: From 251575a814459caf68bf495ae5075c1d7cbc284f Mon Sep 17 00:00:00 2001 From: Ioan Ferencik Date: Thu, 11 Jun 2026 21:16:44 +0200 Subject: [PATCH 20/20] add spacetrack package --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a554e2d2..8ca05f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,8 @@ dependencies = [ "matplotlib>=3.10.9", "netcdf4>=1.7.3", "h5netcdf>=1.8.1", + "spacetrack>=1.4.0" + ] [project.optional-dependencies]