From a3d8c9dc8db96fc2f09ae80235b3f3cf31782134 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 7 Feb 2025 14:28:38 -0500 Subject: [PATCH 01/16] download script for inputs, merge layers initial task --- sample_data/tva.json | 14 +- scripts/download_input_files.py | 444 +++++++++++++++++++++++++++++++ uvdat/core/tasks/merge_layers.py | 55 ++++ 3 files changed, 510 insertions(+), 3 deletions(-) create mode 100755 scripts/download_input_files.py create mode 100644 uvdat/core/tasks/merge_layers.py diff --git a/sample_data/tva.json b/sample_data/tva.json index 786dc1f..9d11019 100644 --- a/sample_data/tva.json +++ b/sample_data/tva.json @@ -62,7 +62,7 @@ }, { "type": "Context", - "name": "TVA Hydro Stations", + "name": "TVA Power Plants", "default_map_center": [ 34.8019, -86.1794 @@ -70,8 +70,8 @@ "default_map_zoom": 6, "datasets": [ { - "name": "TVA HydroStations", - "description": "TVA HydroStations", + "name": "TVA Power Plants", + "description": "TVA Power Plants", "category": "vector", "metadata": {}, "files": [ @@ -81,7 +81,15 @@ "name": "TVA Hydro Stations", "type": "geojson", "metadata": {} + }, + { + "path": "./data/tva/powerplants.zip", + "url": "https://data.kitware.com/api/v1/file/67a645d7091a514e82eec881/download", + "name": "TVA Power Plants", + "type": "zip", + "metadata": {} } + ] } ] diff --git a/scripts/download_input_files.py b/scripts/download_input_files.py new file mode 100755 index 0000000..d99555a --- /dev/null +++ b/scripts/download_input_files.py @@ -0,0 +1,444 @@ +import os + +import requests + +from IPython.display import display + + + +# Configuration + +output_dir = "/work/sds-lab/Puja/P-E/" + + + +# Base URL pattern + +base_url = "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r11i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r11i1p1f1_gn_201501-206412.nc" + + + + + +variables = ["tas", "pr", "evspsbl"] + +scenarios = ["585", "245", "126"] + + + +# Function to download file + +def download_file(url, output_path): + + try: + + response = requests.get(url, stream=True) + + response.raise_for_status() + + + + with open(output_path, 'wb') as f: + + for chunk in response.iter_content(chunk_size=8192): + + f.write(chunk) + + + + display(f"Downloaded: {output_path}") + + except Exception as e: + + display(f"Failed to download {url}: {e}") + + + +# Loop through all combinations + +for variable in variables: + + for scenario in scenarios: + + # Construct URL and output file path + + file_url = base_url.format(scenario=scenario, variable=variable) + + file_name = f"{variable}_Amon_CESM2_ssp{scenario}_r11i1p1f1_gn_201501-206412.nc" + + output_path = os.path.join(output_dir, file_name) + + + + # Download the file + + download_file(file_url, output_path) + +import os + +import requests + +from IPython.display import display + + + +# Configuration + +output_dir = "/work/sds-lab/Puja/P-E/" + + + +# Base URL pattern + +base_url = "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r10i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r10i1p1f1_gn_201501-206412.nc" + + + + + +variables = ["tas", "pr", "evspsbl"] + +scenarios = ["585", "245", "126"] + + + +# Function to download file + +def download_file(url, output_path): + + try: + + response = requests.get(url, stream=True) + + response.raise_for_status() + + + + with open(output_path, 'wb') as f: + + for chunk in response.iter_content(chunk_size=8192): + + f.write(chunk) + + + + display(f"Downloaded: {output_path}") + + except Exception as e: + + display(f"Failed to download {url}: {e}") + + + +# Loop through all combinations + +for variable in variables: + + for scenario in scenarios: + + # Construct URL and output file path + + file_url = base_url.format(scenario=scenario, variable=variable) + + file_name = f"{variable}_Amon_CESM2_ssp{scenario}_r10i1p1f1_gn_201501-206412.nc" + + output_path = os.path.join(output_dir, file_name) + + + + # Download the file + + download_file(file_url, output_path) + +import os + +import requests + +from IPython.display import display + + + +# Configuration + +output_dir = "/work/sds-lab/Puja/P-E/" + + + +# Base URL pattern + +base_url = "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r4i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r4i1p1f1_gn_201501-206412.nc" + + + +variables = ["tas", "pr", "evspsbl"] + +scenarios = ["585", "245", "126"] + + + +# Function to download file + +def download_file(url, output_path): + + try: + + response = requests.get(url, stream=True) + + response.raise_for_status() + + + + with open(output_path, 'wb') as f: + + for chunk in response.iter_content(chunk_size=8192): + + f.write(chunk) + + + + display(f"Downloaded: {output_path}") + + except Exception as e: + + display(f"Failed to download {url}: {e}") + + + +# Loop through all combinations + +for variable in variables: + + for scenario in scenarios: + + # Construct URL and output file path + + file_url = base_url.format(scenario=scenario, variable=variable) + + file_name = f"{variable}_Amon_CESM2_ssp{scenario}_r4i1p1f1_gn_201501-206412.nc" + + output_path = os.path.join(output_dir, file_name) + + + + # Download the file + + download_file(file_url, output_path) + +import os + +import requests + +from IPython.display import display + + + +# Configuration + +output_dir = "/work/sds-lab/Puja/P-E/" + + + +base_url = "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r4i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r4i1p1f2_gr_201501-210012.nc" + + + +variables = ["tas", "pr", "evspsbl"] + +scenarios = ["126", "245", "585"] + + + +# Function to download file + +def download_file(url, output_path): + + try: + + response = requests.get(url, stream=True) + + response.raise_for_status() + + + + with open(output_path, 'wb') as f: + + for chunk in response.iter_content(chunk_size=8192): + + f.write(chunk) + + + + display(f"Downloaded: {output_path}") + + except Exception as e: + + display(f"Failed to download {url}: {e}") + + + +# Loop through all combinations + +for variable in variables: + + for scenario in scenarios: + + # Construct URL and output file path + + file_url = base_url.format(scenario=scenario, variable=variable) + + file_name = f"{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r4i1p1f2_gr_201501-210012.nc" + + output_path = os.path.join(output_dir, file_name) + + + + # Download the file + + download_file(file_url, output_path) + +import os + +import requests + +from IPython.display import display + + + +# Configuration + +output_dir = "/work/sds-lab/Puja/P-E/" + + + +base_url = "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r5i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r5i1p1f2_gr_201501-210012.nc" + + + +variables = ["tas", "pr", "evspsbl"] + +scenarios = ["126", "245", "585"] + + + +# Function to download file + +def download_file(url, output_path): + + try: + + response = requests.get(url, stream=True) + + response.raise_for_status() + + + + with open(output_path, 'wb') as f: + + for chunk in response.iter_content(chunk_size=8192): + + f.write(chunk) + + + + display(f"Downloaded: {output_path}") + + except Exception as e: + + display(f"Failed to download {url}: {e}") + + + +# Loop through all combinations + +for variable in variables: + + for scenario in scenarios: + + # Construct URL and output file path + + file_url = base_url.format(scenario=scenario, variable=variable) + + file_name = f"{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r5i1p1f2_gr_201501-210012.nc" + + output_path = os.path.join(output_dir, file_name) + + + + # Download the file + + download_file(file_url, output_path) + +import os + +import requests + +from IPython.display import display + + + +# Configuration + +output_dir = "/work/sds-lab/Puja/P-E/" + + + +ase_url = "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r6i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r6i1p1f2_gr_201501-210012.nc" + + + +variables = ["tas", "pr", "evspsbl"] + +scenarios = ["126", "245", "585"] + + + +# Function to download file + +def download_file(url, output_path): + + try: + + response = requests.get(url, stream=True) + + response.raise_for_status() + + + + with open(output_path, 'wb') as f: + + for chunk in response.iter_content(chunk_size=8192): + + f.write(chunk) + + + + display(f"Downloaded: {output_path}") + + except Exception as e: + + display(f"Failed to download {url}: {e}") + + + +# Loop through all combinations + +for variable in variables: + + for scenario in scenarios: + + # Construct URL and output file path + + file_url = base_url.format(scenario=scenario, variable=variable) + + file_name = f"{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r6i1p1f2_gr_201501-210012.nc" + + output_path = os.path.join(output_dir, file_name) + + + + # Download the file + + download_file(file_url, output_path) + + + diff --git a/uvdat/core/tasks/merge_layers.py b/uvdat/core/tasks/merge_layers.py new file mode 100644 index 0000000..9f025b4 --- /dev/null +++ b/uvdat/core/tasks/merge_layers.py @@ -0,0 +1,55 @@ +from celery import shared_task +from django.contrib.gis.geos import GEOSGeometry +from uvdat.core.models import VectorMapLayer +import json +from typing import List, Optional, Literal + + +@shared_task +def merge_vector_layer_data( + base_layer_id: int, + other_layer_ids: List[int], + operation: Literal['intersection', 'complete_overlap'] = 'intersection', + exclude_non_overlapping: bool = False, + properties_to_merge: Optional[List[str]] = None +) -> dict: + try: + base_layer = VectorMapLayer.objects.get(id=base_layer_id) + base_geojson = base_layer.read_geojson_data() + # Extract base features + base_features = base_geojson.get('features', []) + filtered_features = [] + # Fetch specified vector map layers + other_layers = VectorMapLayer.objects.filter(id__in=other_layer_ids) + for base_feature in base_features: + base_geom = GEOSGeometry(json.dumps(base_feature['geometry'])) + base_feature['overlapping_properties'] = [] + has_overlap = False + for layer in other_layers: + layer_geojson = layer.read_geojson_data() + for feature in layer_geojson.get('features', []): + feature_geom = GEOSGeometry(json.dumps(feature['geometry'])) + if operation == 'intersection' and base_geom.intersects(feature_geom): + properties = feature['properties'] + if properties_to_merge: + properties = {k: v for k, v in properties.items() if k in properties_to_merge} + base_feature['overlapping_properties'].append(properties) + has_overlap = True + elif operation == 'complete_overlap' and base_geom.covers(feature_geom): + properties = feature['properties'] + if properties_to_merge: + properties = {k: v for k, v in properties.items() if k in properties_to_merge} + base_feature['overlapping_properties'].append(properties) + has_overlap = True + if not exclude_non_overlapping or has_overlap: + filtered_features.append(base_feature) + + # Save the updated GeoJSON + updated_geojson = {'type': 'FeatureCollection', 'features': filtered_features} + base_layer.write_geojson_data(updated_geojson) + return {'status': 'success', 'message': 'Vector processing completed.'} + + except VectorMapLayer.DoesNotExist: + return {'status': 'error', 'message': 'Base layer not found.'} + except Exception as e: + return {'status': 'error', 'message': str(e)} From 360448b6401b90a5bb70dbc656288683e698ff8b Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 7 Feb 2025 14:30:30 -0500 Subject: [PATCH 02/16] update dev docs --- docs/dev-guide/dev.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev-guide/dev.md b/docs/dev-guide/dev.md index 9bd2f35..fda19f0 100644 --- a/docs/dev-guide/dev.md +++ b/docs/dev-guide/dev.md @@ -10,7 +10,7 @@ To begin development, check out the repo via `git clone git@github.com:OpenGeosc 2. Run `docker compose run --rm django ./manage.py createsuperuser` and follow the prompts to create your own user. 3. Run `docker compose run --rm django ./manage.py makeclient --username {your.username@email.com}` to create the client application id for user logins. - 4. Run `docker compose run --rm django ./manage.py ingest_data ./sample_data/test.json` to use sample data. + 4. Run `docker compose run --rm django ./manage.py ingest_data tva.json` to use sample data. 5. Copy the `./client/env.example` environment file to `./client/.env`. The default environment variables should be sufficient for local development. ## Run Application From 47580e711f2ef87b7f1e445a0cd3914ceb6b43c1 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 10 Feb 2025 12:19:00 -0500 Subject: [PATCH 03/16] scripts for merging data --- .../DataSelection/NetCDFDataConfigurator.vue | 8 +- scripts/.gitignore | 4 + scripts/download_input_files.py | 524 ++++-------------- scripts/filter_geojson.py | 57 ++ scripts/geojson_to_tsv.py | 40 ++ scripts/merged_layers.py | 102 ++++ uvdat/core/rest/__init__.py | 2 + uvdat/core/rest/tasks.py | 35 ++ uvdat/core/tasks/merge_layers.py | 157 ++++-- uvdat/core/tasks/netcdf.py | 7 +- uvdat/urls.py | 2 + 11 files changed, 467 insertions(+), 471 deletions(-) create mode 100644 scripts/.gitignore create mode 100644 scripts/filter_geojson.py create mode 100644 scripts/geojson_to_tsv.py create mode 100644 scripts/merged_layers.py create mode 100644 uvdat/core/rest/tasks.py diff --git a/client/src/components/DataSelection/NetCDFDataConfigurator.vue b/client/src/components/DataSelection/NetCDFDataConfigurator.vue index 29a3d47..b98c36d 100644 --- a/client/src/components/DataSelection/NetCDFDataConfigurator.vue +++ b/client/src/components/DataSelection/NetCDFDataConfigurator.vue @@ -565,10 +565,10 @@ export default defineComponent({ {{ modelValue.toFixed(2) }} @@ -589,10 +589,10 @@ export default defineComponent({ {{ modelValue.toFixed(2) }} diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..0c6a867 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,4 @@ +**/*.json +**/*.geojson +**/*.csv +**/*.tsv \ No newline at end of file diff --git a/scripts/download_input_files.py b/scripts/download_input_files.py index d99555a..7ae0cd1 100755 --- a/scripts/download_input_files.py +++ b/scripts/download_input_files.py @@ -1,444 +1,120 @@ import os - -import requests - -from IPython.display import display - - - -# Configuration - -output_dir = "/work/sds-lab/Puja/P-E/" - - - -# Base URL pattern - -base_url = "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r11i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r11i1p1f1_gn_201501-206412.nc" - - - - - -variables = ["tas", "pr", "evspsbl"] - -scenarios = ["585", "245", "126"] - - - -# Function to download file - -def download_file(url, output_path): - - try: - - response = requests.get(url, stream=True) - - response.raise_for_status() - - - - with open(output_path, 'wb') as f: - - for chunk in response.iter_content(chunk_size=8192): - - f.write(chunk) - - - - display(f"Downloaded: {output_path}") - - except Exception as e: - - display(f"Failed to download {url}: {e}") - - - -# Loop through all combinations - -for variable in variables: - - for scenario in scenarios: - - # Construct URL and output file path - - file_url = base_url.format(scenario=scenario, variable=variable) - - file_name = f"{variable}_Amon_CESM2_ssp{scenario}_r11i1p1f1_gn_201501-206412.nc" - - output_path = os.path.join(output_dir, file_name) - - - - # Download the file - - download_file(file_url, output_path) - -import os - -import requests - -from IPython.display import display - - - -# Configuration - -output_dir = "/work/sds-lab/Puja/P-E/" - - - -# Base URL pattern - -base_url = "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r10i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r10i1p1f1_gn_201501-206412.nc" - - - - - -variables = ["tas", "pr", "evspsbl"] - -scenarios = ["585", "245", "126"] - - - -# Function to download file - -def download_file(url, output_path): - - try: - - response = requests.get(url, stream=True) - - response.raise_for_status() - - - - with open(output_path, 'wb') as f: - - for chunk in response.iter_content(chunk_size=8192): - - f.write(chunk) - - - - display(f"Downloaded: {output_path}") - - except Exception as e: - - display(f"Failed to download {url}: {e}") - - - -# Loop through all combinations - -for variable in variables: - - for scenario in scenarios: - - # Construct URL and output file path - - file_url = base_url.format(scenario=scenario, variable=variable) - - file_name = f"{variable}_Amon_CESM2_ssp{scenario}_r10i1p1f1_gn_201501-206412.nc" - - output_path = os.path.join(output_dir, file_name) - - - - # Download the file - - download_file(file_url, output_path) - -import os - import requests - -from IPython.display import display - - +import click +import xarray as xr +import numpy as np # Configuration - -output_dir = "/work/sds-lab/Puja/P-E/" - - - -# Base URL pattern - -base_url = "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r4i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r4i1p1f1_gn_201501-206412.nc" - - +default_output_dir = "./outputs" +conus_dir = os.path.join(default_output_dir, "CONUS") + +# Base URL patterns and filenames +base_urls = [ + { + "url": "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r11i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r11i1p1f1_gn_201501-206412.nc", + "filename": "{variable}_Amon_CESM2_ssp{scenario}_r11i1p1f1_gn_201501-206412.nc", + }, + { + "url": "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r10i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r10i1p1f1_gn_201501-206412.nc", + "filename": "{variable}_Amon_CESM2_ssp{scenario}_r10i1p1f1_gn_201501-206412.nc", + }, + { + "url": "https://esgf-data1.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/NCAR/CESM2/ssp{scenario}/r4i1p1f1/Amon/{variable}/gn/v20200528/{variable}_Amon_CESM2_ssp{scenario}_r4i1p1f1_gn_201501-206412.nc", + "filename": "{variable}_Amon_CESM2_ssp{scenario}_r4i1p1f1_gn_201501-206412.nc", + }, + { + "url": "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r4i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r4i1p1f2_gr_201501-210012.nc", + "filename": "{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r4i1p1f2_gr_201501-210012.nc", + }, + { + "url": "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r6i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r6i1p1f2_gr_201501-210012.nc", + "filename": "{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r6i1p1f2_gr_201501-210012.nc", + }, +] variables = ["tas", "pr", "evspsbl"] - scenarios = ["585", "245", "126"] - - -# Function to download file - -def download_file(url, output_path): - - try: - - response = requests.get(url, stream=True) - - response.raise_for_status() - - - - with open(output_path, 'wb') as f: - - for chunk in response.iter_content(chunk_size=8192): - - f.write(chunk) - - - - display(f"Downloaded: {output_path}") - - except Exception as e: - - display(f"Failed to download {url}: {e}") - - - -# Loop through all combinations - -for variable in variables: - - for scenario in scenarios: - - # Construct URL and output file path - - file_url = base_url.format(scenario=scenario, variable=variable) - - file_name = f"{variable}_Amon_CESM2_ssp{scenario}_r4i1p1f1_gn_201501-206412.nc" - - output_path = os.path.join(output_dir, file_name) - - - - # Download the file - - download_file(file_url, output_path) - -import os - -import requests - -from IPython.display import display - - - -# Configuration - -output_dir = "/work/sds-lab/Puja/P-E/" - - - -base_url = "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r4i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r4i1p1f2_gr_201501-210012.nc" - - - -variables = ["tas", "pr", "evspsbl"] - -scenarios = ["126", "245", "585"] - - - -# Function to download file - -def download_file(url, output_path): - - try: - - response = requests.get(url, stream=True) - - response.raise_for_status() - - - - with open(output_path, 'wb') as f: - - for chunk in response.iter_content(chunk_size=8192): - - f.write(chunk) - - - - display(f"Downloaded: {output_path}") - - except Exception as e: - - display(f"Failed to download {url}: {e}") - - - -# Loop through all combinations - -for variable in variables: - - for scenario in scenarios: - - # Construct URL and output file path - - file_url = base_url.format(scenario=scenario, variable=variable) - - file_name = f"{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r4i1p1f2_gr_201501-210012.nc" - - output_path = os.path.join(output_dir, file_name) - - - - # Download the file - - download_file(file_url, output_path) - -import os - -import requests - -from IPython.display import display - - - -# Configuration - -output_dir = "/work/sds-lab/Puja/P-E/" - - - -base_url = "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r5i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r5i1p1f2_gr_201501-210012.nc" - - - -variables = ["tas", "pr", "evspsbl"] - -scenarios = ["126", "245", "585"] - - - -# Function to download file - -def download_file(url, output_path): - - try: - - response = requests.get(url, stream=True) - - response.raise_for_status() - - - - with open(output_path, 'wb') as f: - - for chunk in response.iter_content(chunk_size=8192): - - f.write(chunk) - - - - display(f"Downloaded: {output_path}") - - except Exception as e: - - display(f"Failed to download {url}: {e}") - - - -# Loop through all combinations - -for variable in variables: - - for scenario in scenarios: - - # Construct URL and output file path - - file_url = base_url.format(scenario=scenario, variable=variable) - - file_name = f"{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r5i1p1f2_gr_201501-210012.nc" - - output_path = os.path.join(output_dir, file_name) - - - - # Download the file - - download_file(file_url, output_path) - -import os - -import requests - -from IPython.display import display - - - -# Configuration - -output_dir = "/work/sds-lab/Puja/P-E/" - - - -ase_url = "https://aims3.llnl.gov/thredds/fileServer/css03_data/CMIP6/ScenarioMIP/CNRM-CERFACS/CNRM-CM6-1/ssp{scenario}/r6i1p1f2/Amon/{variable}/gr/v20190410/{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r6i1p1f2_gr_201501-210012.nc" - - - -variables = ["tas", "pr", "evspsbl"] - -scenarios = ["126", "245", "585"] - - - -# Function to download file - def download_file(url, output_path): + """Download a file if it does not exist.""" + if os.path.exists(output_path): + print(f"File already exists: {output_path}, skipping download.") + return True try: - response = requests.get(url, stream=True) - response.raise_for_status() - - + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 with open(output_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - - f.write(chunk) - - - - display(f"Downloaded: {output_path}") - + if chunk: + f.write(chunk) + downloaded += len(chunk) + progress = (downloaded / total_size) * 100 if total_size > 0 else 0 + print(f"Downloading {output_path} - {progress:.2f}% completed", end='\r') + + print(f"\nDownloaded: {output_path}") + return True except Exception as e: - - display(f"Failed to download {url}: {e}") - - - -# Loop through all combinations - -for variable in variables: - - for scenario in scenarios: - - # Construct URL and output file path - - file_url = base_url.format(scenario=scenario, variable=variable) - - file_name = f"{variable}_Amon_CNRM-CM6-1_ssp{scenario}_r6i1p1f2_gr_201501-210012.nc" - + print(f"\nFailed to download {url}: {e}") + return False + +def process_conus(nc_path): + """Clip the netCDF data to a rectangle around the Continental U.S.""" + if not os.path.exists(conus_dir): + os.makedirs(conus_dir, exist_ok=True) + + output_conus_path = os.path.join(conus_dir, os.path.basename(nc_path)) + if os.path.exists(output_conus_path): + print(f"CONUS-processed file already exists: {output_conus_path}, skipping.") + return + + ds = xr.open_dataset(nc_path) + + # Identify first variable with 'time', 'lat', and 'lon' + target_var = None + for var in ds.data_vars: + dims = ds[var].dims + if all(dim in dims for dim in ['time', 'lat', 'lon']): + target_var = var + break + + if not target_var: + print(f"Skipping {nc_path}, no valid variable found.") + return + + # Convert longitude from [0, 360] to [-180, 180] + ds = ds.assign_coords(lon=((ds.lon + 180) % 360) - 180) + ds = ds.sortby(ds.lon) + + # Define CONUS bounds (approximate) + lat_min, lat_max = 20, 50 + lon_min, lon_max = -130, -65 + ds.sortby(ds.lat, True) + # Subset dataset for just the target variable + ds_conus = ds[target_var].sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)) + + # Save to new netCDF file + ds_conus.to_netcdf(output_conus_path) + print(f"Processed CONUS file saved: {output_conus_path}") + + + +@click.command() +@click.option('--output_dir', default=default_output_dir, help='Directory to save downloaded files') +def download_data(output_dir): + os.makedirs(output_dir, exist_ok=True) + file_list = [(entry, variable, scenario) for entry in base_urls for variable in variables for scenario in scenarios] + + for entry, variable, scenario in file_list: + file_url = entry["url"].format(scenario=scenario, variable=variable) + file_name = entry["filename"].format(scenario=scenario, variable=variable) output_path = os.path.join(output_dir, file_name) + if download_file(file_url, output_path): + process_conus(output_path) - - # Download the file - - download_file(file_url, output_path) - - - +if __name__ == '__main__': + download_data() diff --git a/scripts/filter_geojson.py b/scripts/filter_geojson.py new file mode 100644 index 0000000..17430a1 --- /dev/null +++ b/scripts/filter_geojson.py @@ -0,0 +1,57 @@ +import click +import geopandas as gpd +import logging +import json + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@click.command() +@click.argument('geojson_file1', type=click.Path(exists=True)) +@click.argument('geojson_file2', type=click.Path(exists=True)) +@click.argument('output_file', type=click.Path()) +def filter_intersections(geojson_file1, geojson_file2, output_file): + """ + A script to filter features from the first GeoJSON file that intersect with features + from the second GeoJSON file and save the filtered features into a new GeoJSON file. + """ + # Load the GeoJSON files using GeoPandas + logger.info(f"Loading GeoJSON files: {geojson_file1}, {geojson_file2}") + gdf1 = gpd.read_file(geojson_file1) + gdf2 = gpd.read_file(geojson_file2) + + # Ensure both datasets use the same coordinate reference system (CRS) + if gdf1.crs != gdf2.crs: + logger.info("CRS mismatch, reprojecting GeoJSON 2 to match GeoJSON 1.") + gdf2 = gdf2.to_crs(gdf1.crs) + + # Prepare a list to store the intersecting features + logger.info("Finding intersecting features...") + filtered_features = [] + + # Loop through each feature in the first GeoDataFrame + for _, row1 in gdf1.iterrows(): + # Check if the feature from gdf1 intersects with any feature from gdf2 + intersecting_features = gdf2[gdf2.geometry.intersects(row1['geometry'])] + + # If any intersection exists, add the feature to the filtered list + if not intersecting_features.empty: + # Convert the feature to GeoJSON format (including geometry) + feature_geojson = { + 'type': 'Feature', + 'geometry': row1['geometry'].__geo_interface__, + 'properties': row1.drop('geometry').to_dict() + } + filtered_features.append(feature_geojson) + + # Save the filtered features to a new GeoJSON file + logger.info(f"Saving filtered features to {output_file}") + with open(output_file, 'w') as f: + geojson = {"type": "FeatureCollection", "features": filtered_features} + json.dump(geojson, f) + + logger.info("Filtering complete.") + +if __name__ == '__main__': + filter_intersections() diff --git a/scripts/geojson_to_tsv.py b/scripts/geojson_to_tsv.py new file mode 100644 index 0000000..7767f22 --- /dev/null +++ b/scripts/geojson_to_tsv.py @@ -0,0 +1,40 @@ +import click +import geopandas as gpd +import csv +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@click.command() +@click.argument('geojson_file', type=click.Path(exists=True)) +@click.argument('output_tsv_file', type=click.Path()) +def geojson_to_tsv(geojson_file, output_tsv_file): + """ + A script to convert a GeoJSON file into a TSV (Tab-Separated Values) file. + Each feature in the GeoJSON becomes a row in the TSV, + with the feature properties as columns. + """ + # Load GeoJSON file using GeoPandas + logger.info(f"Loading GeoJSON file: {geojson_file}") + gdf = gpd.read_file(geojson_file) + + # Prepare the data for TSV (excluding geometry) + logger.info("Preparing the data for TSV conversion...") + data = gdf.drop(columns='geometry') # Drop geometry to focus on properties + + # Open the output TSV file and write the data + logger.info(f"Saving the result to {output_tsv_file}") + with open(output_tsv_file, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=data.columns, delimiter='\t') # Set delimiter to tab + writer.writeheader() # Write the header row (columns) + + # Write each row as a dictionary + for _, row in data.iterrows(): + writer.writerow(row.to_dict()) + + logger.info("Processing complete.") + +if __name__ == '__main__': + geojson_to_tsv() diff --git a/scripts/merged_layers.py b/scripts/merged_layers.py new file mode 100644 index 0000000..fc058ca --- /dev/null +++ b/scripts/merged_layers.py @@ -0,0 +1,102 @@ +import click +import geopandas as gpd +import json +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@click.command() +@click.argument('geojson_file1', type=click.Path(exists=True)) +@click.argument('geojson_files', type=click.Path(exists=True), nargs=-1) # Accept multiple secondary files +@click.argument('output_file', type=click.Path()) +@click.option('--flatten', default=True, is_flag=True, help="Flatten the properties of the intersecting features into a single string.") +@click.option('--use_first_value', default=False, is_flag=True, help="Only take the first intersecting value for each property (do not flatten).") +def intersect_geojson(geojson_file1, geojson_files, output_file, flatten, use_first_value): + """ + A script to find all intersecting features from a primary GeoJSON file and a list of secondary GeoJSON files. + Each feature from the primary GeoJSON is converted into a GeoJSON feature, + and properties from the intersecting features in the secondary GeoJSON files are added into a 'merged_properties' field as a comma-separated string. + Only features with intersections are included in the output. + The properties from the secondary files are excluded from the root 'properties' and moved into 'merged_properties' as a joined string. + """ + # Load the primary GeoJSON file using GeoPandas + logger.info(f"Loading primary GeoJSON file: {geojson_file1}") + gdf1 = gpd.read_file(geojson_file1) + + # Prepare a list to hold the resulting features + output_features = [] + + # Loop through each secondary GeoJSON file + for geojson_file2 in geojson_files: + logger.info(f"Loading secondary GeoJSON file: {geojson_file2}") + gdf2 = gpd.read_file(geojson_file2) + + # Ensure both datasets use the same coordinate reference system (CRS) + if gdf1.crs != gdf2.crs: + logger.info(f"CRS mismatch with {geojson_file2}, reprojecting.") + gdf2 = gdf2.to_crs(gdf1.crs) + + # For each feature in the first GeoDataFrame, find intersecting features from the second GeoDataFrame + logger.info(f"Calculating intersects with {geojson_file2}...") + for _, row1 in gdf1.iterrows(): + # Find features in gdf2 that intersect the current feature from gdf1 + intersecting_features = gdf2[gdf2.geometry.intersects(row1['geometry'])] + + # Only proceed if there are intersecting features + if not intersecting_features.empty: + # Prepare the properties dictionary (include all original properties from gdf1) + feature_properties = row1.drop('geometry') + for _, row_dict in intersecting_features.iterrows(): + for item in row_dict.to_dict().keys(): + if feature_properties.get(item): + feature_properties = feature_properties.drop(item) + feature_properties = feature_properties.to_dict() + + # Prepare 'merged_properties' and join intersecting feature properties as a comma-separated string + merged_properties = [] + for _, row2 in intersecting_features.iterrows(): + merged_properties.append(row2.drop('geometry').to_dict()) + + if flatten and not use_first_value: + new_vals = {} + for i, prop in enumerate(merged_properties): + for key, value in prop.items(): + if f'intersecting_{key}' not in new_vals: + new_vals[f'intersecting_{key}'] = [] + new_vals[f'intersecting_{key}'].append(value) + for key, value in new_vals.items(): + str_vals = [str(item) for item in value] + feature_properties[key] = ', '.join(str_vals) + elif use_first_value: + # If use_first_value is True, only take the first value for each intersecting property + for prop in merged_properties: + for key, value in prop.items(): + if f'intersecting_{key}' not in feature_properties: + feature_properties[f'intersecting_{key}'] = value + # No need to flatten or join as the first value is taken for each property + + else: + feature_properties['merged_properties'] = merged_properties + + # Convert the feature to a GeoJSON feature + feature_geojson = { + 'type': 'Feature', + 'geometry': row1['geometry'].__geo_interface__, # Convert geometry to GeoJSON format + 'properties': feature_properties # Add properties to the GeoJSON feature + } + + # Append the feature to the output list + output_features.append(feature_geojson) + + # Save the output features as a new GeoJSON file + logger.info(f"Saving the result to {output_file}") + with open(output_file, 'w') as f: + geojson = {"type": "FeatureCollection", "features": output_features} + json.dump(geojson, f) + + logger.info("Processing complete.") + +if __name__ == '__main__': + intersect_geojson() diff --git a/uvdat/core/rest/__init__.py b/uvdat/core/rest/__init__.py index b3c7705..a71f777 100644 --- a/uvdat/core/rest/__init__.py +++ b/uvdat/core/rest/__init__.py @@ -12,6 +12,7 @@ from .simulations import SimulationViewSet from .user import UserViewSet from .vector_feature_table_data import VectorFeatureTableDataViewSet +from .tasks import TasksAPIView __all__ = [ ContextViewSet, @@ -34,4 +35,5 @@ ProcessingTaskView, UserViewSet, VectorFeatureTableDataViewSet, + TasksAPIView, ] diff --git a/uvdat/core/rest/tasks.py b/uvdat/core/rest/tasks.py new file mode 100644 index 0000000..12ad646 --- /dev/null +++ b/uvdat/core/rest/tasks.py @@ -0,0 +1,35 @@ +from rest_framework.viewsets import ViewSet +from rest_framework.response import Response +from rest_framework import status +from rest_framework.decorators import action +from drf_spectacular.utils import extend_schema +from rest_framework.serializers import Serializer, IntegerField, CharField, BooleanField, ListField +from uvdat.core.tasks.merge_layers import merge_vector_layer_data +from .permissions import DefaultPermission + +class MergeVectorLayerSerializer(Serializer): + base_layer_id = IntegerField() + other_layer_ids = ListField(child=IntegerField()) + dataset_name = CharField(max_length=255) + operation = CharField(default='intersection') + exclude_non_overlapping = BooleanField(default=True) + properties_to_merge = ListField(child=CharField(), required=False, allow_null=True) + +class TasksAPIView(ViewSet): + permission_classes = [DefaultPermission] + + @extend_schema(request=MergeVectorLayerSerializer) + @action(detail=False, methods=['post'], url_path='merge-vector-layer') + def post(self, request, *args, **kwargs): + serializer = MergeVectorLayerSerializer(data=request.data) + if serializer.is_valid(): + merge_vector_layer_data.delay( + base_layer_id=serializer.validated_data['base_layer_id'], + other_layer_ids=serializer.validated_data['other_layer_ids'], + dataset_name=serializer.validated_data['dataset_name'], + operation=serializer.validated_data['operation'], + exclude_non_overlapping=serializer.validated_data['exclude_non_overlapping'], + properties_to_merge=serializer.validated_data.get('properties_to_merge') + ) + return Response({'status': 'success', 'message': 'Task has been initiated.'}, status=status.HTTP_202_ACCEPTED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/uvdat/core/tasks/merge_layers.py b/uvdat/core/tasks/merge_layers.py index 9f025b4..ba19a93 100644 --- a/uvdat/core/tasks/merge_layers.py +++ b/uvdat/core/tasks/merge_layers.py @@ -1,55 +1,136 @@ +import logging +import geopandas as gpd +import pandas as pd from celery import shared_task -from django.contrib.gis.geos import GEOSGeometry -from uvdat.core.models import VectorMapLayer +from uvdat.core.models import VectorMapLayer, Dataset, FileItem import json from typing import List, Optional, Literal +from django.core.files.base import ContentFile +# Configure logging +logger = logging.getLogger(__name__) + +def save_geojson_to_dataset(dataset_name: str, geojson_data: dict): + dataset, _ = Dataset.objects.get_or_create(name=dataset_name, defaults={'category': 'generated'}) + geojson_content = json.dumps(geojson_data, indent=2) + file_item = FileItem.objects.create( + name=f'{dataset_name}.geojson', + dataset=dataset, + file_type='geojson', + file_size=len(geojson_content.encode('utf-8')), + ) + file_item.file.save(f'{dataset_name}.geojson', ContentFile(geojson_content.encode()), save=True) + return file_item @shared_task def merge_vector_layer_data( base_layer_id: int, other_layer_ids: List[int], - operation: Literal['intersection', 'complete_overlap'] = 'intersection', - exclude_non_overlapping: bool = False, - properties_to_merge: Optional[List[str]] = None + dataset_name: str, + operation: Literal['intersection', 'contains'] = 'intersection', + exclude_non_overlapping: bool = True, + properties_to_merge: Optional[List[str]] = None, + flatten: bool = True, # Default to flattening + use_first_value: bool = False # first value from merged properties ) -> dict: + logger.info(f'Starting merge_vector_layer_data task for base_layer_id: {base_layer_id}') + try: + # Load the base layer GeoDataFrame base_layer = VectorMapLayer.objects.get(id=base_layer_id) - base_geojson = base_layer.read_geojson_data() - # Extract base features - base_features = base_geojson.get('features', []) - filtered_features = [] - # Fetch specified vector map layers - other_layers = VectorMapLayer.objects.filter(id__in=other_layer_ids) - for base_feature in base_features: - base_geom = GEOSGeometry(json.dumps(base_feature['geometry'])) - base_feature['overlapping_properties'] = [] - has_overlap = False - for layer in other_layers: - layer_geojson = layer.read_geojson_data() - for feature in layer_geojson.get('features', []): - feature_geom = GEOSGeometry(json.dumps(feature['geometry'])) - if operation == 'intersection' and base_geom.intersects(feature_geom): - properties = feature['properties'] - if properties_to_merge: - properties = {k: v for k, v in properties.items() if k in properties_to_merge} - base_feature['overlapping_properties'].append(properties) - has_overlap = True - elif operation == 'complete_overlap' and base_geom.covers(feature_geom): - properties = feature['properties'] - if properties_to_merge: - properties = {k: v for k, v in properties.items() if k in properties_to_merge} - base_feature['overlapping_properties'].append(properties) - has_overlap = True - if not exclude_non_overlapping or has_overlap: - filtered_features.append(base_feature) - - # Save the updated GeoJSON - updated_geojson = {'type': 'FeatureCollection', 'features': filtered_features} - base_layer.write_geojson_data(updated_geojson) - return {'status': 'success', 'message': 'Vector processing completed.'} + base_gdf = gpd.GeoDataFrame.from_features(base_layer.read_geojson_data().get('features', [])) + + # Load the secondary layers and store their properties + other_gdfs = [] + for layer_id in other_layer_ids: + layer = VectorMapLayer.objects.get(id=layer_id) + layer_gdf = gpd.GeoDataFrame.from_features(layer.read_geojson_data().get('features', [])) + other_gdfs.append(layer_gdf) + + if not other_gdfs: + logger.warning('No valid other layers found. Skipping merge.') + return {'status': 'error', 'message': 'No valid other layers provided.'} + + # Merge all secondary layers into a single GeoDataFrame + other_gdf = pd.concat(other_gdfs, ignore_index=True) + + # Ensure both layers use the same CRS + if base_gdf.crs != other_gdf.crs: + logger.info('Reprojecting other layers to match base layer CRS...') + other_gdf = other_gdf.to_crs(base_gdf.crs) + + # Prepare list to collect the merged features + output_features = [] + + # Loop through each feature in the base layer + logger.info(f"Processing {len(base_gdf)} features from the base layer.") + for _, base_row in base_gdf.iterrows(): + # Find features in the other layers that intersect or completely overlap the current base layer feature + intersecting_features = [] + for _, other_row in other_gdf.iterrows(): + if operation == 'intersection' and base_row['geometry'].intersects(other_row['geometry']): + intersecting_features.append(other_row) + elif operation == 'contains' and base_row['geometry'].contains(other_row['geometry']): + intersecting_features.append(other_row) + + # If exclude_non_overlapping is True and no intersection or complete overlap is found, skip this base feature + if exclude_non_overlapping and not intersecting_features: + continue + + # Prepare the properties for the current feature + feature_properties = base_row.drop('geometry') + + # Merge properties from the intersecting features + merged_properties = [] + for other_row in intersecting_features: + other_props = other_row.drop('geometry').to_dict() + + # If properties_to_merge is provided, only merge those + if properties_to_merge: + filtered_props = {key: value for key, value in other_props.items() if key in properties_to_merge} + merged_properties.append(filtered_props) + else: + merged_properties.append(other_props) + + # Flatten or use first intersecting value for properties + if flatten: + new_vals = {} + for prop in merged_properties: + for key, value in prop.items(): + if f'intersecting_{key}' not in new_vals: + new_vals[f'intersecting_{key}'] = [] + new_vals[f'intersecting_{key}'].append(value) + + if use_first_value: + for key, value in feature_properties.items(): + if isinstance(value, list): + feature_properties[key] = value[0] + else: + for key, value in new_vals.items(): + feature_properties[key] = ', '.join(map(str, value)) + else: + feature_properties['merged_properties'] = merged_properties + + # Convert the feature to a GeoJSON feature + feature_geojson = { + 'type': 'Feature', + 'geometry': base_row['geometry'].__geo_interface__, # Convert geometry to GeoJSON format + 'properties': feature_properties # Add properties to the GeoJSON feature + } + + # Append the feature to the output list + output_features.append(feature_geojson) + + # Save the output features as a new GeoJSON file + logger.info(f"Saving the result to {dataset_name}.geojson") + geojson = {"type": "FeatureCollection", "features": output_features} + save_geojson_to_dataset(dataset_name, geojson) + + return {'status': 'success', 'message': 'Vector processing completed and dataset created.'} except VectorMapLayer.DoesNotExist: + logger.error(f'Base layer with ID {base_layer_id} not found.') return {'status': 'error', 'message': 'Base layer not found.'} except Exception as e: + logger.error(f'Error in merge_vector_layer_data: {str(e)}') return {'status': 'error', 'message': str(e)} diff --git a/uvdat/core/tasks/netcdf.py b/uvdat/core/tasks/netcdf.py index 1de1867..fc2cc40 100644 --- a/uvdat/core/tasks/netcdf.py +++ b/uvdat/core/tasks/netcdf.py @@ -248,7 +248,7 @@ def preview_netcdf_slice( except Exception as e: logger.warning(f'Slicer Range Exception: {e}') slicer_range = None - + ds = ds.sortby(ds[y_variable], ascending=False) data_var = ds.get(variable) variables = data_var.dims base_variables = (x_variable, y_variable, sliding_variable) @@ -288,8 +288,6 @@ def preview_netcdf_slice( # Convert to an RGB image using PIL image = Image.fromarray(colored_data, mode='RGB') image_buffer = BytesIO() - if longitude360: - image = image.transpose(Image.FLIP_TOP_BOTTOM) image.save(image_buffer, format='PNG') image_buffer.seek(0) base64_image = base64.b64encode(image_buffer.getvalue()).decode('utf-8') @@ -478,6 +476,7 @@ def create_netcdf_slices( logger.warning(f'Slicer Range Exception: {e}') slicer_range = None # Extract the data for the specified variable + ds = ds.sortby(ds[y_variable], ascending=False) data_var = ds.get(variable) variables_data = data_var.dims dim_size = ds.dims.get(sliding_variable) @@ -640,8 +639,6 @@ def create_netcdf_slices( # Convert to an RGB image using PIL image = Image.fromarray(colored_data, mode='RGB') image_buffer = BytesIO() - if longitude360: - image = image.transpose(Image.FLIP_TOP_BOTTOM) image.save(image_buffer, format='PNG') image_buffer.seek(0) image_name = f'{variable}_{sliding_variable}_{i}.png' diff --git a/uvdat/urls.py b/uvdat/urls.py index cf8898a..8985291 100644 --- a/uvdat/urls.py +++ b/uvdat/urls.py @@ -25,6 +25,7 @@ UserViewSet, VectorFeatureTableDataViewSet, VectorMapLayerViewSet, + TasksAPIView, ) router = routers.SimpleRouter() @@ -58,6 +59,7 @@ router.register(r'netcdf', NetCDFDataView, basename='netcdf') router.register(r'processing-tasks', ProcessingTaskView, basename='processing-tasks') router.register(r'users', UserViewSet, basename='users') +router.register(r'tasks', TasksAPIView, basename='tasks') urlpatterns = [ path('accounts/', include('allauth.urls')), From 5a5fc0a124696637b5c227aa05f778f24f9cc301 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 10 Feb 2025 12:45:07 -0500 Subject: [PATCH 04/16] linting, map_merge fixing, loading selection settings --- client/src/map/mapLayers.ts | 1 + uvdat/core/rest/__init__.py | 2 +- uvdat/core/rest/tasks.py | 21 ++++++---- uvdat/core/tasks/merge_layers.py | 70 ++++++++++++++++++++++++-------- uvdat/urls.py | 2 +- 5 files changed, 69 insertions(+), 27 deletions(-) diff --git a/client/src/map/mapLayers.ts b/client/src/map/mapLayers.ts index 68497a7..ed20590 100644 --- a/client/src/map/mapLayers.ts +++ b/client/src/map/mapLayers.ts @@ -106,6 +106,7 @@ const toggleLayerSelection = (layer: VectorMapLayer | RasterMapLayer | NetCDFLay } else { MapStore.selectedMapLayers.value = [...MapStore.selectedMapLayers.value, layer]; toggleLayerVisibility(layer, true); + setPopupEvents(internalMap.value as maplibregl.Map); } }; diff --git a/uvdat/core/rest/__init__.py b/uvdat/core/rest/__init__.py index a71f777..e5a372f 100644 --- a/uvdat/core/rest/__init__.py +++ b/uvdat/core/rest/__init__.py @@ -10,9 +10,9 @@ from .processing_task import ProcessingTaskView from .regions import DerivedRegionViewSet, SourceRegionViewSet from .simulations import SimulationViewSet +from .tasks import TasksAPIView from .user import UserViewSet from .vector_feature_table_data import VectorFeatureTableDataViewSet -from .tasks import TasksAPIView __all__ = [ ContextViewSet, diff --git a/uvdat/core/rest/tasks.py b/uvdat/core/rest/tasks.py index 12ad646..9329c66 100644 --- a/uvdat/core/rest/tasks.py +++ b/uvdat/core/rest/tasks.py @@ -1,12 +1,15 @@ -from rest_framework.viewsets import ViewSet -from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.decorators import action -from drf_spectacular.utils import extend_schema -from rest_framework.serializers import Serializer, IntegerField, CharField, BooleanField, ListField +from rest_framework.response import Response +from rest_framework.serializers import BooleanField, CharField, IntegerField, ListField, Serializer +from rest_framework.viewsets import ViewSet + from uvdat.core.tasks.merge_layers import merge_vector_layer_data + from .permissions import DefaultPermission + class MergeVectorLayerSerializer(Serializer): base_layer_id = IntegerField() other_layer_ids = ListField(child=IntegerField()) @@ -15,6 +18,7 @@ class MergeVectorLayerSerializer(Serializer): exclude_non_overlapping = BooleanField(default=True) properties_to_merge = ListField(child=CharField(), required=False, allow_null=True) + class TasksAPIView(ViewSet): permission_classes = [DefaultPermission] @@ -29,7 +33,10 @@ def post(self, request, *args, **kwargs): dataset_name=serializer.validated_data['dataset_name'], operation=serializer.validated_data['operation'], exclude_non_overlapping=serializer.validated_data['exclude_non_overlapping'], - properties_to_merge=serializer.validated_data.get('properties_to_merge') + properties_to_merge=serializer.validated_data.get('properties_to_merge'), + ) + return Response( + {'status': 'success', 'message': 'Task has been initiated.'}, + status=status.HTTP_202_ACCEPTED, ) - return Response({'status': 'success', 'message': 'Task has been initiated.'}, status=status.HTTP_202_ACCEPTED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/uvdat/core/tasks/merge_layers.py b/uvdat/core/tasks/merge_layers.py index ba19a93..e3bfb2b 100644 --- a/uvdat/core/tasks/merge_layers.py +++ b/uvdat/core/tasks/merge_layers.py @@ -1,27 +1,42 @@ +import json import logging -import geopandas as gpd -import pandas as pd +from typing import List, Literal, Optional + from celery import shared_task -from uvdat.core.models import VectorMapLayer, Dataset, FileItem -import json -from typing import List, Optional, Literal from django.core.files.base import ContentFile +import geopandas as gpd +import pandas as pd + +from uvdat.core.models import Dataset, FileItem, VectorMapLayer + +from .dataset import process_file_item # Configure logging logger = logging.getLogger(__name__) + def save_geojson_to_dataset(dataset_name: str, geojson_data: dict): - dataset, _ = Dataset.objects.get_or_create(name=dataset_name, defaults={'category': 'generated'}) + dataset, _ = Dataset.objects.get_or_create( + name=dataset_name, + defaults={ + 'category': 'generated', + 'description': 'Generated dataset from vector layer merge.', + 'metadata': {}, + }, + ) geojson_content = json.dumps(geojson_data, indent=2) file_item = FileItem.objects.create( name=f'{dataset_name}.geojson', dataset=dataset, file_type='geojson', + metadata={}, file_size=len(geojson_content.encode('utf-8')), ) file_item.file.save(f'{dataset_name}.geojson', ContentFile(geojson_content.encode()), save=True) + process_file_item.delay(file_item.id) return file_item + @shared_task def merge_vector_layer_data( base_layer_id: int, @@ -31,20 +46,24 @@ def merge_vector_layer_data( exclude_non_overlapping: bool = True, properties_to_merge: Optional[List[str]] = None, flatten: bool = True, # Default to flattening - use_first_value: bool = False # first value from merged properties + use_first_value: bool = False, # first value from merged properties ) -> dict: logger.info(f'Starting merge_vector_layer_data task for base_layer_id: {base_layer_id}') try: # Load the base layer GeoDataFrame base_layer = VectorMapLayer.objects.get(id=base_layer_id) - base_gdf = gpd.GeoDataFrame.from_features(base_layer.read_geojson_data().get('features', [])) + base_gdf = gpd.GeoDataFrame.from_features( + base_layer.read_geojson_data().get('features', []) + ) # Load the secondary layers and store their properties other_gdfs = [] for layer_id in other_layer_ids: layer = VectorMapLayer.objects.get(id=layer_id) - layer_gdf = gpd.GeoDataFrame.from_features(layer.read_geojson_data().get('features', [])) + layer_gdf = gpd.GeoDataFrame.from_features( + layer.read_geojson_data().get('features', []) + ) other_gdfs.append(layer_gdf) if not other_gdfs: @@ -63,23 +82,32 @@ def merge_vector_layer_data( output_features = [] # Loop through each feature in the base layer - logger.info(f"Processing {len(base_gdf)} features from the base layer.") + logger.info(f'Processing {len(base_gdf)} features from the base layer.') for _, base_row in base_gdf.iterrows(): # Find features in the other layers that intersect or completely overlap the current base layer feature intersecting_features = [] for _, other_row in other_gdf.iterrows(): - if operation == 'intersection' and base_row['geometry'].intersects(other_row['geometry']): + if operation == 'intersection' and base_row['geometry'].intersects( + other_row['geometry'] + ): intersecting_features.append(other_row) - elif operation == 'contains' and base_row['geometry'].contains(other_row['geometry']): + elif operation == 'contains' and base_row['geometry'].contains( + other_row['geometry'] + ): intersecting_features.append(other_row) - # If exclude_non_overlapping is True and no intersection or complete overlap is found, skip this base feature if exclude_non_overlapping and not intersecting_features: continue # Prepare the properties for the current feature feature_properties = base_row.drop('geometry') + for row_dict in intersecting_features: + for item in row_dict.to_dict().keys(): + if feature_properties.get(item): + feature_properties = feature_properties.drop(item) + feature_properties = feature_properties.to_dict() + # Merge properties from the intersecting features merged_properties = [] for other_row in intersecting_features: @@ -87,7 +115,11 @@ def merge_vector_layer_data( # If properties_to_merge is provided, only merge those if properties_to_merge: - filtered_props = {key: value for key, value in other_props.items() if key in properties_to_merge} + filtered_props = { + key: value + for key, value in other_props.items() + if key in properties_to_merge + } merged_properties.append(filtered_props) else: merged_properties.append(other_props) @@ -114,16 +146,18 @@ def merge_vector_layer_data( # Convert the feature to a GeoJSON feature feature_geojson = { 'type': 'Feature', - 'geometry': base_row['geometry'].__geo_interface__, # Convert geometry to GeoJSON format - 'properties': feature_properties # Add properties to the GeoJSON feature + 'geometry': base_row[ + 'geometry' + ].__geo_interface__, # Convert geometry to GeoJSON format + 'properties': feature_properties, # Add properties to the GeoJSON feature } # Append the feature to the output list output_features.append(feature_geojson) # Save the output features as a new GeoJSON file - logger.info(f"Saving the result to {dataset_name}.geojson") - geojson = {"type": "FeatureCollection", "features": output_features} + logger.info(f'Saving the result to {dataset_name}.geojson') + geojson = {'type': 'FeatureCollection', 'features': output_features} save_geojson_to_dataset(dataset_name, geojson) return {'status': 'success', 'message': 'Vector processing completed and dataset created.'} diff --git a/uvdat/urls.py b/uvdat/urls.py index 8985291..e282ed7 100644 --- a/uvdat/urls.py +++ b/uvdat/urls.py @@ -22,10 +22,10 @@ RasterMapLayerViewSet, SimulationViewSet, SourceRegionViewSet, + TasksAPIView, UserViewSet, VectorFeatureTableDataViewSet, VectorMapLayerViewSet, - TasksAPIView, ) router = routers.SimpleRouter() From 09b636517066e9db86aaf5e7ff19952c7f82e1a8 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 10 Feb 2025 12:56:57 -0500 Subject: [PATCH 05/16] renaming NetCDFLayers --- uvdat/core/rest/map_layers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/uvdat/core/rest/map_layers.py b/uvdat/core/rest/map_layers.py index e683f3a..15f78a0 100644 --- a/uvdat/core/rest/map_layers.py +++ b/uvdat/core/rest/map_layers.py @@ -14,6 +14,7 @@ from uvdat.core.models import ( LayerRepresentation, NetCDFLayer, + NetCDFData, RasterMapLayer, VectorFeature, VectorMapLayer, @@ -580,6 +581,8 @@ def update_name(self, request, *args, **kwargs): VectorMapLayer.objects.filter(id=layer_id).update(name=new_name) elif layer_type == 'raster': RasterMapLayer.objects.filter(id=layer_id).update(name=new_name) + elif layer_type == 'netcdf': + NetCDFData.objects.filter(id=layer_id).update(name=new_name) else: return Response( {'error': 'Invalid layer type. Must be "vector" or "raster".'}, From 16fd490ff4db22dd915a3ae517f056544630f332 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 10 Feb 2025 14:55:31 -0500 Subject: [PATCH 06/16] add time mapping to netcdf layers --- client/src/components/NetCDFLayerConfig.vue | 35 ++++++++++- client/src/types.ts | 1 + scripts/netcdf_explorer.py | 31 +++++++--- uvdat/core/rest/netcdf.py | 10 ++++ uvdat/core/tasks/netcdf.py | 65 +++++++++++++++------ 5 files changed, 114 insertions(+), 28 deletions(-) diff --git a/client/src/components/NetCDFLayerConfig.vue b/client/src/components/NetCDFLayerConfig.vue index 28972df..2c5be99 100644 --- a/client/src/components/NetCDFLayerConfig.vue +++ b/client/src/components/NetCDFLayerConfig.vue @@ -27,6 +27,36 @@ export default defineComponent({ const found = visibleNetCDFLayers.find((item) => item.netCDFLayer === props.layer.id); return found?.images.length || 0; }); + const stepMapping = computed(() => { + const found = visibleNetCDFLayers.find((item) => item.netCDFLayer === props.layer.id); + const mapSlicer: Record = {}; + let unixTimeStamp = true; + if (found) { + if (found?.sliding) { + for (let i = 0; i < found.images.length; i += 1) { + mapSlicer[i] = found.sliding.min + i * found.sliding.step; + if (found.sliding.variable === 'time') { + // convert unix timestamp to human readable date YYYY-MM-DD + try { + const date = new Date((mapSlicer[i] as number) / 1e6); + // eslint-disable-next-line prefer-destructuring + mapSlicer[i] = date.toISOString().split('T')[0]; + } catch (e) { + unixTimeStamp = false; + break; + } + } + } + if (unixTimeStamp) { + return mapSlicer; + } + } + for (let i = 0; i < found.images.length; i += 1) { + mapSlicer[i] = i; + } + } + return mapSlicer; + }); const updateIndex = () => { updateNetCDFLayer(props.layer.id, currentIndex.value); }; @@ -43,6 +73,7 @@ export default defineComponent({ layerOpacity, currentIndex, totalIndex, + stepMapping, }; }, }); @@ -72,9 +103,9 @@ export default defineComponent({ mdi-checkbox-marked - Step + {{ - currentIndex + stepMapping[currentIndex] }} diff --git a/client/src/types.ts b/client/src/types.ts index f824266..63d8dda 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -441,6 +441,7 @@ export interface NetCDFImages { netCDFLayer: number; parent_bounds: [[[number, number], [number, number], [number, number], [number, number], [number, number]]]; images: string[]; + sliding: { min: number, max: number; step: number, variable: string }; } export interface NetCDFVariable { diff --git a/scripts/netcdf_explorer.py b/scripts/netcdf_explorer.py index 3f55e91..e5adcf3 100644 --- a/scripts/netcdf_explorer.py +++ b/scripts/netcdf_explorer.py @@ -1,5 +1,8 @@ import click +import cftime +import pandas as pd import xarray as xr +import datetime from PIL import Image import numpy as np import os @@ -37,16 +40,30 @@ def describe(file_path, output_json): "dtype": str(variable.dtype), "attributes": {key: str(value) for key, value in variable.attrs.items()}, } - # Calculate min and max values if the variable has numeric data - try: - var_min = float(variable.min().values) if variable.size > 0 else None - var_max = float(variable.max().values) if variable.size > 0 else None + if isinstance(variable.values[0], cftime.DatetimeNoLeap): + vals = [] + for item in variable.values: + print(item) + dt = item + dt_obj = datetime.datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + unix_timestamp = pd.Timestamp(dt_obj).timestamp() + vals.append(unix_timestamp) + print(f'TimeStamp: {unix_timestamp}') + var_min = float(min(vals)) if variable.size > 0 else None + var_max = float(max(vals)) if variable.size > 0 else None var_info["min"] = var_min var_info["max"] = var_max - except Exception: - var_info["min"] = 0 - var_info["max"] = variable.size + else: + try: + var_min = float(variable.min().values) if variable.size > 0 else None + var_max = float(variable.max().values) if variable.size > 0 else None + var_info["min"] = var_min + var_info["max"] = var_max + except Exception: + var_info["min"] = 0 + var_info["max"] = variable.size + var_info["steps"] = variable.size description["variables"][var_name] = var_info diff --git a/uvdat/core/rest/netcdf.py b/uvdat/core/rest/netcdf.py index 0941e4f..267824f 100644 --- a/uvdat/core/rest/netcdf.py +++ b/uvdat/core/rest/netcdf.py @@ -80,9 +80,19 @@ def images(self, request, *args, **kwargs): ) # Prepare the response data with image URLs and parent bounds + sliding_dim = netcdf_layer.parameters.get('sliding_dimension', None) + step_count = netcdf_layer.parameters.get('stepCount', None) + if sliding_dim and step_count: + sliding_data = { + "min": sliding_dim.get('min', 0), + "max": sliding_dim.get('max', step_count), + "variable": sliding_dim.get('variable', 'time'), + } + sliding_data['step'] = (sliding_data['max'] - sliding_data['min']) / step_count response_data = { 'netCDFLayer': int(netcdf_layer_id), 'parent_bounds': netcdf_layer.bounds.envelope.coords, # 4-tuple for the parent layer bounds + "sliding": sliding_data, 'images': [image.image.url for image in netcdf_images], # Only return the image URL } diff --git a/uvdat/core/tasks/netcdf.py b/uvdat/core/tasks/netcdf.py index fc2cc40..8f33586 100644 --- a/uvdat/core/tasks/netcdf.py +++ b/uvdat/core/tasks/netcdf.py @@ -1,10 +1,12 @@ import base64 from datetime import datetime +import cftime from io import BytesIO import logging from pathlib import Path import re import tempfile +import pandas as pd from PIL import Image from celery import current_task, shared_task @@ -47,27 +49,52 @@ def create_netcdf_data_layer(file_item, metadata): } # Calculate min and max values if the variable has numeric data - try: - var_min = float(variable.min().values) if variable.size > 0 else None - var_max = float(variable.max().values) if variable.size > 0 else None - if 'datetime' in str(variable.dtype): - var_info['startDate'] = str(variable.min().values) - var_info['endDate'] = str(variable.max().values) + if isinstance(variable.values[0], (cftime.DatetimeNoLeap, cftime.DatetimeAllLeap, cftime.Datetime360Day, cftime.DatetimeJulian)): + vals = [] + str_vals = [] + for item in variable.values: + print(item) + dt = item + dt_obj = datetime.datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + unix_timestamp = pd.Timestamp(dt_obj).timestamp() + str_vals.append(pd.Timestamp(dt_obj).isoformat()) + vals.append(unix_timestamp) + var_min = float(min(vals)) if variable.size > 0 else None + var_max = float(max(vals)) if variable.size > 0 else None var_info['min'] = var_min var_info['max'] = var_max - if var_name in description['dimensions'].keys(): - var_info['steps'] = description['dimensions'][var_name] - if re.search(r'\blat\b|\blatitude\b', var_name, re.IGNORECASE): - if -90 <= var_min <= 90 and -90 <= var_max <= 90: - var_info['geospatial'] = 'latitude' - elif re.search(r'\blon\b|\blongitude\b', var_name, re.IGNORECASE): - if -180 <= var_min <= 180 and -180 <= var_max <= 180: - var_info['geospatial'] = 'longitude' - elif 0 <= var_min <= 360 and 0 <= var_max <= 360: - var_info['geospatial'] = 'longitude360' - except Exception: - var_info['min'] = 0 - var_info['max'] = variable.size + var_info['steps'] = variable.size + # var_info['timeMap'] = str_vals + else: + try: + var_min = float(variable.min().values) if variable.size > 0 else None + var_max = float(variable.max().values) if variable.size > 0 else None + if 'datetime' in str(variable.dtype): + var_info['startDate'] = str(variable.min().values) + var_info['endDate'] = str(variable.max().values) + # str_vals = [] + # for item in variable.values: + # str_vals.append(str(item)) + # var_info['timeMap'] = str_vals + + var_info['min'] = var_min + var_info['max'] = var_max + var_info['steps'] = variable.size + + if var_name in description['dimensions'].keys(): + var_info['steps'] = description['dimensions'][var_name] + if re.search(r'\blat\b|\blatitude\b', var_name, re.IGNORECASE): + if -90 <= var_min <= 90 and -90 <= var_max <= 90: + var_info['geospatial'] = 'latitude' + elif re.search(r'\blon\b|\blongitude\b', var_name, re.IGNORECASE): + if -180 <= var_min <= 180 and -180 <= var_max <= 180: + var_info['geospatial'] = 'longitude' + elif 0 <= var_min <= 360 and 0 <= var_max <= 360: + var_info['geospatial'] = 'longitude360' + except Exception: + var_info['min'] = 0 + var_info['max'] = variable.size + var_info["steps"] = variable.size description['variables'][var_name] = var_info From 59b13896579ed52a83869e07806f720f7f7c98e3 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 11 Feb 2025 08:58:11 -0500 Subject: [PATCH 07/16] adjusting timestamps to unix base --- .../DataSelection/NetCDFDataConfigurator.vue | 16 +-- client/src/components/NetCDFLayerConfig.vue | 2 +- client/src/utils.ts | 60 +++++++++++- uvdat/core/rest/netcdf.py | 2 +- uvdat/core/tasks/netcdf.py | 97 +++++++++++++------ 5 files changed, 132 insertions(+), 45 deletions(-) diff --git a/client/src/components/DataSelection/NetCDFDataConfigurator.vue b/client/src/components/DataSelection/NetCDFDataConfigurator.vue index b98c36d..81d34d0 100644 --- a/client/src/components/DataSelection/NetCDFDataConfigurator.vue +++ b/client/src/components/DataSelection/NetCDFDataConfigurator.vue @@ -194,12 +194,12 @@ export default defineComponent({ } const data = getVariableInformation(newLayerSlice.value); if (data.startDate) { - sliceLayerRangeStep.value = (data.max / 1e6 - data.min / 1e6) / (data.steps || 1); - const startDate = new Date(data.min / 1e6); - const endDate = new Date(data.max / 1e6); + sliceLayerRangeStep.value = (data.max - data.min) / (data.steps || 1); + const startDate = new Date(data.min); + const endDate = new Date(data.max); const diffMilli = endDate.getTime() - startDate.getTime(); const differenceInHours = diffMilli / (1000 * 60 * 60); - sliceLayerRange.value = [data.min / 1e6, data.max / 1e6]; + sliceLayerRange.value = [data.min, data.max]; sliceLayerRangeStep.value = Math.round(differenceInHours / (data.steps || 1)) * (1000 * 60 * 60); } else { sliceLayerRange.value = [data.min, data.max]; @@ -654,8 +654,8 @@ export default defineComponent({ @@ -664,10 +664,10 @@ export default defineComponent({ diff --git a/client/src/components/NetCDFLayerConfig.vue b/client/src/components/NetCDFLayerConfig.vue index 2c5be99..a2c89fd 100644 --- a/client/src/components/NetCDFLayerConfig.vue +++ b/client/src/components/NetCDFLayerConfig.vue @@ -38,7 +38,7 @@ export default defineComponent({ if (found.sliding.variable === 'time') { // convert unix timestamp to human readable date YYYY-MM-DD try { - const date = new Date((mapSlicer[i] as number) / 1e6); + const date = new Date((mapSlicer[i] as number) * 1000); // eslint-disable-next-line prefer-destructuring mapSlicer[i] = date.toISOString().split('T')[0]; } catch (e) { diff --git a/client/src/utils.ts b/client/src/utils.ts index 00707e0..5b2f866 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -174,11 +174,11 @@ const formatNumPrecision = (num: number, range?: number) => { return num; }; -function convertTimestampNSToDatetimeString(timestamp: number): string { +function convertTimestampNSToDatetimeString(timestamp: number, format = 'date'): string { // Convert the nanoseconds to milliseconds - const milliseconds = Math.round(timestamp); + const seconds = Math.round(timestamp); // Create a Date object - const date = new Date(milliseconds); + const date = new Date(seconds * 1000); // Extract the parts of the date in UTC const year = date.getUTCFullYear(); const month = (date.getUTCMonth() + 1).toString().padStart(2, '0'); // Months are 0-indexed @@ -188,7 +188,57 @@ function convertTimestampNSToDatetimeString(timestamp: number): string { const hours = date.getUTCHours().toString().padStart(2, '0'); const minutes = date.getUTCMinutes().toString().padStart(2, '0'); // Format the date as YYYYMMDD HH:MM - return `${year}${month}${day} ${hours}:${minutes}`; + if (format === 'date') { + return `${year}-${month}-${day}`; + } if (format === 'year') { + return `${year}`; + } + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function formatISOToYYMMDD(dateString: string): string | null { + const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/; + + if (!isoRegex.test(dateString)) { + return null; // Not a valid ISO 8601 string + } + + try { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return null; // Invalid date + } + + const yy = String(date.getFullYear()).slice(-2); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + + return `${yy}-${mm}-${dd}`; + } catch { + return null; + } +} + +function formatCompactToISO(compactNumber: number, format: 'date' | 'datetime' = 'date'): string | null { + const compactStr = compactNumber.toString(); + + if (!/^(\d{12}|\d{8})$/.test(compactStr)) { + return null; // Invalid format + } + + const year = compactStr.slice(0, 4); + const month = compactStr.slice(4, 6); + const day = compactStr.slice(6, 8); + + if (format === 'date') { + return `${year}-${month}-${day}`; + } if (compactStr.length === 12) { + const hour = compactStr.slice(8, 10); + const minute = compactStr.slice(10, 12); + return `${year}-${month}-${day} ${hour}:${minute}`; + } + + return `${year}-${month}-${day}`; // Default fallback } function convert360Longitude(longitude: number): number { @@ -206,5 +256,7 @@ export { formatNumPrecision, createColorNumberPairs, convertTimestampNSToDatetimeString, + formatCompactToISO, + formatISOToYYMMDD, convert360Longitude, }; diff --git a/uvdat/core/rest/netcdf.py b/uvdat/core/rest/netcdf.py index 267824f..8bfa982 100644 --- a/uvdat/core/rest/netcdf.py +++ b/uvdat/core/rest/netcdf.py @@ -88,7 +88,7 @@ def images(self, request, *args, **kwargs): "max": sliding_dim.get('max', step_count), "variable": sliding_dim.get('variable', 'time'), } - sliding_data['step'] = (sliding_data['max'] - sliding_data['min']) / step_count + sliding_data['step'] = (sliding_data['max'] - sliding_data['min']) / (step_count - 1) response_data = { 'netCDFLayer': int(netcdf_layer_id), 'parent_bounds': netcdf_layer.bounds.envelope.coords, # 4-tuple for the parent layer bounds diff --git a/uvdat/core/tasks/netcdf.py b/uvdat/core/tasks/netcdf.py index 8f33586..0c35c27 100644 --- a/uvdat/core/tasks/netcdf.py +++ b/uvdat/core/tasks/netcdf.py @@ -22,6 +22,49 @@ logger = logging.getLogger(__name__) +def convert_time(obj, output='compact'): + if isinstance(obj, str): # Handle ctime (string) + dt_obj = datetime.strptime(obj, '%a %b %d %H:%M:%S %Y') + elif isinstance(obj, np.datetime64): # Handle datetime64 + dt_obj = pd.Timestamp(obj).to_pydatetime() + elif isinstance(obj, datetime): # Handle Python datetime objects + dt_obj = obj + elif isinstance(obj, (cftime.DatetimeNoLeap, cftime.DatetimeAllLeap, cftime.Datetime360Day, cftime.DatetimeJulian)): + dt_obj = datetime(obj.year, obj.month, obj.day, obj.hour, obj.minute, obj.second) + elif isinstance(obj, (int, float)): + if obj > 1e10: # Assume milliseconds timestamp + dt_obj = datetime.fromtimestamp(obj / 1000) + else: # Assume seconds timestamp + dt_obj = datetime.fromtimestamp(obj) + else: + return obj # Return as-is if the type is unrecognized + + if output == 'iso': + return dt_obj.isoformat() + elif output == 'datetime': + return dt_obj + elif output == 'compact': + return int(dt_obj.strftime('%Y%m%d%H%M%S')) + elif output == 'unix': + return dt_obj.timestamp() + + +def convert_to_timestamp(obj): + if isinstance(obj, str): # Handle ctime (string) + dt_obj = datetime.strptime(obj, '%a %b %d %H:%M:%S %Y') + return dt_obj.timestamp() + elif isinstance(obj, np.datetime64): # Handle datetime64 + return (obj - np.datetime64('1970-01-01T00:00:00')) / np.timedelta64(1, 's') + elif isinstance(obj, datetime): # Handle Python datetime objects + return obj.timestamp() + elif isinstance(obj, (cftime.DatetimeNoLeap, cftime.DatetimeAllLeap, cftime.Datetime360Day, cftime.DatetimeJulian)): + dt = obj + dt_obj = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + unix_timestamp = pd.Timestamp(dt_obj).timestamp() + return unix_timestamp + else: + return obj + def create_netcdf_data_layer(file_item, metadata): with tempfile.TemporaryDirectory() as temp_dir: raw_data_path = Path(temp_dir, 'netcdf.nc') @@ -51,20 +94,20 @@ def create_netcdf_data_layer(file_item, metadata): # Calculate min and max values if the variable has numeric data if isinstance(variable.values[0], (cftime.DatetimeNoLeap, cftime.DatetimeAllLeap, cftime.Datetime360Day, cftime.DatetimeJulian)): vals = [] - str_vals = [] for item in variable.values: print(item) dt = item - dt_obj = datetime.datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) - unix_timestamp = pd.Timestamp(dt_obj).timestamp() - str_vals.append(pd.Timestamp(dt_obj).isoformat()) - vals.append(unix_timestamp) - var_min = float(min(vals)) if variable.size > 0 else None - var_max = float(max(vals)) if variable.size > 0 else None - var_info['min'] = var_min - var_info['max'] = var_max + dt_obj = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + timeobj = convert_time(pd.Timestamp(dt_obj), 'datetime') + vals.append(timeobj) + var_min = (min(vals)) if variable.size > 0 else None + var_max = (max(vals)) if variable.size > 0 else None + var_info['min'] = convert_time(var_min, 'unix') + var_info['max'] = convert_time(var_max, 'unix') + var_info['startDate'] = convert_time(var_min, 'iso') + var_info['endDate'] = convert_time(var_max, 'iso') + var_info['timeType'] = 'unix' var_info['steps'] = variable.size - # var_info['timeMap'] = str_vals else: try: var_min = float(variable.min().values) if variable.size > 0 else None @@ -72,13 +115,17 @@ def create_netcdf_data_layer(file_item, metadata): if 'datetime' in str(variable.dtype): var_info['startDate'] = str(variable.min().values) var_info['endDate'] = str(variable.max().values) - # str_vals = [] - # for item in variable.values: - # str_vals.append(str(item)) - # var_info['timeMap'] = str_vals - - var_info['min'] = var_min - var_info['max'] = var_max + + if 'time' in var_name: + var_info['min'] = convert_time(var_min, 'unix') + var_info['max'] = convert_time(var_max, 'unix') + var_info['startDate'] = convert_time(var_min, 'iso') + var_info['endDate'] = convert_time(var_max, 'iso') + + var_info['timeType'] = 'unix' + else: + var_info['min'] = var_min + var_info['max'] = var_max var_info['steps'] = variable.size if var_name in description['dimensions'].keys(): @@ -321,18 +368,6 @@ def preview_netcdf_slice( return base64_image -def convert_to_timestamp(obj): - if isinstance(obj, str): # Handle ctime (string) - dt_obj = datetime.strptime(obj, '%a %b %d %H:%M:%S %Y') - return dt_obj.timestamp() - elif isinstance(obj, np.datetime64): # Handle datetime64 - return (obj - np.datetime64('1970-01-01T00:00:00')) / np.timedelta64(1, 's') - elif isinstance(obj, datetime): # Handle Python datetime objects - return obj.timestamp() - else: - return obj - - @shared_task def create_netcdf_slices( netcdf_data_id: int, @@ -546,8 +581,8 @@ def create_netcdf_slices( bounds = None if slicer_range: slicer_range = [ - convert_to_timestamp(slicer_range[0]), - convert_to_timestamp(slicer_range[1]), + convert_time(slicer_range[0], 'unix'), + convert_time(slicer_range[1], 'unix'), ] if variables: try: From 9ac601aaef7923fa0918b4e90a1a1008fbc764d2 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 12 Feb 2025 13:04:40 -0500 Subject: [PATCH 08/16] initial vector feature table rendering --- client/src/api/UVDATApi.ts | 27 ++ ...tureChart.vue => PropertyFeatureChart.vue} | 2 +- .../FeatureSelection/SelectedFeature.vue | 37 ++- .../FeatureSelection/VectorFeatureChart.vue | 200 ++++++++++++++ .../Metadata/FeatureChartEditor.vue | 6 +- client/src/components/PropertiesConfig.vue | 19 +- .../components/TabularData/TableSummary.vue | 253 ++++++++++++++++++ client/src/types.ts | 66 +++++ sample_data/fire.json | 64 +++++ uvdat/core/rest/map_layers.py | 2 +- uvdat/core/rest/netcdf.py | 12 +- uvdat/core/rest/vector_feature_table_data.py | 224 +++++++++++++--- uvdat/core/tasks/netcdf.py | 36 ++- 13 files changed, 887 insertions(+), 61 deletions(-) rename client/src/components/FeatureSelection/{RenderFeatureChart.vue => PropertyFeatureChart.vue} (98%) create mode 100644 client/src/components/FeatureSelection/VectorFeatureChart.vue create mode 100644 client/src/components/TabularData/TableSummary.vue create mode 100644 sample_data/fire.json diff --git a/client/src/api/UVDATApi.ts b/client/src/api/UVDATApi.ts index 197eeda..e511268 100644 --- a/client/src/api/UVDATApi.ts +++ b/client/src/api/UVDATApi.ts @@ -8,6 +8,7 @@ import { ContextWithIds, Dataset, DerivedRegion, + FeatureGraphData, FileItem, LayerCollection, LayerCollectionLayer, @@ -21,6 +22,7 @@ import { RasterData, RasterMapLayer, SimulationType, + TableSummary, VectorMapLayer, } from '../types'; @@ -494,4 +496,29 @@ export default class UVdatApi { public static async deleteContext(contextId: number): Promise<{ detail: string }> { return (await UVdatApi.apiClient.delete(`/contexts/${contextId}/`)).data; } + + public static async getVectorTableSummary(layerId: number): Promise { + return (await UVdatApi.apiClient.get('/vectorfeature/tabledata/table-summary/', { params: { layerId } })).data; + } + + public static async getFeatureGraphData( + tableType: string, + vectorFeatureId: number, + xAxis: string = 'index', + yAxis: string = 'mean_va', + filter?: string, + filterVal?: string, + ): Promise { + const response = await UVdatApi.apiClient.get('/vectorfeature/tabledata/feature-graph/', { + params: { + tableType, + vectorFeatureId, + xAxis, + yAxis, + filter, + filterVal, + }, + }); + return response.data; + } } diff --git a/client/src/components/FeatureSelection/RenderFeatureChart.vue b/client/src/components/FeatureSelection/PropertyFeatureChart.vue similarity index 98% rename from client/src/components/FeatureSelection/RenderFeatureChart.vue rename to client/src/components/FeatureSelection/PropertyFeatureChart.vue index 5fd08b3..80e285e 100644 --- a/client/src/components/FeatureSelection/RenderFeatureChart.vue +++ b/client/src/components/FeatureSelection/PropertyFeatureChart.vue @@ -7,7 +7,7 @@ import { drawBarChart } from '../Metadata/drawChart'; // FeatureChart TypeScript interface (as provided) export default defineComponent({ - name: 'RenderFeatureChart', + name: 'PropertyFeatureChart', props: { featureChart: { type: Object as PropType, diff --git a/client/src/components/FeatureSelection/SelectedFeature.vue b/client/src/components/FeatureSelection/SelectedFeature.vue index 1838195..6b30e08 100644 --- a/client/src/components/FeatureSelection/SelectedFeature.vue +++ b/client/src/components/FeatureSelection/SelectedFeature.vue @@ -3,13 +3,15 @@ import { PropType, Ref, computed, defineComponent, onMounted, ref, } from 'vue'; import MapStore from '../../MapStore'; -import { ClickedProps, FeatureChartWithData } from '../../types'; +import { ClickedProps, FeatureChartWithData, VectorFeatureTableGraph } from '../../types'; import { colorGenerator } from '../../map/mapColors'; -import RenderFeatureChart from './RenderFeatureChart.vue'; +import PropertyFeatureChart from './PropertyFeatureChart.vue'; +import VectorFeatureChart from './VectorFeatureChart.vue'; export default defineComponent({ components: { - RenderFeatureChart, + PropertyFeatureChart, + VectorFeatureChart, }, props: { data: { @@ -28,6 +30,8 @@ export default defineComponent({ const mapLayerId = ref(props.data.layerId); const featureId = ref(props.data.id as number); const enabledChartPanels: Ref = ref([]); + const enabledVectorChartPanel: Ref = ref([]); + const vectorGraphs: Ref = ref([]); const processLayerProps = () => { const found = MapStore.selectedVectorMapLayers.value.find((item) => item.id === props.data.layerId); if (found?.default_style.properties) { @@ -65,6 +69,9 @@ export default defineComponent({ }); } } + if (found?.default_style.vectorFeatureTableGraphs) { + vectorGraphs.value = found.default_style.vectorFeatureTableGraphs; + } }; onMounted(() => processLayerProps()); @@ -81,8 +88,10 @@ export default defineComponent({ featureId, featureCharts, enabledChartPanels, + enabledVectorChartPanel, selectedFeatureExpanded, toggleFeatureExpaned, + vectorGraphs, }; }, }); @@ -137,7 +146,24 @@ export default defineComponent({ {{ featureChart.name }} - + + + + + + + + + mdi-chart-line + {{ vectorGraph.name }} + + + @@ -146,4 +172,7 @@ export default defineComponent({ diff --git a/client/src/components/FeatureSelection/VectorFeatureChart.vue b/client/src/components/FeatureSelection/VectorFeatureChart.vue new file mode 100644 index 0000000..a33f7ab --- /dev/null +++ b/client/src/components/FeatureSelection/VectorFeatureChart.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/client/src/components/Metadata/FeatureChartEditor.vue b/client/src/components/Metadata/FeatureChartEditor.vue index 03045ac..b160a4f 100644 --- a/client/src/components/Metadata/FeatureChartEditor.vue +++ b/client/src/components/Metadata/FeatureChartEditor.vue @@ -7,13 +7,13 @@ import { FeatureChart } from '../../types'; import MapStore from '../../MapStore'; import { drawBarChart } from './drawChart'; // Separate drawChart function import { colorGenerator } from '../../map/mapColors'; -import RenderFeatureChart from '../FeatureSelection/RenderFeatureChart.vue'; +import PropertyFeatureChart from '../FeatureSelection/PropertyFeatureChart.vue'; export default defineComponent({ name: 'FeatureChartEditor', components: { draggable, - RenderFeatureChart, + PropertyFeatureChart, }, props: { layerId: { @@ -278,7 +278,7 @@ export default defineComponent({ {{ chart.name }} - + diff --git a/client/src/components/PropertiesConfig.vue b/client/src/components/PropertiesConfig.vue index 9bf2540..149707b 100644 --- a/client/src/components/PropertiesConfig.vue +++ b/client/src/components/PropertiesConfig.vue @@ -5,12 +5,14 @@ import { import AvailableProperties from './Metadata/AvailableProperties.vue'; import MetadataSettings from './Metadata/MetadataSettings.vue'; import SelectedFeatureChartCard from './Metadata/SelectedFeatureChartCard.vue'; +import TableSummary from './TabularData/TableSummary.vue'; export default defineComponent({ components: { AvailableProperties, MetadataSettings, SelectedFeatureChartCard, + TableSummary, }, props: { layerId: { @@ -19,7 +21,7 @@ export default defineComponent({ }, }, setup() { - const tab: Ref<'availableProperties' | 'settings' | 'charts'> = ref('availableProperties'); + const tab: Ref<'availableProperties' | 'settings' | 'charts' | 'tabularData'> = ref('availableProperties'); return { tab, @@ -72,12 +74,27 @@ export default defineComponent({ + + + +